1354 lines
39 KiB
Markdown
1354 lines
39 KiB
Markdown
|
|
# 📋 수주 등록 화면 개발 계획서
|
|||
|
|
|
|||
|
|
## 📌 프로젝트 개요
|
|||
|
|
|
|||
|
|
**목표**: 수주 등록 모달 화면 구현을 위한 범용 컴포넌트 개발 및 전용 화면 구성
|
|||
|
|
|
|||
|
|
**기간**: 약 4-5일
|
|||
|
|
|
|||
|
|
**전략**: 핵심 범용 컴포넌트 우선 개발 → 전용 화면 구성 → 점진적 확장
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 🎯 Phase 1: 범용 컴포넌트 개발 (2-3일)
|
|||
|
|
|
|||
|
|
### 1.1 EntitySearchInput 컴포넌트 ⭐⭐⭐
|
|||
|
|
|
|||
|
|
**목적**: 엔티티 테이블(거래처, 품목, 사용자 등)에서 데이터를 검색하고 선택하는 범용 입력 컴포넌트
|
|||
|
|
|
|||
|
|
#### 주요 기능
|
|||
|
|
|
|||
|
|
- 자동완성 검색 (타이핑 시 실시간 검색)
|
|||
|
|
- 모달 검색 (버튼 클릭 → 전체 목록 + 검색)
|
|||
|
|
- 콤보 모드 (입력 필드 + 검색 버튼)
|
|||
|
|
- 다중 필드 검색 지원
|
|||
|
|
- 선택된 항목 표시 및 초기화
|
|||
|
|
|
|||
|
|
#### 인터페이스 설계
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// frontend/lib/registry/components/entity-search-input/types.ts
|
|||
|
|
|
|||
|
|
export interface EntitySearchInputProps {
|
|||
|
|
// 데이터 소스
|
|||
|
|
tableName: string; // 검색할 테이블명 (예: "customer_mng")
|
|||
|
|
displayField: string; // 표시할 필드 (예: "customer_name")
|
|||
|
|
valueField: string; // 값으로 사용할 필드 (예: "customer_code")
|
|||
|
|
searchFields?: string[]; // 검색 대상 필드들 (기본: [displayField])
|
|||
|
|
|
|||
|
|
// UI 모드
|
|||
|
|
mode?: "autocomplete" | "modal" | "combo"; // 기본: "combo"
|
|||
|
|
placeholder?: string;
|
|||
|
|
disabled?: boolean;
|
|||
|
|
|
|||
|
|
// 필터링
|
|||
|
|
filterCondition?: Record<string, any>; // 추가 WHERE 조건
|
|||
|
|
companyCode?: string; // 멀티테넌시
|
|||
|
|
|
|||
|
|
// 선택된 값
|
|||
|
|
value?: any;
|
|||
|
|
onChange?: (value: any, fullData?: any) => void;
|
|||
|
|
|
|||
|
|
// 모달 설정 (mode가 "modal" 또는 "combo"일 때)
|
|||
|
|
modalTitle?: string;
|
|||
|
|
modalColumns?: string[]; // 모달에 표시할 컬럼들
|
|||
|
|
|
|||
|
|
// 추가 표시 정보
|
|||
|
|
showAdditionalInfo?: boolean; // 선택 후 추가 정보 표시 (예: 주소)
|
|||
|
|
additionalFields?: string[]; // 추가로 표시할 필드들
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 파일 구조
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
frontend/lib/registry/components/entity-search-input/
|
|||
|
|
├── EntitySearchInputComponent.tsx # 메인 컴포넌트
|
|||
|
|
├── EntitySearchModal.tsx # 검색 모달
|
|||
|
|
├── types.ts # 타입 정의
|
|||
|
|
├── useEntitySearch.ts # 검색 로직 훅
|
|||
|
|
└── EntitySearchInputConfig.tsx # 속성 편집 패널
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### API 엔드포인트 (백엔드)
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// backend-node/src/controllers/entitySearchController.ts
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* GET /api/entity-search/:tableName
|
|||
|
|
* Query params:
|
|||
|
|
* - searchText: 검색어
|
|||
|
|
* - searchFields: 검색할 필드들 (콤마 구분)
|
|||
|
|
* - filterCondition: JSON 형식의 추가 조건
|
|||
|
|
* - page, limit: 페이징
|
|||
|
|
*/
|
|||
|
|
router.get("/api/entity-search/:tableName", async (req, res) => {
|
|||
|
|
const { tableName } = req.params;
|
|||
|
|
const {
|
|||
|
|
searchText,
|
|||
|
|
searchFields,
|
|||
|
|
filterCondition,
|
|||
|
|
page = 1,
|
|||
|
|
limit = 20,
|
|||
|
|
} = req.query;
|
|||
|
|
|
|||
|
|
// 멀티테넌시 자동 적용
|
|||
|
|
const companyCode = req.user.companyCode;
|
|||
|
|
|
|||
|
|
// 검색 실행
|
|||
|
|
const results = await entitySearchService.search({
|
|||
|
|
tableName,
|
|||
|
|
searchText,
|
|||
|
|
searchFields: searchFields?.split(","),
|
|||
|
|
filterCondition: filterCondition ? JSON.parse(filterCondition) : {},
|
|||
|
|
companyCode,
|
|||
|
|
page: parseInt(page),
|
|||
|
|
limit: parseInt(limit),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
res.json({ success: true, data: results });
|
|||
|
|
});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 사용 예시
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
// 거래처 검색 - 콤보 모드 (입력 + 버튼)
|
|||
|
|
<EntitySearchInput
|
|||
|
|
tableName="customer_mng"
|
|||
|
|
displayField="customer_name"
|
|||
|
|
valueField="customer_code"
|
|||
|
|
searchFields={["customer_name", "customer_code", "business_number"]}
|
|||
|
|
mode="combo"
|
|||
|
|
placeholder="거래처를 검색하세요"
|
|||
|
|
modalTitle="거래처 검색 및 선택"
|
|||
|
|
modalColumns={["customer_code", "customer_name", "address", "tel"]}
|
|||
|
|
showAdditionalInfo
|
|||
|
|
additionalFields={["address", "tel", "business_number"]}
|
|||
|
|
value={formData.customerCode}
|
|||
|
|
onChange={(code, fullData) => {
|
|||
|
|
setFormData({
|
|||
|
|
...formData,
|
|||
|
|
customerCode: code,
|
|||
|
|
customerName: fullData.customer_name,
|
|||
|
|
customerAddress: fullData.address,
|
|||
|
|
});
|
|||
|
|
}}
|
|||
|
|
/>
|
|||
|
|
|
|||
|
|
// 품목 검색 - 모달 전용
|
|||
|
|
<EntitySearchInput
|
|||
|
|
tableName="item_info"
|
|||
|
|
displayField="item_name"
|
|||
|
|
valueField="item_code"
|
|||
|
|
mode="modal"
|
|||
|
|
placeholder="품목 선택"
|
|||
|
|
modalTitle="품목 검색"
|
|||
|
|
value={formData.itemCode}
|
|||
|
|
onChange={(code, fullData) => {
|
|||
|
|
setFormData({
|
|||
|
|
...formData,
|
|||
|
|
itemCode: code,
|
|||
|
|
itemName: fullData.item_name,
|
|||
|
|
unitPrice: fullData.unit_price,
|
|||
|
|
});
|
|||
|
|
}}
|
|||
|
|
/>
|
|||
|
|
|
|||
|
|
// 사용자 검색 - 자동완성 전용
|
|||
|
|
<EntitySearchInput
|
|||
|
|
tableName="user_info"
|
|||
|
|
displayField="user_name"
|
|||
|
|
valueField="user_id"
|
|||
|
|
searchFields={["user_name", "user_id", "email"]}
|
|||
|
|
mode="autocomplete"
|
|||
|
|
placeholder="사용자 검색"
|
|||
|
|
value={formData.userId}
|
|||
|
|
onChange={(userId, userData) => {
|
|||
|
|
setFormData({
|
|||
|
|
...formData,
|
|||
|
|
userId,
|
|||
|
|
userName: userData.user_name,
|
|||
|
|
});
|
|||
|
|
}}
|
|||
|
|
/>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### 1.2 ModalRepeaterTable 컴포넌트 ⭐⭐
|
|||
|
|
|
|||
|
|
**목적**: 모달에서 데이터를 검색하여 선택하고, 선택된 항목들을 동적 테이블(Repeater)에 추가하는 범용 컴포넌트
|
|||
|
|
|
|||
|
|
#### 주요 기능
|
|||
|
|
|
|||
|
|
- 모달 버튼 클릭 → 소스 테이블 검색 모달 열기
|
|||
|
|
- 다중 선택 지원 (체크박스)
|
|||
|
|
- 선택한 항목들을 Repeater 테이블에 추가
|
|||
|
|
- 추가된 행의 필드 편집 가능 (수량, 단가 등)
|
|||
|
|
- 계산 필드 지원 (수량 × 단가 = 금액)
|
|||
|
|
- 행 삭제 기능
|
|||
|
|
- 중복 방지 (이미 추가된 항목은 선택 불가)
|
|||
|
|
|
|||
|
|
#### 인터페이스 설계
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// frontend/lib/registry/components/modal-repeater-table/types.ts
|
|||
|
|
|
|||
|
|
export interface ModalRepeaterTableProps {
|
|||
|
|
// 소스 데이터 (모달에서 가져올 데이터)
|
|||
|
|
sourceTable: string; // 검색할 테이블 (예: "item_info")
|
|||
|
|
sourceColumns: string[]; // 모달에 표시할 컬럼들
|
|||
|
|
sourceSearchFields?: string[]; // 검색 가능한 필드들
|
|||
|
|
|
|||
|
|
// 모달 설정
|
|||
|
|
modalTitle: string; // 모달 제목 (예: "품목 검색 및 선택")
|
|||
|
|
modalButtonText?: string; // 모달 열기 버튼 텍스트 (기본: "품목 검색")
|
|||
|
|
multiSelect?: boolean; // 다중 선택 허용 (기본: true)
|
|||
|
|
|
|||
|
|
// Repeater 테이블 설정
|
|||
|
|
columns: RepeaterColumnConfig[]; // 테이블 컬럼 설정
|
|||
|
|
|
|||
|
|
// 계산 규칙
|
|||
|
|
calculationRules?: CalculationRule[]; // 자동 계산 규칙
|
|||
|
|
|
|||
|
|
// 데이터
|
|||
|
|
value: any[]; // 현재 추가된 항목들
|
|||
|
|
onChange: (newData: any[]) => void; // 데이터 변경 콜백
|
|||
|
|
|
|||
|
|
// 중복 체크
|
|||
|
|
uniqueField?: string; // 중복 체크할 필드 (예: "item_code")
|
|||
|
|
|
|||
|
|
// 필터링
|
|||
|
|
filterCondition?: Record<string, any>;
|
|||
|
|
companyCode?: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface RepeaterColumnConfig {
|
|||
|
|
field: string; // 필드명
|
|||
|
|
label: string; // 컬럼 헤더 라벨
|
|||
|
|
type?: "text" | "number" | "date" | "select"; // 입력 타입
|
|||
|
|
editable?: boolean; // 편집 가능 여부
|
|||
|
|
calculated?: boolean; // 계산 필드 여부
|
|||
|
|
width?: string; // 컬럼 너비
|
|||
|
|
required?: boolean; // 필수 입력 여부
|
|||
|
|
defaultValue?: any; // 기본값
|
|||
|
|
selectOptions?: { value: string; label: string }[]; // select일 때 옵션
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface CalculationRule {
|
|||
|
|
result: string; // 결과를 저장할 필드
|
|||
|
|
formula: string; // 계산 공식 (예: "quantity * unit_price")
|
|||
|
|
dependencies: string[]; // 의존하는 필드들 (자동 추출 가능)
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 파일 구조
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
frontend/lib/registry/components/modal-repeater-table/
|
|||
|
|
├── ModalRepeaterTableComponent.tsx # 메인 컴포넌트
|
|||
|
|
├── ItemSelectionModal.tsx # 항목 선택 모달
|
|||
|
|
├── RepeaterTable.tsx # 동적 테이블 (편집 가능)
|
|||
|
|
├── types.ts # 타입 정의
|
|||
|
|
├── useCalculation.ts # 계산 로직 훅
|
|||
|
|
└── ModalRepeaterTableConfig.tsx # 속성 편집 패널
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 사용 예시
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
// 품목 추가 테이블 (수주 등록)
|
|||
|
|
<ModalRepeaterTable
|
|||
|
|
sourceTable="item_info"
|
|||
|
|
sourceColumns={["item_code", "item_name", "spec", "unit", "unit_price"]}
|
|||
|
|
sourceSearchFields={["item_code", "item_name", "spec"]}
|
|||
|
|
modalTitle="품목 검색 및 선택"
|
|||
|
|
modalButtonText="품목 검색"
|
|||
|
|
multiSelect={true}
|
|||
|
|
columns={[
|
|||
|
|
{ field: "item_code", label: "품번", editable: false, width: "120px" },
|
|||
|
|
{ field: "item_name", label: "품명", editable: false, width: "200px" },
|
|||
|
|
{ field: "spec", label: "규격", editable: false, width: "150px" },
|
|||
|
|
{
|
|||
|
|
field: "quantity",
|
|||
|
|
label: "수량",
|
|||
|
|
type: "number",
|
|||
|
|
editable: true,
|
|||
|
|
required: true,
|
|||
|
|
defaultValue: 1,
|
|||
|
|
width: "100px",
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
field: "unit_price",
|
|||
|
|
label: "단가",
|
|||
|
|
type: "number",
|
|||
|
|
editable: true,
|
|||
|
|
required: true,
|
|||
|
|
width: "120px",
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
field: "amount",
|
|||
|
|
label: "금액",
|
|||
|
|
type: "number",
|
|||
|
|
editable: false,
|
|||
|
|
calculated: true,
|
|||
|
|
width: "120px",
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
field: "delivery_date",
|
|||
|
|
label: "납품일",
|
|||
|
|
type: "date",
|
|||
|
|
editable: true,
|
|||
|
|
width: "130px",
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
field: "note",
|
|||
|
|
label: "비고",
|
|||
|
|
type: "text",
|
|||
|
|
editable: true,
|
|||
|
|
width: "200px",
|
|||
|
|
},
|
|||
|
|
]}
|
|||
|
|
calculationRules={[
|
|||
|
|
{
|
|||
|
|
result: "amount",
|
|||
|
|
formula: "quantity * unit_price",
|
|||
|
|
dependencies: ["quantity", "unit_price"],
|
|||
|
|
},
|
|||
|
|
]}
|
|||
|
|
uniqueField="item_code"
|
|||
|
|
value={selectedItems}
|
|||
|
|
onChange={(newItems) => {
|
|||
|
|
setSelectedItems(newItems);
|
|||
|
|
// 전체 금액 재계산
|
|||
|
|
const totalAmount = newItems.reduce(
|
|||
|
|
(sum, item) => sum + (item.amount || 0),
|
|||
|
|
0
|
|||
|
|
);
|
|||
|
|
setFormData({ ...formData, totalAmount });
|
|||
|
|
}}
|
|||
|
|
/>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 컴포넌트 동작 흐름
|
|||
|
|
|
|||
|
|
1. **초기 렌더링**
|
|||
|
|
|
|||
|
|
- "품목 검색" 버튼 표시
|
|||
|
|
- 현재 추가된 항목들을 테이블로 표시
|
|||
|
|
|
|||
|
|
2. **모달 열기**
|
|||
|
|
|
|||
|
|
- 버튼 클릭 → `ItemSelectionModal` 열림
|
|||
|
|
- `sourceTable`에서 데이터 조회 (페이징, 검색 지원)
|
|||
|
|
- 이미 추가된 항목은 체크박스 비활성화 (중복 방지)
|
|||
|
|
|
|||
|
|
3. **항목 선택 및 추가**
|
|||
|
|
|
|||
|
|
- 체크박스로 다중 선택
|
|||
|
|
- "추가" 버튼 클릭 → 선택된 항목들이 `value` 배열에 추가
|
|||
|
|
- `onChange` 콜백 호출
|
|||
|
|
|
|||
|
|
4. **편집**
|
|||
|
|
|
|||
|
|
- 추가된 행의 편집 가능한 필드 클릭 → 인라인 편집
|
|||
|
|
- `editable: true`인 필드만 편집 가능
|
|||
|
|
- 값 변경 시 → 계산 필드 자동 업데이트
|
|||
|
|
|
|||
|
|
5. **계산 필드 업데이트**
|
|||
|
|
|
|||
|
|
- `calculationRules`에 따라 자동 계산
|
|||
|
|
- 예: `quantity` 또는 `unit_price` 변경 시 → `amount` 자동 계산
|
|||
|
|
|
|||
|
|
6. **행 삭제**
|
|||
|
|
- 각 행의 삭제 버튼 클릭 → 해당 항목 제거
|
|||
|
|
- `onChange` 콜백 호출
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 🎯 Phase 2: 백엔드 API 개발 (1일)
|
|||
|
|
|
|||
|
|
### 2.1 엔티티 검색 API
|
|||
|
|
|
|||
|
|
**파일**: `backend-node/src/controllers/entitySearchController.ts`
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
import { Request, Response } from "express";
|
|||
|
|
import pool from "../database/pool";
|
|||
|
|
import logger from "../utils/logger";
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 엔티티 검색
|
|||
|
|
* GET /api/entity-search/:tableName
|
|||
|
|
*/
|
|||
|
|
export async function searchEntity(req: Request, res: Response) {
|
|||
|
|
try {
|
|||
|
|
const { tableName } = req.params;
|
|||
|
|
const {
|
|||
|
|
searchText = "",
|
|||
|
|
searchFields = "",
|
|||
|
|
filterCondition = "{}",
|
|||
|
|
page = "1",
|
|||
|
|
limit = "20",
|
|||
|
|
} = req.query;
|
|||
|
|
|
|||
|
|
// 멀티테넌시
|
|||
|
|
const companyCode = req.user!.companyCode;
|
|||
|
|
|
|||
|
|
// 검색 필드 파싱
|
|||
|
|
const fields = searchFields ? (searchFields as string).split(",") : [];
|
|||
|
|
|
|||
|
|
// WHERE 조건 생성
|
|||
|
|
const whereConditions: string[] = [];
|
|||
|
|
const params: any[] = [];
|
|||
|
|
let paramIndex = 1;
|
|||
|
|
|
|||
|
|
// 멀티테넌시 필터링
|
|||
|
|
if (companyCode !== "*") {
|
|||
|
|
whereConditions.push(`company_code = $${paramIndex}`);
|
|||
|
|
params.push(companyCode);
|
|||
|
|
paramIndex++;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 검색 조건
|
|||
|
|
if (searchText && fields.length > 0) {
|
|||
|
|
const searchConditions = fields.map((field) => {
|
|||
|
|
const condition = `${field}::text ILIKE $${paramIndex}`;
|
|||
|
|
paramIndex++;
|
|||
|
|
return condition;
|
|||
|
|
});
|
|||
|
|
whereConditions.push(`(${searchConditions.join(" OR ")})`);
|
|||
|
|
|
|||
|
|
// 검색어 파라미터 추가
|
|||
|
|
fields.forEach(() => {
|
|||
|
|
params.push(`%${searchText}%`);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 추가 필터 조건
|
|||
|
|
const additionalFilter = JSON.parse(filterCondition as string);
|
|||
|
|
for (const [key, value] of Object.entries(additionalFilter)) {
|
|||
|
|
whereConditions.push(`${key} = $${paramIndex}`);
|
|||
|
|
params.push(value);
|
|||
|
|
paramIndex++;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 페이징
|
|||
|
|
const offset = (parseInt(page as string) - 1) * parseInt(limit as string);
|
|||
|
|
const whereClause =
|
|||
|
|
whereConditions.length > 0
|
|||
|
|
? `WHERE ${whereConditions.join(" AND ")}`
|
|||
|
|
: "";
|
|||
|
|
|
|||
|
|
// 쿼리 실행
|
|||
|
|
const countQuery = `SELECT COUNT(*) FROM ${tableName} ${whereClause}`;
|
|||
|
|
const dataQuery = `SELECT * FROM ${tableName} ${whereClause} ORDER BY id DESC LIMIT $${paramIndex} OFFSET $${
|
|||
|
|
paramIndex + 1
|
|||
|
|
}`;
|
|||
|
|
|
|||
|
|
params.push(parseInt(limit as string));
|
|||
|
|
params.push(offset);
|
|||
|
|
|
|||
|
|
const countResult = await pool.query(
|
|||
|
|
countQuery,
|
|||
|
|
params.slice(0, params.length - 2)
|
|||
|
|
);
|
|||
|
|
const dataResult = await pool.query(dataQuery, params);
|
|||
|
|
|
|||
|
|
logger.info("엔티티 검색 성공", {
|
|||
|
|
tableName,
|
|||
|
|
searchText,
|
|||
|
|
companyCode,
|
|||
|
|
rowCount: dataResult.rowCount,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
res.json({
|
|||
|
|
success: true,
|
|||
|
|
data: dataResult.rows,
|
|||
|
|
pagination: {
|
|||
|
|
total: parseInt(countResult.rows[0].count),
|
|||
|
|
page: parseInt(page as string),
|
|||
|
|
limit: parseInt(limit as string),
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
} catch (error: any) {
|
|||
|
|
logger.error("엔티티 검색 오류", { error: error.message });
|
|||
|
|
res.status(500).json({ success: false, message: error.message });
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**라우트 등록**: `backend-node/src/routes/index.ts`
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
import { Router } from "express";
|
|||
|
|
import { authenticateToken } from "../middleware/authMiddleware";
|
|||
|
|
import { searchEntity } from "../controllers/entitySearchController";
|
|||
|
|
|
|||
|
|
const router = Router();
|
|||
|
|
|
|||
|
|
// 엔티티 검색
|
|||
|
|
router.get("/api/entity-search/:tableName", authenticateToken, searchEntity);
|
|||
|
|
|
|||
|
|
export default router;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### 2.2 수주 등록 API (기본)
|
|||
|
|
|
|||
|
|
**파일**: `backend-node/src/controllers/orderController.ts`
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
import { Request, Response } from "express";
|
|||
|
|
import pool from "../database/pool";
|
|||
|
|
import logger from "../utils/logger";
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 수주 등록
|
|||
|
|
* POST /api/orders
|
|||
|
|
*/
|
|||
|
|
export async function createOrder(req: Request, res: Response) {
|
|||
|
|
const client = await pool.connect();
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
await client.query("BEGIN");
|
|||
|
|
|
|||
|
|
const {
|
|||
|
|
inputMode, // 입력 방식
|
|||
|
|
customerCode, // 거래처 코드
|
|||
|
|
deliveryDate, // 납품일
|
|||
|
|
items, // 품목 목록
|
|||
|
|
memo, // 메모
|
|||
|
|
} = req.body;
|
|||
|
|
|
|||
|
|
// 멀티테넌시
|
|||
|
|
const companyCode = req.user!.companyCode;
|
|||
|
|
const userId = req.user!.userId;
|
|||
|
|
|
|||
|
|
// 수주 마스터 생성
|
|||
|
|
const orderQuery = `
|
|||
|
|
INSERT INTO order_mng_master (
|
|||
|
|
company_code,
|
|||
|
|
order_no,
|
|||
|
|
customer_code,
|
|||
|
|
input_mode,
|
|||
|
|
delivery_date,
|
|||
|
|
total_amount,
|
|||
|
|
memo,
|
|||
|
|
created_by,
|
|||
|
|
created_at
|
|||
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())
|
|||
|
|
RETURNING *
|
|||
|
|
`;
|
|||
|
|
|
|||
|
|
// 수주 번호 자동 생성 (채번 규칙 활용 - 별도 구현 필요)
|
|||
|
|
const orderNo = await generateOrderNumber(companyCode);
|
|||
|
|
|
|||
|
|
// 전체 금액 계산
|
|||
|
|
const totalAmount = items.reduce(
|
|||
|
|
(sum: number, item: any) => sum + (item.amount || 0),
|
|||
|
|
0
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const orderResult = await client.query(orderQuery, [
|
|||
|
|
companyCode,
|
|||
|
|
orderNo,
|
|||
|
|
customerCode,
|
|||
|
|
inputMode,
|
|||
|
|
deliveryDate,
|
|||
|
|
totalAmount,
|
|||
|
|
memo,
|
|||
|
|
userId,
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
const orderId = orderResult.rows[0].id;
|
|||
|
|
|
|||
|
|
// 수주 상세 (품목) 생성
|
|||
|
|
for (const item of items) {
|
|||
|
|
const itemQuery = `
|
|||
|
|
INSERT INTO order_mng_sub (
|
|||
|
|
company_code,
|
|||
|
|
order_id,
|
|||
|
|
item_code,
|
|||
|
|
item_name,
|
|||
|
|
spec,
|
|||
|
|
quantity,
|
|||
|
|
unit_price,
|
|||
|
|
amount,
|
|||
|
|
delivery_date,
|
|||
|
|
note
|
|||
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
|||
|
|
`;
|
|||
|
|
|
|||
|
|
await client.query(itemQuery, [
|
|||
|
|
companyCode,
|
|||
|
|
orderId,
|
|||
|
|
item.item_code,
|
|||
|
|
item.item_name,
|
|||
|
|
item.spec,
|
|||
|
|
item.quantity,
|
|||
|
|
item.unit_price,
|
|||
|
|
item.amount,
|
|||
|
|
item.delivery_date,
|
|||
|
|
item.note,
|
|||
|
|
]);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await client.query("COMMIT");
|
|||
|
|
|
|||
|
|
logger.info("수주 등록 성공", {
|
|||
|
|
companyCode,
|
|||
|
|
orderNo,
|
|||
|
|
orderId,
|
|||
|
|
itemCount: items.length,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
res.json({
|
|||
|
|
success: true,
|
|||
|
|
data: {
|
|||
|
|
orderId,
|
|||
|
|
orderNo,
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
} catch (error: any) {
|
|||
|
|
await client.query("ROLLBACK");
|
|||
|
|
logger.error("수주 등록 오류", { error: error.message });
|
|||
|
|
res.status(500).json({ success: false, message: error.message });
|
|||
|
|
} finally {
|
|||
|
|
client.release();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 수주 번호 생성 함수 (예시 - 실제로는 채번 규칙 시스템 활용)
|
|||
|
|
async function generateOrderNumber(companyCode: string): Promise<string> {
|
|||
|
|
const today = new Date();
|
|||
|
|
const year = today.getFullYear().toString().slice(2);
|
|||
|
|
const month = String(today.getMonth() + 1).padStart(2, "0");
|
|||
|
|
|
|||
|
|
// 당일 수주 카운트 조회
|
|||
|
|
const countQuery = `
|
|||
|
|
SELECT COUNT(*) FROM order_mng_master
|
|||
|
|
WHERE company_code = $1
|
|||
|
|
AND DATE(created_at) = CURRENT_DATE
|
|||
|
|
`;
|
|||
|
|
|
|||
|
|
const result = await pool.query(countQuery, [companyCode]);
|
|||
|
|
const seq = parseInt(result.rows[0].count) + 1;
|
|||
|
|
|
|||
|
|
return `ORD${year}${month}${String(seq).padStart(4, "0")}`;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 🎯 Phase 3: 수주 등록 전용 컴포넌트 (1일)
|
|||
|
|
|
|||
|
|
### 3.1 OrderRegistrationModal 컴포넌트
|
|||
|
|
|
|||
|
|
**파일**: `frontend/components/order/OrderRegistrationModal.tsx`
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
"use client";
|
|||
|
|
|
|||
|
|
import React, { useState } from "react";
|
|||
|
|
import {
|
|||
|
|
Dialog,
|
|||
|
|
DialogContent,
|
|||
|
|
DialogDescription,
|
|||
|
|
DialogFooter,
|
|||
|
|
DialogHeader,
|
|||
|
|
DialogTitle,
|
|||
|
|
} from "@/components/ui/dialog";
|
|||
|
|
import { Button } from "@/components/ui/button";
|
|||
|
|
import { Label } from "@/components/ui/label";
|
|||
|
|
import { Input } from "@/components/ui/input";
|
|||
|
|
import {
|
|||
|
|
Select,
|
|||
|
|
SelectContent,
|
|||
|
|
SelectItem,
|
|||
|
|
SelectTrigger,
|
|||
|
|
SelectValue,
|
|||
|
|
} from "@/components/ui/select";
|
|||
|
|
import { Textarea } from "@/components/ui/textarea";
|
|||
|
|
import { EntitySearchInput } from "@/lib/registry/components/entity-search-input/EntitySearchInputComponent";
|
|||
|
|
import { ModalRepeaterTable } from "@/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent";
|
|||
|
|
import { toast } from "sonner";
|
|||
|
|
import apiClient from "@/lib/api/client";
|
|||
|
|
|
|||
|
|
interface OrderRegistrationModalProps {
|
|||
|
|
open: boolean;
|
|||
|
|
onOpenChange: (open: boolean) => void;
|
|||
|
|
onSuccess?: () => void;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function OrderRegistrationModal({
|
|||
|
|
open,
|
|||
|
|
onOpenChange,
|
|||
|
|
onSuccess,
|
|||
|
|
}: OrderRegistrationModalProps) {
|
|||
|
|
// 입력 방식
|
|||
|
|
const [inputMode, setInputMode] = useState<string>("customer_first");
|
|||
|
|
|
|||
|
|
// 폼 데이터
|
|||
|
|
const [formData, setFormData] = useState<any>({
|
|||
|
|
customerCode: "",
|
|||
|
|
customerName: "",
|
|||
|
|
deliveryDate: "",
|
|||
|
|
memo: "",
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 선택된 품목 목록
|
|||
|
|
const [selectedItems, setSelectedItems] = useState<any[]>([]);
|
|||
|
|
|
|||
|
|
// 저장 중
|
|||
|
|
const [isSaving, setIsSaving] = useState(false);
|
|||
|
|
|
|||
|
|
// 저장 처리
|
|||
|
|
const handleSave = async () => {
|
|||
|
|
try {
|
|||
|
|
// 유효성 검사
|
|||
|
|
if (!formData.customerCode) {
|
|||
|
|
toast.error("거래처를 선택해주세요");
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (selectedItems.length === 0) {
|
|||
|
|
toast.error("품목을 추가해주세요");
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
setIsSaving(true);
|
|||
|
|
|
|||
|
|
// 수주 등록 API 호출
|
|||
|
|
const response = await apiClient.post("/api/orders", {
|
|||
|
|
inputMode,
|
|||
|
|
customerCode: formData.customerCode,
|
|||
|
|
deliveryDate: formData.deliveryDate,
|
|||
|
|
items: selectedItems,
|
|||
|
|
memo: formData.memo,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (response.data.success) {
|
|||
|
|
toast.success("수주가 등록되었습니다");
|
|||
|
|
onOpenChange(false);
|
|||
|
|
onSuccess?.();
|
|||
|
|
|
|||
|
|
// 폼 초기화
|
|||
|
|
resetForm();
|
|||
|
|
} else {
|
|||
|
|
toast.error(response.data.message || "수주 등록에 실패했습니다");
|
|||
|
|
}
|
|||
|
|
} catch (error: any) {
|
|||
|
|
console.error("수주 등록 오류:", error);
|
|||
|
|
toast.error(
|
|||
|
|
error.response?.data?.message || "수주 등록 중 오류가 발생했습니다"
|
|||
|
|
);
|
|||
|
|
} finally {
|
|||
|
|
setIsSaving(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 취소 처리
|
|||
|
|
const handleCancel = () => {
|
|||
|
|
onOpenChange(false);
|
|||
|
|
resetForm();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 폼 초기화
|
|||
|
|
const resetForm = () => {
|
|||
|
|
setInputMode("customer_first");
|
|||
|
|
setFormData({
|
|||
|
|
customerCode: "",
|
|||
|
|
customerName: "",
|
|||
|
|
deliveryDate: "",
|
|||
|
|
memo: "",
|
|||
|
|
});
|
|||
|
|
setSelectedItems([]);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 전체 금액 계산
|
|||
|
|
const totalAmount = selectedItems.reduce(
|
|||
|
|
(sum, item) => sum + (item.amount || 0),
|
|||
|
|
0
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|||
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[1200px] max-h-[90vh] overflow-y-auto">
|
|||
|
|
<DialogHeader>
|
|||
|
|
<DialogTitle className="text-base sm:text-lg">수주 등록</DialogTitle>
|
|||
|
|
<DialogDescription className="text-xs sm:text-sm">
|
|||
|
|
새로운 수주를 등록합니다
|
|||
|
|
</DialogDescription>
|
|||
|
|
</DialogHeader>
|
|||
|
|
|
|||
|
|
<div className="space-y-6">
|
|||
|
|
{/* 입력 방식 선택 */}
|
|||
|
|
<div className="space-y-2">
|
|||
|
|
<Label htmlFor="inputMode" className="text-xs sm:text-sm">
|
|||
|
|
입력 방식 *
|
|||
|
|
</Label>
|
|||
|
|
<Select value={inputMode} onValueChange={setInputMode}>
|
|||
|
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
|||
|
|
<SelectValue placeholder="입력 방식 선택" />
|
|||
|
|
</SelectTrigger>
|
|||
|
|
<SelectContent>
|
|||
|
|
<SelectItem value="customer_first">거래처 우선</SelectItem>
|
|||
|
|
<SelectItem value="quotation">견대 방식</SelectItem>
|
|||
|
|
<SelectItem value="unit_price">단가 방식</SelectItem>
|
|||
|
|
</SelectContent>
|
|||
|
|
</Select>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* 입력 방식에 따른 동적 폼 */}
|
|||
|
|
{inputMode === "customer_first" && (
|
|||
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|||
|
|
{/* 거래처 검색 */}
|
|||
|
|
<div className="space-y-2">
|
|||
|
|
<Label className="text-xs sm:text-sm">거래처 *</Label>
|
|||
|
|
<EntitySearchInput
|
|||
|
|
tableName="customer_mng"
|
|||
|
|
displayField="customer_name"
|
|||
|
|
valueField="customer_code"
|
|||
|
|
searchFields={[
|
|||
|
|
"customer_name",
|
|||
|
|
"customer_code",
|
|||
|
|
"business_number",
|
|||
|
|
]}
|
|||
|
|
mode="combo"
|
|||
|
|
placeholder="거래처를 검색하세요"
|
|||
|
|
modalTitle="거래처 검색 및 선택"
|
|||
|
|
modalColumns={[
|
|||
|
|
"customer_code",
|
|||
|
|
"customer_name",
|
|||
|
|
"address",
|
|||
|
|
"tel",
|
|||
|
|
]}
|
|||
|
|
showAdditionalInfo
|
|||
|
|
additionalFields={["address", "tel"]}
|
|||
|
|
value={formData.customerCode}
|
|||
|
|
onChange={(code, fullData) => {
|
|||
|
|
setFormData({
|
|||
|
|
...formData,
|
|||
|
|
customerCode: code,
|
|||
|
|
customerName: fullData?.customer_name || "",
|
|||
|
|
});
|
|||
|
|
}}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* 납품일 */}
|
|||
|
|
<div className="space-y-2">
|
|||
|
|
<Label htmlFor="deliveryDate" className="text-xs sm:text-sm">
|
|||
|
|
납품일
|
|||
|
|
</Label>
|
|||
|
|
<Input
|
|||
|
|
id="deliveryDate"
|
|||
|
|
type="date"
|
|||
|
|
value={formData.deliveryDate}
|
|||
|
|
onChange={(e) =>
|
|||
|
|
setFormData({ ...formData, deliveryDate: e.target.value })
|
|||
|
|
}
|
|||
|
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{inputMode === "quotation" && (
|
|||
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|||
|
|
<div className="space-y-2">
|
|||
|
|
<Label className="text-xs sm:text-sm">견대 번호 *</Label>
|
|||
|
|
<Input
|
|||
|
|
placeholder="견대 번호를 입력하세요"
|
|||
|
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{inputMode === "unit_price" && (
|
|||
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|||
|
|
<div className="space-y-2">
|
|||
|
|
<Label className="text-xs sm:text-sm">단가 방식 설정</Label>
|
|||
|
|
<Input
|
|||
|
|
placeholder="단가 정보 입력"
|
|||
|
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* 추가된 품목 */}
|
|||
|
|
<div className="space-y-2">
|
|||
|
|
<Label className="text-xs sm:text-sm">추가된 품목</Label>
|
|||
|
|
<ModalRepeaterTable
|
|||
|
|
sourceTable="item_info"
|
|||
|
|
sourceColumns={[
|
|||
|
|
"item_code",
|
|||
|
|
"item_name",
|
|||
|
|
"spec",
|
|||
|
|
"unit",
|
|||
|
|
"unit_price",
|
|||
|
|
]}
|
|||
|
|
sourceSearchFields={["item_code", "item_name", "spec"]}
|
|||
|
|
modalTitle="품목 검색 및 선택"
|
|||
|
|
modalButtonText="품목 검색"
|
|||
|
|
multiSelect={true}
|
|||
|
|
columns={[
|
|||
|
|
{
|
|||
|
|
field: "item_code",
|
|||
|
|
label: "품번",
|
|||
|
|
editable: false,
|
|||
|
|
width: "120px",
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
field: "item_name",
|
|||
|
|
label: "품명",
|
|||
|
|
editable: false,
|
|||
|
|
width: "200px",
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
field: "spec",
|
|||
|
|
label: "규격",
|
|||
|
|
editable: false,
|
|||
|
|
width: "150px",
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
field: "quantity",
|
|||
|
|
label: "수량",
|
|||
|
|
type: "number",
|
|||
|
|
editable: true,
|
|||
|
|
required: true,
|
|||
|
|
defaultValue: 1,
|
|||
|
|
width: "100px",
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
field: "unit_price",
|
|||
|
|
label: "단가",
|
|||
|
|
type: "number",
|
|||
|
|
editable: true,
|
|||
|
|
required: true,
|
|||
|
|
width: "120px",
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
field: "amount",
|
|||
|
|
label: "금액",
|
|||
|
|
type: "number",
|
|||
|
|
editable: false,
|
|||
|
|
calculated: true,
|
|||
|
|
width: "120px",
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
field: "delivery_date",
|
|||
|
|
label: "납품일",
|
|||
|
|
type: "date",
|
|||
|
|
editable: true,
|
|||
|
|
width: "130px",
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
field: "note",
|
|||
|
|
label: "비고",
|
|||
|
|
type: "text",
|
|||
|
|
editable: true,
|
|||
|
|
width: "200px",
|
|||
|
|
},
|
|||
|
|
]}
|
|||
|
|
calculationRules={[
|
|||
|
|
{
|
|||
|
|
result: "amount",
|
|||
|
|
formula: "quantity * unit_price",
|
|||
|
|
dependencies: ["quantity", "unit_price"],
|
|||
|
|
},
|
|||
|
|
]}
|
|||
|
|
uniqueField="item_code"
|
|||
|
|
value={selectedItems}
|
|||
|
|
onChange={setSelectedItems}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* 전체 금액 표시 */}
|
|||
|
|
{selectedItems.length > 0 && (
|
|||
|
|
<div className="flex justify-end">
|
|||
|
|
<div className="text-sm sm:text-base font-semibold">
|
|||
|
|
전체 금액: {totalAmount.toLocaleString()}원
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* 메모 */}
|
|||
|
|
<div className="space-y-2">
|
|||
|
|
<Label htmlFor="memo" className="text-xs sm:text-sm">
|
|||
|
|
메모
|
|||
|
|
</Label>
|
|||
|
|
<Textarea
|
|||
|
|
id="memo"
|
|||
|
|
placeholder="메모를 입력하세요"
|
|||
|
|
value={formData.memo}
|
|||
|
|
onChange={(e) =>
|
|||
|
|
setFormData({ ...formData, memo: e.target.value })
|
|||
|
|
}
|
|||
|
|
className="text-xs sm:text-sm"
|
|||
|
|
rows={3}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<DialogFooter className="gap-2 sm:gap-0">
|
|||
|
|
<Button
|
|||
|
|
variant="outline"
|
|||
|
|
onClick={handleCancel}
|
|||
|
|
disabled={isSaving}
|
|||
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|||
|
|
>
|
|||
|
|
취소
|
|||
|
|
</Button>
|
|||
|
|
<Button
|
|||
|
|
onClick={handleSave}
|
|||
|
|
disabled={isSaving}
|
|||
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|||
|
|
>
|
|||
|
|
{isSaving ? "저장 중..." : "저장"}
|
|||
|
|
</Button>
|
|||
|
|
</DialogFooter>
|
|||
|
|
</DialogContent>
|
|||
|
|
</Dialog>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 🎯 Phase 4: 화면관리 시스템 통합 (선택 사항)
|
|||
|
|
|
|||
|
|
### 4.1 컴포넌트 레지스트리 등록
|
|||
|
|
|
|||
|
|
**파일**: `frontend/lib/registry/component-registry.ts`
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// EntitySearchInput 등록
|
|||
|
|
{
|
|||
|
|
id: "entity-search-input",
|
|||
|
|
name: "엔티티 검색 입력",
|
|||
|
|
category: "input",
|
|||
|
|
description: "데이터베이스 테이블에서 엔티티를 검색하고 선택하는 입력 필드",
|
|||
|
|
component: EntitySearchInputComponent,
|
|||
|
|
configPanel: EntitySearchInputConfig,
|
|||
|
|
defaultProps: {
|
|||
|
|
tableName: "",
|
|||
|
|
displayField: "",
|
|||
|
|
valueField: "",
|
|||
|
|
mode: "combo",
|
|||
|
|
placeholder: "검색...",
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// ModalRepeaterTable 등록
|
|||
|
|
{
|
|||
|
|
id: "modal-repeater-table",
|
|||
|
|
name: "모달 연동 Repeater",
|
|||
|
|
category: "data",
|
|||
|
|
description: "모달에서 데이터를 선택하여 동적 테이블에 추가하는 컴포넌트",
|
|||
|
|
component: ModalRepeaterTableComponent,
|
|||
|
|
configPanel: ModalRepeaterTableConfig,
|
|||
|
|
defaultProps: {
|
|||
|
|
sourceTable: "",
|
|||
|
|
modalTitle: "항목 검색 및 선택",
|
|||
|
|
multiSelect: true,
|
|||
|
|
columns: [],
|
|||
|
|
value: [],
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 📅 개발 일정
|
|||
|
|
|
|||
|
|
| Phase | 작업 내용 | 예상 기간 | 담당자 |
|
|||
|
|
| -------- | ----------------------------------------- | --------- | -------- |
|
|||
|
|
| Phase 1 | EntitySearchInput 컴포넌트 개발 | 1.5일 | Frontend |
|
|||
|
|
| Phase 1 | ModalRepeaterTable 컴포넌트 개발 | 1.5일 | Frontend |
|
|||
|
|
| Phase 2 | 백엔드 API 개발 (엔티티 검색 + 수주 등록) | 1일 | Backend |
|
|||
|
|
| Phase 3 | 수주 등록 전용 컴포넌트 개발 | 1일 | Frontend |
|
|||
|
|
| Phase 4 | 화면관리 시스템 통합 (선택) | 1일 | Frontend |
|
|||
|
|
| **총계** | | **4-5일** | |
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## ✅ 체크리스트
|
|||
|
|
|
|||
|
|
### EntitySearchInput 컴포넌트
|
|||
|
|
|
|||
|
|
- [ ] 기본 인터페이스 및 타입 정의
|
|||
|
|
- [ ] 자동완성 모드 구현
|
|||
|
|
- [ ] 모달 모드 구현
|
|||
|
|
- [ ] 콤보 모드 구현
|
|||
|
|
- [ ] 백엔드 API 연동
|
|||
|
|
- [ ] 멀티테넌시 적용
|
|||
|
|
- [ ] 선택된 항목 표시 및 추가 정보 표시
|
|||
|
|
- [ ] 속성 편집 패널 구현
|
|||
|
|
- [ ] 테스트 및 디버깅
|
|||
|
|
|
|||
|
|
### ModalRepeaterTable 컴포넌트
|
|||
|
|
|
|||
|
|
- [ ] 기본 인터페이스 및 타입 정의
|
|||
|
|
- [ ] 모달 검색 구현
|
|||
|
|
- [ ] 다중 선택 기능
|
|||
|
|
- [ ] Repeater 테이블 렌더링
|
|||
|
|
- [ ] 인라인 편집 기능
|
|||
|
|
- [ ] 계산 필드 자동 업데이트
|
|||
|
|
- [ ] 행 추가/삭제 기능
|
|||
|
|
- [ ] 중복 방지 로직
|
|||
|
|
- [ ] 속성 편집 패널 구현
|
|||
|
|
- [ ] 테스트 및 디버깅
|
|||
|
|
|
|||
|
|
### 백엔드 API
|
|||
|
|
|
|||
|
|
- [ ] 엔티티 검색 API 구현
|
|||
|
|
- [ ] 멀티테넌시 적용
|
|||
|
|
- [ ] 페이징 지원
|
|||
|
|
- [ ] 검색 필터링 로직
|
|||
|
|
- [ ] 수주 등록 API 구현
|
|||
|
|
- [ ] 트랜잭션 처리
|
|||
|
|
- [ ] 채번 규칙 연동
|
|||
|
|
- [ ] 오류 처리 및 로깅
|
|||
|
|
|
|||
|
|
### 수주 등록 컴포넌트
|
|||
|
|
|
|||
|
|
- [ ] 기본 폼 구조 구현
|
|||
|
|
- [ ] 입력 방식별 동적 폼 전환
|
|||
|
|
- [ ] EntitySearchInput 통합
|
|||
|
|
- [ ] ModalRepeaterTable 통합
|
|||
|
|
- [ ] 전체 금액 계산
|
|||
|
|
- [ ] 저장 로직 구현
|
|||
|
|
- [ ] 유효성 검사
|
|||
|
|
- [ ] UI/UX 개선
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 🎨 UI/UX 가이드라인
|
|||
|
|
|
|||
|
|
### 모달 디자인
|
|||
|
|
|
|||
|
|
- 최대 너비: `max-w-[95vw] sm:max-w-[1200px]`
|
|||
|
|
- 반응형 크기 적용
|
|||
|
|
- 스크롤 가능하도록 `overflow-y-auto` 적용
|
|||
|
|
- 모바일에서는 전체 화면에 가까운 크기
|
|||
|
|
|
|||
|
|
### 입력 필드
|
|||
|
|
|
|||
|
|
- 높이: `h-8 sm:h-10` (모바일 32px, 데스크톱 40px)
|
|||
|
|
- 텍스트 크기: `text-xs sm:text-sm`
|
|||
|
|
- 필수 항목은 라벨에 `*` 표시
|
|||
|
|
|
|||
|
|
### 버튼
|
|||
|
|
|
|||
|
|
- Footer 버튼: `gap-2 sm:gap-0`
|
|||
|
|
- 모바일에서는 같은 크기 (`flex-1`)
|
|||
|
|
- 데스크톱에서는 자동 크기 (`flex-none`)
|
|||
|
|
|
|||
|
|
### 테이블
|
|||
|
|
|
|||
|
|
- 고정 컬럼 너비 지정
|
|||
|
|
- 스크롤 가능하도록 설계
|
|||
|
|
- 모바일에서는 가로 스크롤 허용
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 🔧 기술 스택
|
|||
|
|
|
|||
|
|
- **Frontend**: Next.js 14, TypeScript, React 18
|
|||
|
|
- **UI 라이브러리**: shadcn/ui, Tailwind CSS
|
|||
|
|
- **Backend**: Node.js, Express, TypeScript
|
|||
|
|
- **Database**: PostgreSQL
|
|||
|
|
- **상태 관리**: React Hooks (useState, useEffect)
|
|||
|
|
- **API 통신**: Axios
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 📝 참고 자료
|
|||
|
|
|
|||
|
|
- [shadcn/ui 모달 가이드](.cursor/rules/admin-page-style-guide.mdc)
|
|||
|
|
- [멀티테넌시 가이드](.cursor/rules/multi-tenancy-guide.mdc)
|
|||
|
|
- [API 클라이언트 사용 규칙](.cursor/rules/)
|
|||
|
|
- [화면관리 시스템 문서](./화면관리_시스템_구현_계획서.md)
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 🚀 다음 단계
|
|||
|
|
|
|||
|
|
1. **즉시 개발**: EntitySearchInput 컴포넌트부터 시작
|
|||
|
|
2. **병렬 작업**: 프론트엔드와 백엔드 API 동시 개발 가능
|
|||
|
|
3. **점진적 확장**: 견적서, 발주서 등 유사한 화면에 재사용
|
|||
|
|
4. **피드백 수집**: 실제 사용자 테스트 후 개선사항 반영
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## ❓ FAQ
|
|||
|
|
|
|||
|
|
### Q1: 다른 화면(견적서, 발주서)에도 사용할 수 있나요?
|
|||
|
|
|
|||
|
|
**A**: 네! EntitySearchInput과 ModalRepeaterTable은 범용 컴포넌트로 설계되어 있어서 다른 화면에서도 바로 사용 가능합니다.
|
|||
|
|
|
|||
|
|
### Q2: 화면관리 시스템에 통합해야 하나요?
|
|||
|
|
|
|||
|
|
**A**: 필수는 아닙니다. Phase 3까지만 완료해도 수주 등록 기능은 정상 작동합니다. Phase 4는 향후 다른 화면을 더 쉽게 만들기 위한 선택사항입니다.
|
|||
|
|
|
|||
|
|
### Q3: 계산 필드는 어떻게 작동하나요?
|
|||
|
|
|
|||
|
|
**A**: `calculationRules`에 정의된 공식에 따라 자동으로 계산됩니다. 예를 들어 `quantity * unit_price`는 수량이나 단가가 변경될 때마다 자동으로 금액을 계산합니다.
|
|||
|
|
|
|||
|
|
### Q4: 중복된 품목을 추가하면 어떻게 되나요?
|
|||
|
|
|
|||
|
|
**A**: `uniqueField`에 지정된 필드(예: `item_code`)를 기준으로 중복을 체크하여, 이미 추가된 품목은 모달에서 선택할 수 없도록 비활성화됩니다.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 📦 전용 컴포넌트 vs 범용 컴포넌트 (2025-01-15 추가)
|
|||
|
|
|
|||
|
|
### 왜 전용 컴포넌트를 만들었나?
|
|||
|
|
|
|||
|
|
**문제점**: 범용 컴포넌트를 수주 등록 모달에서 직접 사용하면, 화면 편집기에서 설정을 변경했을 때 수주 등록 로직이 깨질 수 있습니다.
|
|||
|
|
|
|||
|
|
**예시:**
|
|||
|
|
|
|||
|
|
- 사용자가 `AutocompleteSearchInput`의 `tableName`을 `customer_mng`에서 `item_info`로 변경
|
|||
|
|
- → 거래처 검색 대신 품목이 조회되어 수주 등록 실패
|
|||
|
|
|
|||
|
|
**해결책**: 수주 등록 전용 래퍼 컴포넌트 생성
|
|||
|
|
|
|||
|
|
### 전용 컴포넌트 목록
|
|||
|
|
|
|||
|
|
#### 1. OrderCustomerSearch
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
// frontend/components/order/OrderCustomerSearch.tsx
|
|||
|
|
|
|||
|
|
// 범용 컴포넌트를 래핑하여 설정을 고정
|
|||
|
|
<AutocompleteSearchInputComponent
|
|||
|
|
tableName="customer_mng" // 고정 (변경 불가)
|
|||
|
|
displayField="customer_name" // 고정
|
|||
|
|
valueField="customer_code" // 고정
|
|||
|
|
searchFields={["customer_name", "customer_code", "business_number"]} // 고정
|
|||
|
|
// ... 기타 고정 설정
|
|||
|
|
|
|||
|
|
value={value} // 외부에서 제어 가능
|
|||
|
|
onChange={onChange} // 외부에서 제어 가능
|
|||
|
|
/>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**특징**:
|
|||
|
|
|
|||
|
|
- `customer_mng` 테이블만 조회 (고정)
|
|||
|
|
- 거래처명, 거래처코드, 사업자번호로 검색 (고정)
|
|||
|
|
- 설정 변경 불가 → 안전
|
|||
|
|
|
|||
|
|
#### 2. OrderItemRepeaterTable
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
// frontend/components/order/OrderItemRepeaterTable.tsx
|
|||
|
|
|
|||
|
|
// 수주 등록에 최적화된 컬럼 및 계산 규칙 고정
|
|||
|
|
<ModalRepeaterTableComponent
|
|||
|
|
sourceTable="item_info" // 고정
|
|||
|
|
columns={ORDER_COLUMNS} // 고정 (품번, 품명, 수량, 단가, 금액 등)
|
|||
|
|
calculationRules={ORDER_CALCULATION_RULES} // 고정 (수량 * 단가)
|
|||
|
|
uniqueField="id" // 고정
|
|||
|
|
// ... 기타 고정 설정
|
|||
|
|
|
|||
|
|
value={value} // 외부에서 제어 가능
|
|||
|
|
onChange={onChange} // 외부에서 제어 가능
|
|||
|
|
/>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**특징**:
|
|||
|
|
|
|||
|
|
- `item_info` 테이블만 조회 (고정)
|
|||
|
|
- 수주에 필요한 컬럼만 표시 (고정)
|
|||
|
|
- 금액 자동 계산 공식 고정 (수량 \* 단가)
|
|||
|
|
- 설정 변경 불가 → 안전
|
|||
|
|
|
|||
|
|
### 비교표
|
|||
|
|
|
|||
|
|
| 항목 | 범용 컴포넌트 | 전용 컴포넌트 |
|
|||
|
|
| ---------- | ------------------------------------------------- | ----------------------------------------- |
|
|||
|
|
| **목적** | 화면 편집기에서 다양한 용도로 사용 | 수주 등록 전용 |
|
|||
|
|
| **설정** | ConfigPanel에서 자유롭게 변경 가능 | 하드코딩으로 고정 |
|
|||
|
|
| **유연성** | 높음 (모든 테이블/필드 지원) | 낮음 (수주에 최적화) |
|
|||
|
|
| **안정성** | 사용자 실수 가능 | 설정 변경 불가로 안전 |
|
|||
|
|
| **위치** | `lib/registry/components/` | `components/order/` |
|
|||
|
|
| **사용처** | - 화면 편집기 드래그앤드롭<br/>- 범용 데이터 입력 | - 수주 등록 모달<br/>- 특정 비즈니스 로직 |
|
|||
|
|
|
|||
|
|
### 사용 패턴
|
|||
|
|
|
|||
|
|
#### ❌ 잘못된 방법 (범용 컴포넌트 직접 사용)
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
// OrderRegistrationModal.tsx
|
|||
|
|
<AutocompleteSearchInputComponent
|
|||
|
|
tableName="customer_mng" // ⚠️ 화면 편집기에서 변경 가능
|
|||
|
|
displayField="customer_name" // ⚠️ 변경 가능
|
|||
|
|
valueField="customer_code" // ⚠️ 변경 가능
|
|||
|
|
// ... 설정이 바뀌면 수주 등록 로직 깨짐!
|
|||
|
|
/>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### ✅ 올바른 방법 (전용 컴포넌트 사용)
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
// OrderRegistrationModal.tsx
|
|||
|
|
<OrderCustomerSearch
|
|||
|
|
value={customerCode}
|
|||
|
|
onChange={handleChange}
|
|||
|
|
// 내부 설정은 고정되어 있어 안전!
|
|||
|
|
/>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 파일 구조
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
frontend/
|
|||
|
|
├── lib/registry/components/ # 범용 컴포넌트 (화면 편집기용)
|
|||
|
|
│ ├── autocomplete-search-input/
|
|||
|
|
│ │ ├── AutocompleteSearchInputComponent.tsx
|
|||
|
|
│ │ ├── AutocompleteSearchInputConfigPanel.tsx # 설정 변경 가능
|
|||
|
|
│ │ └── types.ts
|
|||
|
|
│ ├── entity-search-input/
|
|||
|
|
│ │ ├── EntitySearchInputComponent.tsx
|
|||
|
|
│ │ ├── EntitySearchInputConfigPanel.tsx # 설정 변경 가능
|
|||
|
|
│ │ └── types.ts
|
|||
|
|
│ └── modal-repeater-table/
|
|||
|
|
│ ├── ModalRepeaterTableComponent.tsx
|
|||
|
|
│ ├── ModalRepeaterTableConfigPanel.tsx # 설정 변경 가능
|
|||
|
|
│ └── types.ts
|
|||
|
|
│
|
|||
|
|
└── components/order/ # 전용 컴포넌트 (수주 등록용)
|
|||
|
|
├── OrderCustomerSearch.tsx # 설정 고정
|
|||
|
|
├── OrderItemRepeaterTable.tsx # 설정 고정
|
|||
|
|
├── OrderRegistrationModal.tsx # 메인 모달
|
|||
|
|
└── README.md
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 개발 원칙
|
|||
|
|
|
|||
|
|
1. **비즈니스 로직이 고정된 경우**: 전용 컴포넌트 생성
|
|||
|
|
2. **화면 편집기에서 사용**: 범용 컴포넌트 사용
|
|||
|
|
3. **전용 컴포넌트는 범용 컴포넌트를 래핑**: 중복 코드 최소화
|
|||
|
|
4. **Props 최소화**: 외부에서 제어 가능한 최소한의 prop만 노출
|
|||
|
|
|
|||
|
|
### 참고 문서
|
|||
|
|
|
|||
|
|
- 전용 컴포넌트 상세 문서: `frontend/components/order/README.md`
|
|||
|
|
- 범용 컴포넌트 문서: 각 컴포넌트 폴더의 README.md
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
**작성일**: 2025-01-14
|
|||
|
|
**최종 수정일**: 2025-01-15
|
|||
|
|
**작성자**: AI Assistant
|
|||
|
|
**버전**: 1.1
|