diff --git a/backend-node/src/controllers/processWorkStandardController.ts b/backend-node/src/controllers/processWorkStandardController.ts index e72f6b9f..c3eeb736 100644 --- a/backend-node/src/controllers/processWorkStandardController.ts +++ b/backend-node/src/controllers/processWorkStandardController.ts @@ -30,26 +30,68 @@ export async function getItemsWithRouting(req: AuthenticatedRequest, res: Respon routingTable = "item_routing_version", routingFkColumn = "item_code", search = "", + extraColumns = "", + filterConditions = "", } = req.query as Record; - const searchCondition = search - ? `AND (i.${nameColumn} ILIKE $2 OR i.${codeColumn} ILIKE $2)` - : ""; const params: any[] = [companyCode]; - if (search) params.push(`%${search}%`); + let paramIndex = 2; + + // 검색 조건 + let searchCondition = ""; + if (search) { + searchCondition = `AND (i.${nameColumn} ILIKE $${paramIndex} OR i.${codeColumn} ILIKE $${paramIndex})`; + params.push(`%${search}%`); + paramIndex++; + } + + // 추가 컬럼 SELECT + const extraColumnNames: string[] = extraColumns + ? extraColumns.split(",").map((c: string) => c.trim()).filter(Boolean) + : []; + const extraSelect = extraColumnNames.map((col) => `i.${col}`).join(", "); + const extraGroupBy = extraColumnNames.map((col) => `i.${col}`).join(", "); + + // 사전 필터 조건 + let filterWhere = ""; + if (filterConditions) { + try { + const filters = JSON.parse(filterConditions) as Array<{ + column: string; + operator: string; + value: string; + }>; + for (const f of filters) { + if (!f.column || !f.value) continue; + if (f.operator === "equals") { + filterWhere += ` AND i.${f.column} = $${paramIndex}`; + params.push(f.value); + } else if (f.operator === "contains") { + filterWhere += ` AND i.${f.column} ILIKE $${paramIndex}`; + params.push(`%${f.value}%`); + } else if (f.operator === "not_equals") { + filterWhere += ` AND i.${f.column} != $${paramIndex}`; + params.push(f.value); + } + paramIndex++; + } + } catch { /* 파싱 실패 시 무시 */ } + } const query = ` SELECT i.id, i.${nameColumn} AS item_name, - i.${codeColumn} AS item_code, + i.${codeColumn} AS item_code + ${extraSelect ? ", " + extraSelect : ""}, COUNT(rv.id) AS routing_count FROM ${tableName} i LEFT JOIN ${routingTable} rv ON rv.${routingFkColumn} = i.${codeColumn} AND rv.company_code = i.company_code WHERE i.company_code = $1 ${searchCondition} - GROUP BY i.id, i.${nameColumn}, i.${codeColumn}, i.created_date + ${filterWhere} + GROUP BY i.id, i.${nameColumn}, i.${codeColumn}${extraGroupBy ? ", " + extraGroupBy : ""}, i.created_date ORDER BY i.created_date DESC NULLS LAST `; @@ -711,3 +753,184 @@ export async function saveAll(req: AuthenticatedRequest, res: Response) { client.release(); } } + +// ============================================================ +// 등록 품목 관리 (item_routing_registered) +// ============================================================ + +/** + * 화면별 등록된 품목 목록 조회 + */ +export async function getRegisteredItems(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 필요" }); + } + + const { screenCode } = req.params; + const { + tableName = "item_info", + nameColumn = "item_name", + codeColumn = "item_number", + routingTable = "item_routing_version", + routingFkColumn = "item_code", + search = "", + extraColumns = "", + } = req.query as Record; + + const params: any[] = [companyCode, screenCode]; + let paramIndex = 3; + + let searchCondition = ""; + if (search) { + searchCondition = `AND (i.${nameColumn} ILIKE $${paramIndex} OR i.${codeColumn} ILIKE $${paramIndex})`; + params.push(`%${search}%`); + paramIndex++; + } + + const extraColumnNames: string[] = extraColumns + ? extraColumns.split(",").map((c: string) => c.trim()).filter(Boolean) + : []; + const extraSelect = extraColumnNames.map((col) => `i.${col}`).join(", "); + const extraGroupBy = extraColumnNames.map((col) => `i.${col}`).join(", "); + + const query = ` + SELECT + irr.id AS registered_id, + irr.sort_order, + i.id, + i.${nameColumn} AS item_name, + i.${codeColumn} AS item_code + ${extraSelect ? ", " + extraSelect : ""}, + COUNT(rv.id) AS routing_count + FROM item_routing_registered irr + JOIN ${tableName} i ON irr.item_id = i.id + AND i.company_code = irr.company_code + LEFT JOIN ${routingTable} rv ON rv.${routingFkColumn} = i.${codeColumn} + AND rv.company_code = i.company_code + WHERE irr.company_code = $1 + AND irr.screen_code = $2 + ${searchCondition} + GROUP BY irr.id, irr.sort_order, i.id, i.${nameColumn}, i.${codeColumn}${extraGroupBy ? ", " + extraGroupBy : ""} + ORDER BY CAST(irr.sort_order AS int) ASC, irr.created_date ASC + `; + + const result = await getPool().query(query, params); + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("등록 품목 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +/** + * 품목 등록 (화면에 품목 추가) + */ +export async function registerItem(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 필요" }); + } + + const { screenCode, itemId, itemCode } = req.body; + if (!screenCode || !itemId) { + return res.status(400).json({ success: false, message: "screenCode, itemId 필수" }); + } + + const query = ` + INSERT INTO item_routing_registered (screen_code, item_id, item_code, company_code, writer) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (screen_code, item_id, company_code) DO NOTHING + RETURNING * + `; + const result = await getPool().query(query, [ + screenCode, itemId, itemCode || null, companyCode, req.user?.userId || null, + ]); + + if (result.rowCount === 0) { + return res.json({ success: true, message: "이미 등록된 품목입니다", data: null }); + } + + logger.info("품목 등록", { companyCode, screenCode, itemId }); + return res.json({ success: true, data: result.rows[0] }); + } catch (error: any) { + logger.error("품목 등록 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +/** + * 여러 품목 일괄 등록 + */ +export async function registerItemsBatch(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 필요" }); + } + + const { screenCode, items } = req.body; + if (!screenCode || !Array.isArray(items) || items.length === 0) { + return res.status(400).json({ success: false, message: "screenCode, items[] 필수" }); + } + + const client = await getPool().connect(); + try { + await client.query("BEGIN"); + const inserted: any[] = []; + + for (const item of items) { + const result = await client.query( + `INSERT INTO item_routing_registered (screen_code, item_id, item_code, company_code, writer) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (screen_code, item_id, company_code) DO NOTHING + RETURNING *`, + [screenCode, item.itemId, item.itemCode || null, companyCode, req.user?.userId || null] + ); + if (result.rows[0]) inserted.push(result.rows[0]); + } + + await client.query("COMMIT"); + logger.info("품목 일괄 등록", { companyCode, screenCode, count: inserted.length }); + return res.json({ success: true, data: inserted }); + } catch (err) { + await client.query("ROLLBACK"); + throw err; + } finally { + client.release(); + } + } catch (error: any) { + logger.error("품목 일괄 등록 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +/** + * 등록 품목 제거 + */ +export async function unregisterItem(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 필요" }); + } + + const { id } = req.params; + const result = await getPool().query( + `DELETE FROM item_routing_registered WHERE id = $1 AND company_code = $2 RETURNING id`, + [id, companyCode] + ); + + if (result.rowCount === 0) { + return res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다" }); + } + + logger.info("등록 품목 제거", { companyCode, id }); + return res.json({ success: true }); + } catch (error: any) { + logger.error("등록 품목 제거 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} diff --git a/backend-node/src/routes/processWorkStandardRoutes.ts b/backend-node/src/routes/processWorkStandardRoutes.ts index 7630b359..c613d55f 100644 --- a/backend-node/src/routes/processWorkStandardRoutes.ts +++ b/backend-node/src/routes/processWorkStandardRoutes.ts @@ -33,4 +33,10 @@ router.delete("/work-item-details/:id", ctrl.deleteWorkItemDetail); // 전체 저장 (일괄) router.put("/save-all", ctrl.saveAll); +// 등록 품목 관리 (화면별 품목 목록) +router.get("/registered-items/:screenCode", ctrl.getRegisteredItems); +router.post("/registered-items", ctrl.registerItem); +router.post("/registered-items/batch", ctrl.registerItemsBatch); +router.delete("/registered-items/:id", ctrl.unregisterItem); + export default router; diff --git a/docs/POP_작업진행_설계서.md b/docs/POP_작업진행_설계서.md new file mode 100644 index 00000000..3b77ddc5 --- /dev/null +++ b/docs/POP_작업진행_설계서.md @@ -0,0 +1,620 @@ +# POP 작업진행 관리 설계서 + +> 작성일: 2026-03-13 +> 목적: POP 시스템에서 작업지시 기반으로 라우팅/작업기준정보를 조회하고, 공정별 작업 진행 상태를 관리하는 구조 설계 + +--- + +## 1. 핵심 설계 원칙 + +**작업지시에 라우팅ID, 작업기준정보ID 등을 별도 컬럼으로 넣지 않는다.** + +- 작업지시(`work_instruction`)에는 `item_id`(품목 ID)만 있으면 충분 +- 품목 → 라우팅 → 작업기준정보는 마스터 데이터 체인으로 조회 +- 작업 진행 상태만 별도 테이블에서 관리 + +--- + +## 2. 기존 테이블 구조 (마스터 데이터) + +### 2-1. ER 다이어그램 + +> GitHub / VSCode Mermaid 플러그인에서 렌더링됩니다. + +```mermaid +erDiagram + %% ========== 마스터 데이터 (변경 없음) ========== + + item_info { + varchar id PK "UUID" + varchar item_number "품번" + varchar item_name "품명" + varchar company_code "회사코드" + } + + item_routing_version { + varchar id PK "UUID" + varchar item_code "품번 (= item_info.item_number)" + varchar version_name "버전명" + boolean is_default "기본버전 여부" + varchar company_code "회사코드" + } + + item_routing_detail { + varchar id PK "UUID" + varchar routing_version_id FK "→ item_routing_version.id" + varchar seq_no "공정순서 10,20,30..." + varchar process_code FK "→ process_mng.process_code" + varchar is_required "필수/선택" + varchar is_fixed_order "고정/선택" + varchar standard_time "표준시간(분)" + varchar company_code "회사코드" + } + + process_mng { + varchar id PK "UUID" + varchar process_code "공정코드" + varchar process_name "공정명" + varchar process_type "공정유형" + varchar company_code "회사코드" + } + + process_work_item { + varchar id PK "UUID" + varchar routing_detail_id FK "→ item_routing_detail.id" + varchar work_phase "PRE / IN / POST" + varchar title "작업항목명" + varchar is_required "Y/N" + int sort_order "정렬순서" + varchar company_code "회사코드" + } + + process_work_item_detail { + varchar id PK "UUID" + varchar work_item_id FK "→ process_work_item.id" + varchar detail_type "check/inspect/input/procedure/info" + varchar content "내용" + varchar input_type "입력타입" + varchar inspection_code "검사코드" + varchar unit "단위" + varchar lower_limit "하한값" + varchar upper_limit "상한값" + varchar company_code "회사코드" + } + + %% ========== 트랜잭션 데이터 ========== + + work_instruction { + varchar id PK "UUID" + varchar work_instruction_no "작업지시번호" + varchar item_id FK "→ item_info.id ★핵심★" + varchar status "waiting/in_progress/completed/cancelled" + varchar qty "지시수량" + varchar completed_qty "완성수량" + varchar worker "작업자" + varchar company_code "회사코드" + } + + work_order_process { + varchar id PK "UUID" + varchar wo_id FK "→ work_instruction.id" + varchar routing_detail_id FK "→ item_routing_detail.id ★추가★" + varchar seq_no "공정순서" + varchar process_code "공정코드" + varchar process_name "공정명" + varchar status "waiting/in_progress/completed/skipped" + varchar plan_qty "계획수량" + varchar good_qty "양품수량" + varchar defect_qty "불량수량" + timestamp started_at "시작시간" + timestamp completed_at "완료시간" + varchar company_code "회사코드" + } + + work_order_work_item { + varchar id PK "UUID ★신규★" + varchar company_code "회사코드" + varchar work_order_process_id FK "→ work_order_process.id" + varchar work_item_id FK "→ process_work_item.id" + varchar work_phase "PRE/IN/POST" + varchar status "pending/completed/skipped/failed" + varchar completed_by "완료자" + timestamp completed_at "완료시간" + } + + work_order_work_item_result { + varchar id PK "UUID ★신규★" + varchar company_code "회사코드" + varchar work_order_work_item_id FK "→ work_order_work_item.id" + varchar work_item_detail_id FK "→ process_work_item_detail.id" + varchar detail_type "check/inspect/input/procedure" + varchar result_value "결과값" + varchar is_passed "Y/N/null" + varchar recorded_by "기록자" + timestamp recorded_at "기록시간" + } + + %% ========== 관계 ========== + + %% 마스터 체인: 품목 → 라우팅 → 작업기준정보 + item_info ||--o{ item_routing_version : "item_number = item_code" + item_routing_version ||--o{ item_routing_detail : "id = routing_version_id" + item_routing_detail }o--|| process_mng : "process_code" + item_routing_detail ||--o{ process_work_item : "id = routing_detail_id" + process_work_item ||--o{ process_work_item_detail : "id = work_item_id" + + %% 트랜잭션: 작업지시 → 공정진행 → 작업기준정보 진행 + work_instruction }o--|| item_info : "item_id = id" + work_instruction ||--o{ work_order_process : "id = wo_id" + work_order_process }o--|| item_routing_detail : "routing_detail_id = id" + work_order_process ||--o{ work_order_work_item : "id = work_order_process_id" + work_order_work_item }o--|| process_work_item : "work_item_id = id" + work_order_work_item ||--o{ work_order_work_item_result : "id = work_order_work_item_id" + work_order_work_item_result }o--|| process_work_item_detail : "work_item_detail_id = id" +``` + +### 2-1-1. 관계 요약 (텍스트) + +``` +[마스터 데이터 체인 - 조회용, 변경 없음] + + item_info ─── 1:N ───→ item_routing_version ─── 1:N ───→ item_routing_detail + (품목) item_number (라우팅 버전) routing_ (공정별 상세) + = item_code version_id + │ + process_mng ◄───┘ process_code (공정 마스터) + │ + ├── 1:N ───→ process_work_item ─── 1:N ───→ process_work_item_detail + │ (작업기준정보) (작업기준정보 상세) + │ routing_detail_id work_item_id + │ +[트랜잭션 데이터 - 상태 관리] │ + │ + work_instruction ─── 1:N ───→ work_order_process ─┘ routing_detail_id (★추가★) + (작업지시) wo_id (공정별 진행) + item_id → item_info │ + ├── 1:N ───→ work_order_work_item ─── 1:N ───→ work_order_work_item_result + │ (작업기준정보 진행) (상세 결과값) + │ work_order_process_id work_order_work_item_id + │ work_item_id → process_work_item work_item_detail_id → process_work_item_detail + │ ★신규 테이블★ ★신규 테이블★ +``` + +### 2-2. 마스터 테이블 상세 + +#### item_info (품목 마스터) +| 컬럼 | 설명 | 비고 | +|------|------|------| +| id | PK (UUID) | | +| item_number | 품번 | item_routing_version.item_code와 매칭 | +| item_name | 품명 | | +| company_code | 회사코드 | 멀티테넌시 | + +#### item_routing_version (라우팅 버전) +| 컬럼 | 설명 | 비고 | +|------|------|------| +| id | PK (UUID) | | +| item_code | 품번 | item_info.item_number와 매칭 | +| version_name | 버전명 | 예: "기본 라우팅", "버전2" | +| is_default | 기본 버전 여부 | true/false, 기본 버전을 사용 | +| company_code | 회사코드 | | + +#### item_routing_detail (라우팅 상세 - 공정별) +| 컬럼 | 설명 | 비고 | +|------|------|------| +| id | PK (UUID) | | +| routing_version_id | FK → item_routing_version.id | | +| seq_no | 공정 순서 | 10, 20, 30... | +| process_code | 공정코드 | FK → process_mng.process_code | +| is_required | 필수/선택 | "필수" / "선택" | +| is_fixed_order | 순서고정 여부 | "고정" / "선택" | +| work_type | 작업유형 | | +| standard_time | 표준시간(분) | | +| outsource_supplier | 외주업체 | | +| company_code | 회사코드 | | + +#### process_work_item (작업기준정보) +| 컬럼 | 설명 | 비고 | +|------|------|------| +| id | PK (UUID) | | +| routing_detail_id | FK → item_routing_detail.id | | +| work_phase | 작업단계 | PRE(작업전) / IN(작업중) / POST(작업후) | +| title | 작업항목명 | 예: "장비 체크", "소재 준비" | +| is_required | 필수여부 | Y/N | +| sort_order | 정렬순서 | | +| description | 설명 | | +| company_code | 회사코드 | | + +#### process_work_item_detail (작업기준정보 상세) +| 컬럼 | 설명 | 비고 | +|------|------|------| +| id | PK (UUID) | | +| work_item_id | FK → process_work_item.id | | +| detail_type | 상세유형 | check(체크) / inspect(검사) / input(입력) / procedure(절차) / info(정보) | +| content | 내용 | 예: "소음검사", "치수검사" | +| input_type | 입력타입 | select, text 등 | +| inspection_code | 검사코드 | | +| inspection_method | 검사방법 | | +| unit | 단위 | | +| lower_limit | 하한값 | | +| upper_limit | 상한값 | | +| is_required | 필수여부 | Y/N | +| sort_order | 정렬순서 | | +| company_code | 회사코드 | | + +--- + +## 3. 작업 진행 테이블 (트랜잭션 데이터) + +### 3-1. work_instruction (작업지시) - 기존 테이블 + +| 컬럼 | 설명 | 비고 | +|------|------|------| +| id | PK (UUID) | | +| work_instruction_no | 작업지시번호 | 예: WO-2026-001 | +| **item_id** | **FK → item_info.id** | **이것만으로 라우팅/작업기준정보 전부 조회 가능** | +| status | 작업지시 상태 | waiting / in_progress / completed / cancelled | +| qty | 지시수량 | | +| completed_qty | 완성수량 | | +| work_team | 작업팀 | | +| worker | 작업자 | | +| equipment_id | 설비 | | +| start_date | 시작일 | | +| end_date | 종료일 | | +| remark | 비고 | | +| company_code | 회사코드 | | + +> **routing 컬럼**: 현재 존재하지만 사용하지 않음 (null). 라우팅 버전을 지정하고 싶으면 이 컬럼에 `item_routing_version.id`를 넣어 특정 버전을 지정할 수 있음. 없으면 `is_default=true` 버전 자동 사용. + +### 3-2. work_order_process (공정별 진행) - 기존 테이블, 변경 필요 + +작업지시가 생성될 때, 해당 품목의 라우팅 공정을 복사해서 이 테이블에 INSERT. + +| 컬럼 | 설명 | 비고 | +|------|------|------| +| id | PK (UUID) | | +| wo_id | FK → work_instruction.id | 작업지시 참조 | +| **routing_detail_id** | **FK → item_routing_detail.id** | **추가 필요 - 라우팅 상세 참조** | +| seq_no | 공정 순서 | 라우팅에서 복사 | +| process_code | 공정코드 | 라우팅에서 복사 | +| process_name | 공정명 | 라우팅에서 복사 (비정규화, 조회 편의) | +| is_required | 필수여부 | 라우팅에서 복사 | +| is_fixed_order | 순서고정 | 라우팅에서 복사 | +| standard_time | 표준시간 | 라우팅에서 복사 | +| **status** | **공정 상태** | **waiting / in_progress / completed / skipped** | +| plan_qty | 계획수량 | | +| input_qty | 투입수량 | | +| good_qty | 양품수량 | | +| defect_qty | 불량수량 | | +| equipment_code | 사용설비 | | +| accepted_by | 접수자 | | +| accepted_at | 접수시간 | | +| started_at | 시작시간 | | +| completed_at | 완료시간 | | +| remark | 비고 | | +| company_code | 회사코드 | | + +### 3-3. work_order_work_item (작업기준정보별 진행) - 신규 테이블 + +POP에서 작업자가 각 작업기준정보 항목을 체크/입력할 때 사용. + +| 컬럼 | 설명 | 비고 | +|------|------|------| +| id | PK (UUID) | gen_random_uuid() | +| company_code | 회사코드 | 멀티테넌시 | +| work_order_process_id | FK → work_order_process.id | 어떤 작업지시의 어떤 공정인지 | +| work_item_id | FK → process_work_item.id | 어떤 작업기준정보인지 | +| work_phase | 작업단계 | PRE / IN / POST (마스터에서 복사) | +| status | 완료상태 | pending / completed / skipped / failed | +| completed_by | 완료자 | 작업자 ID | +| completed_at | 완료시간 | | +| created_date | 생성일 | | +| updated_date | 수정일 | | +| writer | 작성자 | | + +### 3-4. work_order_work_item_result (작업기준정보 상세 결과) - 신규 테이블 + +작업기준정보의 상세 항목(체크, 검사, 입력 등)에 대한 실제 결과값 저장. + +| 컬럼 | 설명 | 비고 | +|------|------|------| +| id | PK (UUID) | gen_random_uuid() | +| company_code | 회사코드 | 멀티테넌시 | +| work_order_work_item_id | FK → work_order_work_item.id | | +| work_item_detail_id | FK → process_work_item_detail.id | 어떤 상세항목인지 | +| detail_type | 상세유형 | check / inspect / input / procedure (마스터에서 복사) | +| result_value | 결과값 | 체크: "Y"/"N", 검사: 측정값, 입력: 입력값 | +| is_passed | 합격여부 | Y / N / null(해당없음) | +| remark | 비고 | 불합격 사유 등 | +| recorded_by | 기록자 | | +| recorded_at | 기록시간 | | +| created_date | 생성일 | | +| updated_date | 수정일 | | +| writer | 작성자 | | + +--- + +## 4. POP 데이터 플로우 + +### 4-1. 작업지시 등록 시 (ERP 측) + +``` +[작업지시 생성] + │ + ├── 1. work_instruction INSERT (item_id, qty, status='waiting' 등) + │ + ├── 2. item_id → item_info.item_number 조회 + │ + ├── 3. item_number → item_routing_version 조회 (is_default=true 또는 지정 버전) + │ + ├── 4. routing_version_id → item_routing_detail 조회 (공정 목록) + │ + └── 5. 각 공정별로 work_order_process INSERT + ├── wo_id = work_instruction.id + ├── routing_detail_id = item_routing_detail.id ← 핵심! + ├── seq_no, process_code, process_name 복사 + ├── status = 'waiting' + └── plan_qty = work_instruction.qty +``` + +### 4-2. POP 작업 조회 시 + +``` +[POP 화면: 작업지시 선택] + │ + ├── 1. work_instruction 목록 조회 (status = 'waiting' or 'in_progress') + │ + ├── 2. 선택한 작업지시의 공정 목록 조회 + │ SELECT wop.*, pm.process_name + │ FROM work_order_process wop + │ LEFT JOIN process_mng pm ON wop.process_code = pm.process_code + │ WHERE wop.wo_id = {작업지시ID} + │ ORDER BY CAST(wop.seq_no AS int) + │ + └── 3. 선택한 공정의 작업기준정보 조회 (마스터 데이터 참조) + SELECT pwi.*, pwid.* + FROM process_work_item pwi + LEFT JOIN process_work_item_detail pwid ON pwi.id = pwid.work_item_id + WHERE pwi.routing_detail_id = {work_order_process.routing_detail_id} + ORDER BY pwi.work_phase, pwi.sort_order, pwid.sort_order +``` + +### 4-3. POP 작업 실행 시 + +``` +[작업자가 공정 시작] + │ + ├── 1. work_order_process UPDATE + │ SET status = 'in_progress', started_at = NOW(), accepted_by = {작업자} + │ + ├── 2. work_instruction UPDATE (첫 공정 시작 시) + │ SET status = 'in_progress' + │ + ├── 3. 작업기준정보 항목별 체크/입력 시 + │ ├── work_order_work_item UPSERT (항목별 상태) + │ └── work_order_work_item_result UPSERT (상세 결과값) + │ + └── 4. 공정 완료 시 + ├── work_order_process UPDATE + │ SET status = 'completed', completed_at = NOW(), + │ good_qty = {양품}, defect_qty = {불량} + │ + └── (모든 공정 완료 시) + work_instruction UPDATE + SET status = 'completed', completed_qty = {최종양품} +``` + +--- + +## 5. 핵심 조회 쿼리 + +### 5-1. 작업지시 → 전체 공정 + 작업기준정보 한방 조회 + +```sql +-- 작업지시의 공정별 진행 현황 + 작업기준정보 +SELECT + wi.work_instruction_no, + wi.qty, + wi.status as wi_status, + ii.item_number, + ii.item_name, + wop.id as process_id, + wop.seq_no, + wop.process_code, + wop.process_name, + wop.status as process_status, + wop.plan_qty, + wop.good_qty, + wop.defect_qty, + wop.started_at, + wop.completed_at, + wop.routing_detail_id, + -- 작업기준정보는 routing_detail_id로 마스터 조회 + pwi.id as work_item_id, + pwi.work_phase, + pwi.title as work_item_title, + pwi.is_required as work_item_required +FROM work_instruction wi +JOIN item_info ii ON wi.item_id = ii.id +JOIN work_order_process wop ON wi.id = wop.wo_id +LEFT JOIN process_work_item pwi ON wop.routing_detail_id = pwi.routing_detail_id +WHERE wi.id = $1 + AND wi.company_code = $2 +ORDER BY CAST(wop.seq_no AS int), pwi.work_phase, pwi.sort_order; +``` + +### 5-2. 특정 공정의 작업기준정보 + 진행 상태 조회 + +```sql +-- POP에서 특정 공정 선택 시: 마스터 + 진행 상태 조인 +SELECT + pwi.id as work_item_id, + pwi.work_phase, + pwi.title, + pwi.is_required, + pwid.id as detail_id, + pwid.detail_type, + pwid.content, + pwid.input_type, + pwid.inspection_code, + pwid.inspection_method, + pwid.unit, + pwid.lower_limit, + pwid.upper_limit, + -- 진행 상태 + wowi.status as item_status, + wowi.completed_by, + wowi.completed_at, + -- 결과값 + wowir.result_value, + wowir.is_passed, + wowir.remark as result_remark +FROM process_work_item pwi +LEFT JOIN process_work_item_detail pwid + ON pwi.id = pwid.work_item_id +LEFT JOIN work_order_work_item wowi + ON wowi.work_item_id = pwi.id + AND wowi.work_order_process_id = $1 -- work_order_process.id +LEFT JOIN work_order_work_item_result wowir + ON wowir.work_order_work_item_id = wowi.id + AND wowir.work_item_detail_id = pwid.id +WHERE pwi.routing_detail_id = $2 -- work_order_process.routing_detail_id +ORDER BY + CASE pwi.work_phase WHEN 'PRE' THEN 1 WHEN 'IN' THEN 2 WHEN 'POST' THEN 3 END, + pwi.sort_order, + pwid.sort_order; +``` + +--- + +## 6. 변경사항 요약 + +### 6-1. 기존 테이블 변경 + +| 테이블 | 변경내용 | +|--------|---------| +| work_order_process | `routing_detail_id VARCHAR(500)` 컬럼 추가 | + +### 6-2. 신규 테이블 + +| 테이블 | 용도 | +|--------|------| +| work_order_work_item | 작업지시 공정별 작업기준정보 진행 상태 | +| work_order_work_item_result | 작업기준정보 상세 항목의 실제 결과값 | + +### 6-3. 건드리지 않는 것 + +| 테이블 | 이유 | +|--------|------| +| work_instruction | item_id만 있으면 충분. 라우팅/작업기준정보 ID 추가 불필요 | +| item_routing_version | 마스터 데이터, 변경 없음 | +| item_routing_detail | 마스터 데이터, 변경 없음 | +| process_work_item | 마스터 데이터, 변경 없음 | +| process_work_item_detail | 마스터 데이터, 변경 없음 | + +--- + +## 7. DDL (마이그레이션 SQL) + +```sql +-- 1. work_order_process에 routing_detail_id 추가 +ALTER TABLE work_order_process +ADD COLUMN IF NOT EXISTS routing_detail_id VARCHAR(500); + +CREATE INDEX IF NOT EXISTS idx_wop_routing_detail_id +ON work_order_process(routing_detail_id); + +-- 2. 작업기준정보별 진행 상태 테이블 +CREATE TABLE IF NOT EXISTS work_order_work_item ( + id VARCHAR(500) PRIMARY KEY DEFAULT gen_random_uuid()::text, + company_code VARCHAR(500) NOT NULL, + work_order_process_id VARCHAR(500) NOT NULL, + work_item_id VARCHAR(500) NOT NULL, + work_phase VARCHAR(500), + status VARCHAR(500) DEFAULT 'pending', + completed_by VARCHAR(500), + completed_at TIMESTAMP, + created_date TIMESTAMP DEFAULT NOW(), + updated_date TIMESTAMP DEFAULT NOW(), + writer VARCHAR(500) +); + +CREATE INDEX idx_wowi_process_id ON work_order_work_item(work_order_process_id); +CREATE INDEX idx_wowi_work_item_id ON work_order_work_item(work_item_id); +CREATE INDEX idx_wowi_company_code ON work_order_work_item(company_code); + +-- 3. 작업기준정보 상세 결과 테이블 +CREATE TABLE IF NOT EXISTS work_order_work_item_result ( + id VARCHAR(500) PRIMARY KEY DEFAULT gen_random_uuid()::text, + company_code VARCHAR(500) NOT NULL, + work_order_work_item_id VARCHAR(500) NOT NULL, + work_item_detail_id VARCHAR(500) NOT NULL, + detail_type VARCHAR(500), + result_value VARCHAR(500), + is_passed VARCHAR(500), + remark TEXT, + recorded_by VARCHAR(500), + recorded_at TIMESTAMP DEFAULT NOW(), + created_date TIMESTAMP DEFAULT NOW(), + updated_date TIMESTAMP DEFAULT NOW(), + writer VARCHAR(500) +); + +CREATE INDEX idx_wowir_work_order_work_item_id ON work_order_work_item_result(work_order_work_item_id); +CREATE INDEX idx_wowir_detail_id ON work_order_work_item_result(work_item_detail_id); +CREATE INDEX idx_wowir_company_code ON work_order_work_item_result(company_code); +``` + +--- + +## 8. 상태값 정의 + +### work_instruction.status (작업지시 상태) +| 값 | 의미 | +|----|------| +| waiting | 대기 | +| in_progress | 진행중 | +| completed | 완료 | +| cancelled | 취소 | + +### work_order_process.status (공정 상태) +| 값 | 의미 | +|----|------| +| waiting | 대기 (아직 시작 안 함) | +| in_progress | 진행중 (작업자가 시작) | +| completed | 완료 | +| skipped | 건너뜀 (선택 공정인 경우) | + +### work_order_work_item.status (작업기준정보 항목 상태) +| 값 | 의미 | +|----|------| +| pending | 미완료 | +| completed | 완료 | +| skipped | 건너뜀 | +| failed | 실패 (검사 불합격 등) | + +### work_order_work_item_result.is_passed (검사 합격여부) +| 값 | 의미 | +|----|------| +| Y | 합격 | +| N | 불합격 | +| null | 해당없음 (체크/입력 항목) | + +--- + +## 9. 설계 의도 요약 + +1. **마스터와 트랜잭션 분리**: 라우팅/작업기준정보는 마스터(템플릿), 실제 진행은 트랜잭션 테이블에서 관리 +2. **조회 경로**: `work_instruction.item_id` → `item_info.item_number` → `item_routing_version` → `item_routing_detail` → `process_work_item` → `process_work_item_detail` +3. **진행 경로**: `work_order_process.routing_detail_id`로 마스터 작업기준정보를 참조하되, 실제 진행/결과는 `work_order_work_item` + `work_order_work_item_result`에 저장 +4. **중복 저장 최소화**: 작업지시에 공정/작업기준정보 ID를 넣지 않음. 품목만 있으면 전부 파생 조회 가능 +5. **work_order_process**: 작업지시 생성 시 라우팅 공정을 복사하는 이유는 진행 중 수량/상태/시간 등 트랜잭션 데이터를 기록해야 하기 때문 (마스터가 변경되어도 이미 발행된 작업지시의 공정은 유지) + +--- + +## 10. 주의사항 + +- `work_order_process`에 공정 정보를 복사(스냅샷)하는 이유: 마스터 라우팅이 나중에 변경되어도 이미 진행 중인 작업지시의 공정 구성은 영향받지 않아야 함 +- `routing_detail_id`는 "이 공정이 어떤 마스터 라우팅에서 왔는지" 추적용. 작업기준정보 조회 키로 사용 +- POP에서 작업기준정보를 표시할 때는 항상 마스터(`process_work_item`)를 조회하고, 결과만 트랜잭션 테이블에 저장 +- 모든 테이블에 `company_code` 필수 (멀티테넌시) diff --git a/frontend/components/v2/config-panels/V2ItemRoutingConfigPanel.tsx b/frontend/components/v2/config-panels/V2ItemRoutingConfigPanel.tsx index e47c61ba..851d90fe 100644 --- a/frontend/components/v2/config-panels/V2ItemRoutingConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2ItemRoutingConfigPanel.tsx @@ -2,7 +2,6 @@ /** * V2 품목별 라우팅 설정 패널 - * 토스식 단계별 UX: 데이터 소스 -> 모달 연동 -> 공정 컬럼 -> 레이아웃(접힘) */ import React, { useState, useEffect } from "react"; @@ -16,10 +15,10 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command import { Badge } from "@/components/ui/badge"; import { Settings, ChevronDown, ChevronRight, Plus, Trash2, Check, ChevronsUpDown, - Database, Monitor, Columns, + Database, Monitor, Columns, List, Filter, Eye, } from "lucide-react"; import { cn } from "@/lib/utils"; -import type { ItemRoutingConfig, ProcessColumnDef } from "@/lib/registry/components/v2-item-routing/types"; +import type { ItemRoutingConfig, ProcessColumnDef, ColumnDef, ItemFilterCondition } from "@/lib/registry/components/v2-item-routing/types"; import { defaultConfig } from "@/lib/registry/components/v2-item-routing/config"; interface V2ItemRoutingConfigPanelProps { @@ -27,53 +26,21 @@ interface V2ItemRoutingConfigPanelProps { onChange: (config: Partial) => void; } -interface TableInfo { - tableName: string; - displayName?: string; -} +interface TableInfo { tableName: string; displayName?: string; } +interface ColumnInfo { columnName: string; displayName?: string; dataType?: string; } +interface ScreenInfo { screenId: number; screenName: string; screenCode: string; } -interface ColumnInfo { - columnName: string; - displayName?: string; - dataType?: string; -} - -interface ScreenInfo { - screenId: number; - screenName: string; - screenCode: string; -} - -// ─── 테이블 Combobox ─── -function TableCombobox({ - value, - onChange, - tables, - loading, -}: { - value: string; - onChange: (v: string) => void; - tables: TableInfo[]; - loading: boolean; +// ─── 공용: 테이블 Combobox ─── +function TableCombobox({ value, onChange, tables, loading }: { + value: string; onChange: (v: string) => void; tables: TableInfo[]; loading: boolean; }) { const [open, setOpen] = useState(false); const selected = tables.find((t) => t.tableName === value); - return ( - @@ -84,12 +51,8 @@ function TableCombobox({ 테이블을 찾을 수 없습니다. {tables.map((t) => ( - { onChange(t.tableName); setOpen(false); }} - className="text-xs" - > + { onChange(t.tableName); setOpen(false); }} className="text-xs">
{t.displayName || t.tableName} @@ -105,17 +68,9 @@ function TableCombobox({ ); } -// ─── 컬럼 Combobox ─── -function ColumnCombobox({ - value, - onChange, - tableName, - placeholder, -}: { - value: string; - onChange: (v: string) => void; - tableName: string; - placeholder?: string; +// ─── 공용: 컬럼 Combobox ─── +function ColumnCombobox({ value, onChange, tableName, placeholder }: { + value: string; onChange: (v: string, displayName?: string) => void; tableName: string; placeholder?: string; }) { const [open, setOpen] = useState(false); const [columns, setColumns] = useState([]); @@ -128,26 +83,17 @@ function ColumnCombobox({ try { const { tableManagementApi } = await import("@/lib/api/tableManagement"); const res = await tableManagementApi.getColumnList(tableName); - if (res.success && res.data?.columns) { - setColumns(res.data.columns); - } + if (res.success && res.data?.columns) setColumns(res.data.columns); } catch { /* ignore */ } finally { setLoading(false); } }; load(); }, [tableName]); const selected = columns.find((c) => c.columnName === value); - return ( - @@ -239,12 +162,8 @@ function ScreenCombobox({ 화면을 찾을 수 없습니다. {screens.map((s) => ( - { onChange(s.screenId); setOpen(false); }} - className="text-xs" - > + { onChange(s.screenId); setOpen(false); }} className="text-xs">
{s.screenName} @@ -260,17 +179,104 @@ function ScreenCombobox({ ); } +// ─── 컬럼 편집 카드 (품목/모달/공정 공용) ─── +function ColumnEditor({ columns, onChange, tableName, title, icon }: { + columns: ColumnDef[]; + onChange: (cols: ColumnDef[]) => void; + tableName: string; + title: string; + icon: React.ReactNode; +}) { + const [open, setOpen] = useState(false); + + const addColumn = () => onChange([...columns, { name: "", label: "새 컬럼", width: 100, align: "left" }]); + const removeColumn = (idx: number) => onChange(columns.filter((_, i) => i !== idx)); + const updateColumn = (idx: number, field: keyof ColumnDef, value: string | number) => { + const next = [...columns]; + next[idx] = { ...next[idx], [field]: value }; + onChange(next); + }; + + return ( + + + + + +
+ {columns.map((col, idx) => ( + +
+ + + + + +
+
+ 컬럼 + { + updateColumn(idx, "name", v); + if (!col.label || col.label === "새 컬럼" || col.label === col.name) updateColumn(idx, "label", displayName || v); + }} tableName={tableName} placeholder="컬럼 선택" /> +
+
+ 표시명 + updateColumn(idx, "label", e.target.value)} className="h-7 text-xs" /> +
+
+ 너비 + updateColumn(idx, "width", parseInt(e.target.value) || 100)} className="h-7 text-xs" /> +
+
+ 정렬 + +
+
+
+
+
+ ))} + +
+
+
+ ); +} + // ─── 메인 컴포넌트 ─── -export const V2ItemRoutingConfigPanel: React.FC = ({ - config: configProp, - onChange, -}) => { +export const V2ItemRoutingConfigPanel: React.FC = ({ config: configProp, onChange }) => { const [tables, setTables] = useState([]); const [loadingTables, setLoadingTables] = useState(false); const [modalOpen, setModalOpen] = useState(false); - const [columnsOpen, setColumnsOpen] = useState(false); const [dataSourceOpen, setDataSourceOpen] = useState(false); const [layoutOpen, setLayoutOpen] = useState(false); + const [filterOpen, setFilterOpen] = useState(false); const config: ItemRoutingConfig = { ...defaultConfig, @@ -278,6 +284,9 @@ export const V2ItemRoutingConfigPanel: React.FC = dataSource: { ...defaultConfig.dataSource, ...configProp?.dataSource }, modals: { ...defaultConfig.modals, ...configProp?.modals }, processColumns: configProp?.processColumns?.length ? configProp.processColumns : defaultConfig.processColumns, + itemDisplayColumns: configProp?.itemDisplayColumns?.length ? configProp.itemDisplayColumns : defaultConfig.itemDisplayColumns, + modalDisplayColumns: configProp?.modalDisplayColumns?.length ? configProp.modalDisplayColumns : defaultConfig.modalDisplayColumns, + itemFilterConditions: configProp?.itemFilterConditions || [], }; useEffect(() => { @@ -287,12 +296,7 @@ export const V2ItemRoutingConfigPanel: React.FC = const { tableManagementApi } = await import("@/lib/api/tableManagement"); const res = await tableManagementApi.getTableList(); if (res.success && res.data) { - setTables( - res.data.map((t: any) => ({ - tableName: t.tableName, - displayName: t.displayName || t.tableName, - })) - ); + setTables(res.data.map((t: any) => ({ tableName: t.tableName, displayName: t.displayName || t.tableName }))); } } catch { /* ignore */ } finally { setLoadingTables(false); } }; @@ -301,11 +305,7 @@ export const V2ItemRoutingConfigPanel: React.FC = const dispatchConfigEvent = (newConfig: Partial) => { if (typeof window !== "undefined") { - window.dispatchEvent( - new CustomEvent("componentConfigChanged", { - detail: { config: { ...config, ...newConfig } }, - }) - ); + window.dispatchEvent(new CustomEvent("componentConfigChanged", { detail: { config: { ...config, ...newConfig } } })); } }; @@ -316,61 +316,141 @@ export const V2ItemRoutingConfigPanel: React.FC = }; const updateDataSource = (field: string, value: string) => { - const newDataSource = { ...config.dataSource, [field]: value }; - const partial = { dataSource: newDataSource }; - onChange({ ...configProp, ...partial }); - dispatchConfigEvent(partial); + const newDS = { ...config.dataSource, [field]: value }; + onChange({ ...configProp, dataSource: newDS }); + dispatchConfigEvent({ dataSource: newDS }); }; const updateModals = (field: string, value?: number) => { - const newModals = { ...config.modals, [field]: value }; - const partial = { modals: newModals }; - onChange({ ...configProp, ...partial }); - dispatchConfigEvent(partial); + const newM = { ...config.modals, [field]: value }; + onChange({ ...configProp, modals: newM }); + dispatchConfigEvent({ modals: newM }); }; - // 공정 컬럼 관리 - const addColumn = () => { - update({ - processColumns: [ - ...config.processColumns, - { name: "", label: "새 컬럼", width: 100, align: "left" as const }, - ], - }); - }; - - const removeColumn = (idx: number) => { - update({ processColumns: config.processColumns.filter((_, i) => i !== idx) }); - }; - - const updateColumn = (idx: number, field: keyof ProcessColumnDef, value: string | number) => { - const next = [...config.processColumns]; - next[idx] = { ...next[idx], [field]: value }; - update({ processColumns: next }); + // 필터 조건 관리 + const filters = config.itemFilterConditions || []; + const addFilter = () => update({ itemFilterConditions: [...filters, { column: "", operator: "equals", value: "" }] }); + const removeFilter = (idx: number) => update({ itemFilterConditions: filters.filter((_, i) => i !== idx) }); + const updateFilter = (idx: number, field: keyof ItemFilterCondition, val: string) => { + const next = [...filters]; + next[idx] = { ...next[idx], [field]: val }; + update({ itemFilterConditions: next }); }; return (
- {/* ─── 1단계: 모달 연동 (Collapsible) ─── */} + {/* ─── 품목 목록 모드 ─── */} +
+
+ + 품목 목록 모드 +
+

좌측 품목 목록에 표시할 방식을 선택하세요

+
+ + +
+ {config.itemListMode === "registered" && ( +

+ 현재 화면 ID를 기준으로 품목 목록이 자동 관리됩니다. +

+ )} +
+ + {/* ─── 품목 표시 컬럼 ─── */} + update({ itemDisplayColumns: cols })} + tableName={config.dataSource.itemTable} + title="품목 목록 컬럼" + icon={} + /> + + {/* ─── 모달 표시 컬럼 (등록 모드에서만 의미 있지만 항상 설정 가능) ─── */} + update({ modalDisplayColumns: cols })} + tableName={config.dataSource.itemTable} + title="품목 추가 모달 컬럼" + icon={} + /> + + {/* ─── 품목 필터 조건 ─── */} + + + + + +
+

품목 조회 시 자동으로 적용되는 필터 조건입니다

+ {filters.map((f, idx) => ( +
+
+ 컬럼 + updateFilter(idx, "column", v)} + tableName={config.dataSource.itemTable} placeholder="필터 컬럼" /> +
+
+ 조건 + +
+
+ + updateFilter(idx, "value", e.target.value)} + placeholder="필터값" className="h-7 text-xs" /> +
+ +
+ ))} + +
+
+
+ + {/* ─── 모달 연동 ─── */} - @@ -379,291 +459,103 @@ export const V2ItemRoutingConfigPanel: React.FC =
버전 추가 - updateModals("versionAddScreenId", v)} - /> + updateModals("versionAddScreenId", v)} />
공정 추가 - updateModals("processAddScreenId", v)} - /> + updateModals("processAddScreenId", v)} />
공정 수정 - updateModals("processEditScreenId", v)} - /> + updateModals("processEditScreenId", v)} />
- {/* ─── 2단계: 공정 테이블 컬럼 (Collapsible + 접이식 카드) ─── */} - - - - - -
-

공정 순서 테이블에 표시할 컬럼

-
- {config.processColumns.map((col, idx) => ( - -
- - - - - -
-
- 컬럼명 - updateColumn(idx, "name", e.target.value)} - className="h-7 text-xs" - placeholder="컬럼명" - /> -
-
- 표시명 - updateColumn(idx, "label", e.target.value)} - className="h-7 text-xs" - placeholder="표시명" - /> -
-
- 너비 - updateColumn(idx, "width", parseInt(e.target.value) || 100)} - className="h-7 text-xs" - placeholder="100" - /> -
-
- 정렬 - -
-
-
-
-
- ))} -
- -
-
-
+ {/* ─── 공정 테이블 컬럼 ─── */} + update({ processColumns: cols })} + tableName={config.dataSource.routingDetailTable} + title="공정 테이블 컬럼" + icon={} + /> - {/* ─── 3단계: 데이터 소스 (Collapsible) ─── */} + {/* ─── 데이터 소스 ─── */} -
품목 테이블 - updateDataSource("itemTable", v)} - tables={tables} - loading={loadingTables} - /> + updateDataSource("itemTable", v)} tables={tables} loading={loadingTables} />
품목명 컬럼 - updateDataSource("itemNameColumn", v)} - tableName={config.dataSource.itemTable} - placeholder="품목명" - /> + updateDataSource("itemNameColumn", v)} tableName={config.dataSource.itemTable} placeholder="품목명" />
품목코드 컬럼 - updateDataSource("itemCodeColumn", v)} - tableName={config.dataSource.itemTable} - placeholder="품목코드" - /> + updateDataSource("itemCodeColumn", v)} tableName={config.dataSource.itemTable} placeholder="품목코드" />
라우팅 버전 테이블 - updateDataSource("routingVersionTable", v)} - tables={tables} - loading={loadingTables} - /> + updateDataSource("routingVersionTable", v)} tables={tables} loading={loadingTables} />
품목 FK 컬럼 - updateDataSource("routingVersionFkColumn", v)} - tableName={config.dataSource.routingVersionTable} - placeholder="FK 컬럼" - /> + updateDataSource("routingVersionFkColumn", v)} tableName={config.dataSource.routingVersionTable} placeholder="FK 컬럼" />
버전명 컬럼 - updateDataSource("routingVersionNameColumn", v)} - tableName={config.dataSource.routingVersionTable} - placeholder="버전명" - /> + updateDataSource("routingVersionNameColumn", v)} tableName={config.dataSource.routingVersionTable} placeholder="버전명" />
라우팅 상세 테이블 - updateDataSource("routingDetailTable", v)} - tables={tables} - loading={loadingTables} - /> + updateDataSource("routingDetailTable", v)} tables={tables} loading={loadingTables} />
버전 FK 컬럼 - updateDataSource("routingDetailFkColumn", v)} - tableName={config.dataSource.routingDetailTable} - placeholder="FK 컬럼" - /> + updateDataSource("routingDetailFkColumn", v)} tableName={config.dataSource.routingDetailTable} placeholder="FK 컬럼" />
공정 마스터 테이블 - updateDataSource("processTable", v)} - tables={tables} - loading={loadingTables} - /> + updateDataSource("processTable", v)} tables={tables} loading={loadingTables} />
공정명 컬럼 - updateDataSource("processNameColumn", v)} - tableName={config.dataSource.processTable} - placeholder="공정명" - /> + updateDataSource("processNameColumn", v)} tableName={config.dataSource.processTable} placeholder="공정명" />
공정코드 컬럼 - updateDataSource("processCodeColumn", v)} - tableName={config.dataSource.processTable} - placeholder="공정코드" - /> + updateDataSource("processCodeColumn", v)} tableName={config.dataSource.processTable} placeholder="공정코드" />
- {/* ─── 4단계: 레이아웃 & 기타 (Collapsible) ─── */} + {/* ─── 레이아웃 & 기타 ─── */} - @@ -673,76 +565,38 @@ export const V2ItemRoutingConfigPanel: React.FC = 좌측 패널 비율 (%)

품목 목록 패널의 너비

- update({ splitRatio: parseInt(e.target.value) || 40 })} - className="h-7 w-[80px] text-xs" - /> + update({ splitRatio: parseInt(e.target.value) || 40 })} className="h-7 w-[80px] text-xs" />
-
좌측 패널 제목 - update({ leftPanelTitle: e.target.value })} - placeholder="품목 목록" - className="h-7 w-[140px] text-xs" - /> + update({ leftPanelTitle: e.target.value })} placeholder="품목 목록" className="h-7 w-[140px] text-xs" />
-
우측 패널 제목 - update({ rightPanelTitle: e.target.value })} - placeholder="공정 순서" - className="h-7 w-[140px] text-xs" - /> + update({ rightPanelTitle: e.target.value })} placeholder="공정 순서" className="h-7 w-[140px] text-xs" />
-
버전 추가 버튼 텍스트 - update({ versionAddButtonText: e.target.value })} - placeholder="+ 라우팅 버전 추가" - className="h-7 w-[140px] text-xs" - /> + update({ versionAddButtonText: e.target.value })} placeholder="+ 라우팅 버전 추가" className="h-7 w-[140px] text-xs" />
-
공정 추가 버튼 텍스트 - update({ processAddButtonText: e.target.value })} - placeholder="+ 공정 추가" - className="h-7 w-[140px] text-xs" - /> + update({ processAddButtonText: e.target.value })} placeholder="+ 공정 추가" className="h-7 w-[140px] text-xs" />
-

첫 번째 버전 자동 선택

품목 선택 시 첫 버전을 자동으로 선택해요

- update({ autoSelectFirstVersion: checked })} - /> + update({ autoSelectFirstVersion: checked })} />
-

읽기 전용

추가/수정/삭제 버튼을 숨겨요

- update({ readonly: checked })} - /> + update({ readonly: checked })} />
@@ -752,5 +606,4 @@ export const V2ItemRoutingConfigPanel: React.FC = }; V2ItemRoutingConfigPanel.displayName = "V2ItemRoutingConfigPanel"; - export default V2ItemRoutingConfigPanel; diff --git a/frontend/lib/registry/components/v2-item-routing/ItemRoutingComponent.tsx b/frontend/lib/registry/components/v2-item-routing/ItemRoutingComponent.tsx index a8f752f9..d0144d0b 100644 --- a/frontend/lib/registry/components/v2-item-routing/ItemRoutingComponent.tsx +++ b/frontend/lib/registry/components/v2-item-routing/ItemRoutingComponent.tsx @@ -1,217 +1,203 @@ "use client"; -import React, { useState, useEffect, useCallback, useMemo } from "react"; -import { Search, Plus, Trash2, Edit, ListOrdered, Package, Star } from "lucide-react"; +import React, { useState, useEffect, useCallback } from "react"; +import { Search, Plus, Trash2, Edit, ListOrdered, Package, Star, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, + AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, + AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; +import { + Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, +} from "@/components/ui/dialog"; +import { Checkbox } from "@/components/ui/checkbox"; import { cn } from "@/lib/utils"; import { useToast } from "@/hooks/use-toast"; -import { ItemRoutingConfig, ItemRoutingComponentProps } from "./types"; -import { defaultConfig } from "./config"; +import { ItemRoutingComponentProps, ColumnDef } from "./types"; import { useItemRouting } from "./hooks/useItemRouting"; +const DEFAULT_ITEM_COLS: ColumnDef[] = [ + { name: "item_name", label: "품명" }, + { name: "item_code", label: "품번", width: 100 }, +]; + export function ItemRoutingComponent({ config: configProp, isPreview, + screenId, }: ItemRoutingComponentProps) { const { toast } = useToast(); + const resolvedConfig = React.useMemo(() => { + if (configProp?.itemListMode === "registered" && !configProp?.screenCode && screenId) { + return { ...configProp, screenCode: `screen_${screenId}` }; + } + return configProp; + }, [configProp, screenId]); + const { - config, - items, - versions, - details, - loading, - selectedItemCode, - selectedItemName, - selectedVersionId, - fetchItems, - selectItem, - selectVersion, - refreshVersions, - refreshDetails, - deleteDetail, - deleteVersion, - setDefaultVersion, - unsetDefaultVersion, - } = useItemRouting(configProp || {}); + config, items, allItems, versions, details, loading, + selectedItemCode, selectedItemName, selectedVersionId, isRegisteredMode, + fetchItems, fetchRegisteredItems, fetchAllItems, + registerItemsBatch, unregisterItem, + selectItem, selectVersion, refreshVersions, refreshDetails, + deleteDetail, deleteVersion, setDefaultVersion, unsetDefaultVersion, + } = useItemRouting(resolvedConfig || {}); const [searchText, setSearchText] = useState(""); const [deleteTarget, setDeleteTarget] = useState<{ - type: "version" | "detail"; - id: string; - name: string; + type: "version" | "detail"; id: string; name: string; } | null>(null); - // 초기 로딩 (마운트 시 1회만) + // 품목 추가 다이얼로그 + const [addDialogOpen, setAddDialogOpen] = useState(false); + const [addSearchText, setAddSearchText] = useState(""); + const [selectedAddItems, setSelectedAddItems] = useState>(new Set()); + const [addLoading, setAddLoading] = useState(false); + + const itemDisplayCols = config.itemDisplayColumns?.length + ? config.itemDisplayColumns : DEFAULT_ITEM_COLS; + const modalDisplayCols = config.modalDisplayColumns?.length + ? config.modalDisplayColumns : DEFAULT_ITEM_COLS; + + // 초기 로딩 const mountedRef = React.useRef(false); useEffect(() => { if (!mountedRef.current) { mountedRef.current = true; - fetchItems(); + if (isRegisteredMode) fetchRegisteredItems(); + else fetchItems(); } - }, [fetchItems]); + }, [fetchItems, fetchRegisteredItems, isRegisteredMode]); - // 모달 저장 성공 감지 -> 데이터 새로고침 + // 모달 저장 성공 감지 const refreshVersionsRef = React.useRef(refreshVersions); const refreshDetailsRef = React.useRef(refreshDetails); refreshVersionsRef.current = refreshVersions; refreshDetailsRef.current = refreshDetails; - useEffect(() => { - const handleSaveSuccess = () => { - refreshVersionsRef.current(); - refreshDetailsRef.current(); - }; - window.addEventListener("saveSuccessInModal", handleSaveSuccess); - return () => { - window.removeEventListener("saveSuccessInModal", handleSaveSuccess); - }; + const h = () => { refreshVersionsRef.current(); refreshDetailsRef.current(); }; + window.addEventListener("saveSuccessInModal", h); + return () => window.removeEventListener("saveSuccessInModal", h); }, []); - // 품목 검색 + // 검색 const handleSearch = useCallback(() => { - fetchItems(searchText || undefined); - }, [fetchItems, searchText]); + if (isRegisteredMode) fetchRegisteredItems(searchText || undefined); + else fetchItems(searchText || undefined); + }, [fetchItems, fetchRegisteredItems, isRegisteredMode, searchText]); - const handleSearchKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === "Enter") handleSearch(); + // ──── 품목 추가 모달 ──── + const handleOpenAddDialog = useCallback(() => { + setAddSearchText(""); setSelectedAddItems(new Set()); setAddDialogOpen(true); + fetchAllItems(); + }, [fetchAllItems]); + + const handleToggleAddItem = useCallback((itemId: string) => { + setSelectedAddItems((prev) => { + const next = new Set(prev); + next.has(itemId) ? next.delete(itemId) : next.add(itemId); + return next; + }); + }, []); + + const handleConfirmAdd = useCallback(async () => { + if (selectedAddItems.size === 0) return; + setAddLoading(true); + const itemList = allItems + .filter((item) => selectedAddItems.has(item.id)) + .map((item) => ({ + itemId: item.id, + itemCode: item.item_code || item[config.dataSource.itemCodeColumn] || "", + })); + const success = await registerItemsBatch(itemList); + setAddLoading(false); + if (success) { + toast({ title: `${itemList.length}개 품목이 등록되었습니다` }); + setAddDialogOpen(false); + } else { + toast({ title: "품목 등록 실패", variant: "destructive" }); + } + }, [selectedAddItems, allItems, config.dataSource.itemCodeColumn, registerItemsBatch, toast]); + + const handleUnregisterItem = useCallback( + async (registeredId: string, itemName: string) => { + const success = await unregisterItem(registeredId); + if (success) toast({ title: `${itemName} 등록 해제됨` }); + else toast({ title: "등록 해제 실패", variant: "destructive" }); }, - [handleSearch] + [unregisterItem, toast] ); - // 버전 추가 모달 + // ──── 기존 핸들러 ──── const handleAddVersion = useCallback(() => { - if (!selectedItemCode) { - toast({ title: "품목을 먼저 선택해주세요", variant: "destructive" }); - return; - } - const screenId = config.modals.versionAddScreenId; - if (!screenId) return; - - window.dispatchEvent( - new CustomEvent("openScreenModal", { - detail: { - screenId, - urlParams: { mode: "add", tableName: config.dataSource.routingVersionTable }, - splitPanelParentData: { - [config.dataSource.routingVersionFkColumn]: selectedItemCode, - }, - }, - }) - ); + if (!selectedItemCode) { toast({ title: "품목을 먼저 선택해주세요", variant: "destructive" }); return; } + const sid = config.modals.versionAddScreenId; + if (!sid) return; + window.dispatchEvent(new CustomEvent("openScreenModal", { + detail: { screenId: sid, urlParams: { mode: "add", tableName: config.dataSource.routingVersionTable }, + splitPanelParentData: { [config.dataSource.routingVersionFkColumn]: selectedItemCode } }, + })); }, [selectedItemCode, config, toast]); - // 공정 추가 모달 const handleAddProcess = useCallback(() => { - if (!selectedVersionId) { - toast({ title: "라우팅 버전을 먼저 선택해주세요", variant: "destructive" }); - return; - } - const screenId = config.modals.processAddScreenId; - if (!screenId) return; - - window.dispatchEvent( - new CustomEvent("openScreenModal", { - detail: { - screenId, - urlParams: { mode: "add", tableName: config.dataSource.routingDetailTable }, - splitPanelParentData: { - [config.dataSource.routingDetailFkColumn]: selectedVersionId, - }, - }, - }) - ); + if (!selectedVersionId) { toast({ title: "라우팅 버전을 먼저 선택해주세요", variant: "destructive" }); return; } + const sid = config.modals.processAddScreenId; + if (!sid) return; + window.dispatchEvent(new CustomEvent("openScreenModal", { + detail: { screenId: sid, urlParams: { mode: "add", tableName: config.dataSource.routingDetailTable }, + splitPanelParentData: { [config.dataSource.routingDetailFkColumn]: selectedVersionId } }, + })); }, [selectedVersionId, config, toast]); - // 공정 수정 모달 const handleEditProcess = useCallback( (detail: Record) => { - const screenId = config.modals.processEditScreenId; - if (!screenId) return; - - window.dispatchEvent( - new CustomEvent("openScreenModal", { - detail: { - screenId, - urlParams: { mode: "edit", tableName: config.dataSource.routingDetailTable }, - editData: detail, - }, - }) - ); - }, - [config] + const sid = config.modals.processEditScreenId; + if (!sid) return; + window.dispatchEvent(new CustomEvent("openScreenModal", { + detail: { screenId: sid, urlParams: { mode: "edit", tableName: config.dataSource.routingDetailTable }, editData: detail }, + })); + }, [config] ); - // 기본 버전 토글 const handleToggleDefault = useCallback( async (versionId: string, currentIsDefault: boolean) => { - let success: boolean; - if (currentIsDefault) { - success = await unsetDefaultVersion(versionId); - if (success) toast({ title: "기본 버전이 해제되었습니다" }); - } else { - success = await setDefaultVersion(versionId); - if (success) toast({ title: "기본 버전으로 설정되었습니다" }); - } - if (!success) { - toast({ title: "기본 버전 변경 실패", variant: "destructive" }); - } + const success = currentIsDefault ? await unsetDefaultVersion(versionId) : await setDefaultVersion(versionId); + if (success) toast({ title: currentIsDefault ? "기본 버전이 해제되었습니다" : "기본 버전으로 설정되었습니다" }); + else toast({ title: "기본 버전 변경 실패", variant: "destructive" }); }, [setDefaultVersion, unsetDefaultVersion, toast] ); - // 삭제 확인 const handleConfirmDelete = useCallback(async () => { if (!deleteTarget) return; - - let success = false; - if (deleteTarget.type === "version") { - success = await deleteVersion(deleteTarget.id); - } else { - success = await deleteDetail(deleteTarget.id); - } - - if (success) { - toast({ title: `${deleteTarget.name} 삭제 완료` }); - } else { - toast({ title: "삭제 실패", variant: "destructive" }); - } + const success = deleteTarget.type === "version" + ? await deleteVersion(deleteTarget.id) : await deleteDetail(deleteTarget.id); + toast({ title: success ? `${deleteTarget.name} 삭제 완료` : "삭제 실패", variant: success ? undefined : "destructive" }); setDeleteTarget(null); }, [deleteTarget, deleteVersion, deleteDetail, toast]); const splitRatio = config.splitRatio || 40; + const registeredItemIds = React.useMemo(() => new Set(items.map((i) => i.id)), [items]); + + // ──── 셀 값 추출 헬퍼 ──── + const getCellValue = (item: Record, colName: string) => { + return item[colName] ?? item[`item_${colName}`] ?? "-"; + }; if (isPreview) { return (
-

- 품목별 라우팅 관리 -

+

품목별 라우팅 관리

- 품목 선택 - 라우팅 버전 - 공정 순서 + {isRegisteredMode ? "등록 품목 모드" : "전체 품목 모드"}

@@ -221,94 +207,111 @@ export function ItemRoutingComponent({ return (
- {/* 좌측 패널: 품목 목록 */} -
-
+ {/* ════ 좌측 패널: 품목 목록 (테이블) ════ */} +
+

{config.leftPanelTitle || "품목 목록"} + {isRegisteredMode && ( + (등록 모드) + )}

+ {isRegisteredMode && !config.readonly && ( + + )}
- {/* 검색 */}
- setSearchText(e.target.value)} - onKeyDown={handleSearchKeyDown} - placeholder="품목명/품번 검색" - className="h-8 text-xs" - /> + setSearchText(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") handleSearch(); }} + placeholder="품목명/품번 검색" className="h-8 text-xs" />
- {/* 품목 리스트 */} -
+ {/* 품목 테이블 */} +
{items.length === 0 ? ( -
+

- {loading ? "로딩 중..." : "품목이 없습니다"} + {loading ? "로딩 중..." : isRegisteredMode ? "등록된 품목이 없습니다" : "품목이 없습니다"}

+ {isRegisteredMode && !loading && !config.readonly && ( + + )}
) : ( -
- {items.map((item) => { - const itemCode = - item[config.dataSource.itemCodeColumn] || item.item_code || item.item_number; - const itemName = - item[config.dataSource.itemNameColumn] || item.item_name; - const isSelected = selectedItemCode === itemCode; + + + + {itemDisplayCols.map((col) => ( + + {col.label} + + ))} + {isRegisteredMode && !config.readonly && ( + + )} + + + + {items.map((item) => { + const itemCode = item[config.dataSource.itemCodeColumn] || item.item_code || item.item_number; + const itemName = item[config.dataSource.itemNameColumn] || item.item_name; + const isSelected = selectedItemCode === itemCode; - return ( - - ); - })} - + return ( + selectItem(itemCode, itemName)}> + {itemDisplayCols.map((col) => ( + + {getCellValue(item, col.name)} + + ))} + {isRegisteredMode && !config.readonly && item.registered_id && ( + + + + )} + + ); + })} + +
)}
- {/* 우측 패널: 버전 + 공정 */} + {/* ════ 우측 패널: 버전 + 공정 ════ */}
{selectedItemCode ? ( <> - {/* 헤더: 선택된 품목 + 버전 추가 */}

{selectedItemName}

{selectedItemCode}

{!config.readonly && ( - )}
- {/* 버전 선택 버튼들 */} {versions.length > 0 ? (
버전: @@ -317,50 +320,24 @@ export function ItemRoutingComponent({ const isDefault = ver.is_default === true; return (
- selectVersion(ver.id)} - > + isDefault && !isActive && "border-amber-400 bg-amber-50 text-amber-700")} + onClick={() => selectVersion(ver.id)}> {isDefault && } {ver[config.dataSource.routingVersionNameColumn] || ver.version_name || ver.id} {!config.readonly && ( <> - - @@ -371,112 +348,65 @@ export function ItemRoutingComponent({
) : (
-

- 라우팅 버전이 없습니다. 버전을 추가해주세요. -

+

라우팅 버전이 없습니다. 버전을 추가해주세요.

)} - {/* 공정 테이블 */} {selectedVersionId ? (
- {/* 공정 테이블 헤더 */}

{config.rightPanelTitle || "공정 순서"} ({details.length}건)

{!config.readonly && ( - )}
- - {/* 테이블 */}
{details.length === 0 ? (
-

- {loading ? "로딩 중..." : "등록된 공정이 없습니다"} -

+

{loading ? "로딩 중..." : "등록된 공정이 없습니다"}

) : ( {config.processColumns.map((col) => ( - + className={cn("text-xs", col.align === "center" && "text-center", col.align === "right" && "text-right")}> {col.label} ))} - {!config.readonly && ( - - 관리 - - )} + {!config.readonly && 관리} {details.map((detail) => ( {config.processColumns.map((col) => { - let cellValue = detail[col.name]; - if (cellValue == null) { - const aliasKey = Object.keys(detail).find( - (k) => k.endsWith(`_${col.name}`) - ); - if (aliasKey) cellValue = detail[aliasKey]; + let v = detail[col.name]; + if (v == null) { + const ak = Object.keys(detail).find((k) => k.endsWith(`_${col.name}`)); + if (ak) v = detail[ak]; } return ( - - {cellValue ?? "-"} + + {v ?? "-"} ); })} {!config.readonly && (
- -
@@ -492,9 +422,7 @@ export function ItemRoutingComponent({ ) : ( versions.length > 0 && (
-

- 라우팅 버전을 선택해주세요 -

+

라우팅 버전을 선택해주세요

) )} @@ -502,43 +430,121 @@ export function ItemRoutingComponent({ ) : (
-

- 좌측에서 품목을 선택하세요 -

-

- 품목을 선택하면 라우팅 버전별 공정 순서를 관리할 수 있습니다 -

+

좌측에서 품목을 선택하세요

+

품목을 선택하면 라우팅 버전별 공정 순서를 관리할 수 있습니다

)} - {/* 삭제 확인 다이얼로그 */} + {/* ════ 삭제 확인 ════ */} setDeleteTarget(null)}> 삭제 확인 {deleteTarget?.name}을(를) 삭제하시겠습니까? - {deleteTarget?.type === "version" && ( - <> -
- 해당 버전에 포함된 모든 공정 정보도 함께 삭제됩니다. - - )} + {deleteTarget?.type === "version" && (<>
해당 버전에 포함된 모든 공정 정보도 함께 삭제됩니다.)}
취소 - + 삭제
+ + {/* ════ 품목 추가 다이얼로그 (테이블 형태 + 검색) ════ */} + + + + 품목 추가 + + 좌측 목록에 표시할 품목을 선택하세요 + {(config.itemFilterConditions?.length ?? 0) > 0 && ( + + (필터 {config.itemFilterConditions!.length}건 적용됨) + + )} + + + +
+ setAddSearchText(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") fetchAllItems(addSearchText || undefined); }} + placeholder="품목명/품번 검색" className="h-8 text-xs sm:h-10 sm:text-sm" /> + +
+ +
+ {allItems.length === 0 ? ( +
+

품목이 없습니다

+
+ ) : ( +
+ + + + {modalDisplayCols.map((col) => ( + + {col.label} + + ))} + 상태 + + + + {allItems.map((item) => { + const isAlreadyRegistered = registeredItemIds.has(item.id); + const isChecked = selectedAddItems.has(item.id); + return ( + { if (!isAlreadyRegistered) handleToggleAddItem(item.id); }}> + + + + {modalDisplayCols.map((col) => ( + + {getCellValue(item, col.name)} + + ))} + + {isAlreadyRegistered && ( + 등록됨 + )} + + + ); + })} + +
+ )} +
+ + {selectedAddItems.size > 0 && ( +

{selectedAddItems.size}개 선택됨

+ )} + + + + + + +
); } diff --git a/frontend/lib/registry/components/v2-item-routing/ItemRoutingRenderer.tsx b/frontend/lib/registry/components/v2-item-routing/ItemRoutingRenderer.tsx index 7a9fa624..653d351d 100644 --- a/frontend/lib/registry/components/v2-item-routing/ItemRoutingRenderer.tsx +++ b/frontend/lib/registry/components/v2-item-routing/ItemRoutingRenderer.tsx @@ -9,7 +9,7 @@ export class ItemRoutingRenderer extends AutoRegisteringComponentRenderer { static componentDefinition = V2ItemRoutingDefinition; render(): React.ReactElement { - const { formData, isPreview, config, tableName } = this.props as Record< + const { formData, isPreview, config, tableName, screenId } = this.props as Record< string, unknown >; @@ -20,6 +20,7 @@ export class ItemRoutingRenderer extends AutoRegisteringComponentRenderer { formData={formData as Record} tableName={tableName as string} isPreview={isPreview as boolean} + screenId={screenId as number | string} /> ); } diff --git a/frontend/lib/registry/components/v2-item-routing/config.ts b/frontend/lib/registry/components/v2-item-routing/config.ts index a84ff23e..42ae1479 100644 --- a/frontend/lib/registry/components/v2-item-routing/config.ts +++ b/frontend/lib/registry/components/v2-item-routing/config.ts @@ -35,4 +35,15 @@ export const defaultConfig: ItemRoutingConfig = { autoSelectFirstVersion: true, versionAddButtonText: "+ 라우팅 버전 추가", processAddButtonText: "+ 공정 추가", + itemListMode: "all", + screenCode: "", + itemDisplayColumns: [ + { name: "item_name", label: "품명" }, + { name: "item_code", label: "품번", width: 100 }, + ], + modalDisplayColumns: [ + { name: "item_name", label: "품명" }, + { name: "item_code", label: "품번", width: 100 }, + ], + itemFilterConditions: [], }; diff --git a/frontend/lib/registry/components/v2-item-routing/hooks/useItemRouting.ts b/frontend/lib/registry/components/v2-item-routing/hooks/useItemRouting.ts index 97f6be4f..bd1f551b 100644 --- a/frontend/lib/registry/components/v2-item-routing/hooks/useItemRouting.ts +++ b/frontend/lib/registry/components/v2-item-routing/hooks/useItemRouting.ts @@ -1,12 +1,21 @@ "use client"; -import { useState, useCallback, useEffect, useMemo, useRef } from "react"; +import { useState, useCallback, useMemo, useRef } from "react"; import { apiClient } from "@/lib/api/client"; -import { ItemRoutingConfig, ItemData, RoutingVersionData, RoutingDetailData } from "../types"; +import { ItemRoutingConfig, ItemData, RoutingVersionData, RoutingDetailData, ColumnDef } from "../types"; import { defaultConfig } from "../config"; const API_BASE = "/process-work-standard"; +/** 표시 컬럼 목록에서 기본(item_name, item_code) 외 추가 컬럼만 추출 */ +function getExtraColumnNames(columns?: ColumnDef[]): string { + if (!columns || columns.length === 0) return ""; + return columns + .map((c) => c.name) + .filter((n) => n && n !== "item_name" && n !== "item_code") + .join(","); +} + export function useItemRouting(configPartial: Partial) { const configKey = useMemo( () => JSON.stringify(configPartial), @@ -27,21 +36,81 @@ export function useItemRouting(configPartial: Partial) { configRef.current = config; const [items, setItems] = useState([]); + const [allItems, setAllItems] = useState([]); const [versions, setVersions] = useState([]); const [details, setDetails] = useState([]); const [loading, setLoading] = useState(false); - // 선택 상태 const [selectedItemCode, setSelectedItemCode] = useState(null); const [selectedItemName, setSelectedItemName] = useState(null); const [selectedVersionId, setSelectedVersionId] = useState(null); - // 품목 목록 조회 + const isRegisteredMode = config.itemListMode === "registered"; + + /** API 기본 파라미터 생성 */ + const buildBaseParams = useCallback((search?: string, columns?: ColumnDef[]) => { + const ds = configRef.current.dataSource; + const extra = getExtraColumnNames(columns); + const filters = configRef.current.itemFilterConditions; + const params: Record = { + tableName: ds.itemTable, + nameColumn: ds.itemNameColumn, + codeColumn: ds.itemCodeColumn, + routingTable: ds.routingVersionTable, + routingFkColumn: ds.routingVersionFkColumn, + }; + if (search) params.search = search; + if (extra) params.extraColumns = extra; + if (filters && filters.length > 0) { + params.filterConditions = JSON.stringify(filters); + } + return new URLSearchParams(params); + }, []); + + // ──────────────────────────────────────── + // 품목 목록 조회 (all 모드) + // ──────────────────────────────────────── const fetchItems = useCallback( async (search?: string) => { + try { + setLoading(true); + const cols = configRef.current.itemDisplayColumns; + const params = buildBaseParams(search, cols); + const res = await apiClient.get(`${API_BASE}/items?${params}`); + if (res.data?.success) { + const data = res.data.data || []; + if (configRef.current.itemListMode !== "registered") { + setItems(data); + } + return data; + } + } catch (err) { + console.error("품목 조회 실패", err); + } finally { + setLoading(false); + } + return []; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [configKey, buildBaseParams] + ); + + // ──────────────────────────────────────── + // 등록 품목 조회 (registered 모드) + // ──────────────────────────────────────── + const fetchRegisteredItems = useCallback( + async (search?: string) => { + const screenCode = configRef.current.screenCode; + if (!screenCode) { + console.warn("screenCode가 설정되지 않았습니다"); + setItems([]); + return; + } try { setLoading(true); const ds = configRef.current.dataSource; + const cols = configRef.current.itemDisplayColumns; + const extra = getExtraColumnNames(cols); const params = new URLSearchParams({ tableName: ds.itemTable, nameColumn: ds.itemNameColumn, @@ -49,13 +118,16 @@ export function useItemRouting(configPartial: Partial) { routingTable: ds.routingVersionTable, routingFkColumn: ds.routingVersionFkColumn, ...(search ? { search } : {}), + ...(extra ? { extraColumns: extra } : {}), }); - const res = await apiClient.get(`${API_BASE}/items?${params}`); + const res = await apiClient.get( + `${API_BASE}/registered-items/${encodeURIComponent(screenCode)}?${params}` + ); if (res.data?.success) { setItems(res.data.data || []); } } catch (err) { - console.error("품목 조회 실패", err); + console.error("등록 품목 조회 실패", err); } finally { setLoading(false); } @@ -64,7 +136,104 @@ export function useItemRouting(configPartial: Partial) { [configKey] ); - // 라우팅 버전 목록 조회 + // ──────────────────────────────────────── + // 전체 품목 조회 (등록 팝업용 - 필터+추가컬럼 적용) + // ──────────────────────────────────────── + const fetchAllItems = useCallback( + async (search?: string) => { + try { + const cols = configRef.current.modalDisplayColumns; + const params = buildBaseParams(search, cols); + const res = await apiClient.get(`${API_BASE}/items?${params}`); + if (res.data?.success) { + setAllItems(res.data.data || []); + } + } catch (err) { + console.error("전체 품목 조회 실패", err); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [configKey, buildBaseParams] + ); + + // ──────────────────────────────────────── + // 품목 등록/제거 (registered 모드) + // ──────────────────────────────────────── + const registerItem = useCallback( + async (itemId: string, itemCode: string) => { + const screenCode = configRef.current.screenCode; + if (!screenCode) return false; + try { + const res = await apiClient.post(`${API_BASE}/registered-items`, { + screenCode, + itemId, + itemCode, + }); + if (res.data?.success) { + await fetchRegisteredItems(); + return true; + } + } catch (err) { + console.error("품목 등록 실패", err); + } + return false; + }, + [fetchRegisteredItems] + ); + + const registerItemsBatch = useCallback( + async (itemList: { itemId: string; itemCode: string }[]) => { + const screenCode = configRef.current.screenCode; + if (!screenCode) return false; + try { + const res = await apiClient.post(`${API_BASE}/registered-items/batch`, { + screenCode, + items: itemList, + }); + if (res.data?.success) { + await fetchRegisteredItems(); + return true; + } + } catch (err) { + console.error("품목 일괄 등록 실패", err); + } + return false; + }, + [fetchRegisteredItems] + ); + + const unregisterItem = useCallback( + async (registeredId: string) => { + try { + const res = await apiClient.delete(`${API_BASE}/registered-items/${registeredId}`); + if (res.data?.success) { + if (selectedItemCode) { + const removedItem = items.find((i) => i.registered_id === registeredId); + if (removedItem) { + const removedCode = removedItem.item_code || removedItem[configRef.current.dataSource.itemCodeColumn]; + if (selectedItemCode === removedCode) { + setSelectedItemCode(null); + setSelectedItemName(null); + setSelectedVersionId(null); + setVersions([]); + setDetails([]); + } + } + } + await fetchRegisteredItems(); + return true; + } + } catch (err) { + console.error("등록 품목 제거 실패", err); + } + return false; + }, + [selectedItemCode, items, fetchRegisteredItems] + ); + + // ──────────────────────────────────────── + // 라우팅 버전/공정 관련 (기존 동일) + // ──────────────────────────────────────── const fetchVersions = useCallback( async (itemCode: string) => { try { @@ -94,7 +263,6 @@ export function useItemRouting(configPartial: Partial) { [configKey] ); - // 공정 상세 목록 조회 (특정 버전의 공정들) - entity join 포함 const fetchDetails = useCallback( async (versionId: string) => { try { @@ -128,18 +296,14 @@ export function useItemRouting(configPartial: Partial) { [configKey] ); - // 품목 선택 const selectItem = useCallback( async (itemCode: string, itemName: string) => { setSelectedItemCode(itemCode); setSelectedItemName(itemName); setSelectedVersionId(null); setDetails([]); - const versionList = await fetchVersions(itemCode); - if (versionList.length > 0) { - // 기본 버전 우선, 없으면 첫번째 버전 선택 const defaultVersion = versionList.find((v: RoutingVersionData) => v.is_default); const targetVersion = defaultVersion || (configRef.current.autoSelectFirstVersion ? versionList[0] : null); if (targetVersion) { @@ -151,7 +315,6 @@ export function useItemRouting(configPartial: Partial) { [fetchVersions, fetchDetails] ); - // 버전 선택 const selectVersion = useCallback( async (versionId: string) => { setSelectedVersionId(versionId); @@ -160,7 +323,6 @@ export function useItemRouting(configPartial: Partial) { [fetchDetails] ); - // 모달에서 데이터 변경 후 새로고침 const refreshVersions = useCallback(async () => { if (selectedItemCode) { const versionList = await fetchVersions(selectedItemCode); @@ -180,7 +342,6 @@ export function useItemRouting(configPartial: Partial) { } }, [selectedVersionId, fetchDetails]); - // 공정 삭제 const deleteDetail = useCallback( async (detailId: string) => { try { @@ -189,19 +350,13 @@ export function useItemRouting(configPartial: Partial) { `/table-management/tables/${ds.routingDetailTable}/delete`, { data: [{ id: detailId }] } ); - if (res.data?.success) { - await refreshDetails(); - return true; - } - } catch (err) { - console.error("공정 삭제 실패", err); - } + if (res.data?.success) { await refreshDetails(); return true; } + } catch (err) { console.error("공정 삭제 실패", err); } return false; }, [refreshDetails] ); - // 버전 삭제 const deleteVersion = useCallback( async (versionId: string) => { try { @@ -211,22 +366,16 @@ export function useItemRouting(configPartial: Partial) { { data: [{ id: versionId }] } ); if (res.data?.success) { - if (selectedVersionId === versionId) { - setSelectedVersionId(null); - setDetails([]); - } + if (selectedVersionId === versionId) { setSelectedVersionId(null); setDetails([]); } await refreshVersions(); return true; } - } catch (err) { - console.error("버전 삭제 실패", err); - } + } catch (err) { console.error("버전 삭제 실패", err); } return false; }, [selectedVersionId, refreshVersions] ); - // 기본 버전 설정 const setDefaultVersion = useCallback( async (versionId: string) => { try { @@ -236,20 +385,15 @@ export function useItemRouting(configPartial: Partial) { routingFkColumn: ds.routingVersionFkColumn, }); if (res.data?.success) { - if (selectedItemCode) { - await fetchVersions(selectedItemCode); - } + if (selectedItemCode) await fetchVersions(selectedItemCode); return true; } - } catch (err) { - console.error("기본 버전 설정 실패", err); - } + } catch (err) { console.error("기본 버전 설정 실패", err); } return false; }, [selectedItemCode, fetchVersions] ); - // 기본 버전 해제 const unsetDefaultVersion = useCallback( async (versionId: string) => { try { @@ -258,14 +402,10 @@ export function useItemRouting(configPartial: Partial) { routingVersionTable: ds.routingVersionTable, }); if (res.data?.success) { - if (selectedItemCode) { - await fetchVersions(selectedItemCode); - } + if (selectedItemCode) await fetchVersions(selectedItemCode); return true; } - } catch (err) { - console.error("기본 버전 해제 실패", err); - } + } catch (err) { console.error("기본 버전 해제 실패", err); } return false; }, [selectedItemCode, fetchVersions] @@ -274,13 +414,20 @@ export function useItemRouting(configPartial: Partial) { return { config, items, + allItems, versions, details, loading, selectedItemCode, selectedItemName, selectedVersionId, + isRegisteredMode, fetchItems, + fetchRegisteredItems, + fetchAllItems, + registerItem, + registerItemsBatch, + unregisterItem, selectItem, selectVersion, refreshVersions, diff --git a/frontend/lib/registry/components/v2-item-routing/types.ts b/frontend/lib/registry/components/v2-item-routing/types.ts index 06b108da..08fe73c2 100644 --- a/frontend/lib/registry/components/v2-item-routing/types.ts +++ b/frontend/lib/registry/components/v2-item-routing/types.ts @@ -10,10 +10,10 @@ export interface ItemRoutingDataSource { itemNameColumn: string; itemCodeColumn: string; routingVersionTable: string; - routingVersionFkColumn: string; // item_routing_version에서 item_code를 가리키는 FK + routingVersionFkColumn: string; routingVersionNameColumn: string; routingDetailTable: string; - routingDetailFkColumn: string; // item_routing_detail에서 routing_version_id를 가리키는 FK + routingDetailFkColumn: string; processTable: string; processNameColumn: string; processCodeColumn: string; @@ -26,14 +26,24 @@ export interface ItemRoutingModals { processEditScreenId?: number; } -// 공정 테이블 컬럼 정의 -export interface ProcessColumnDef { +// 컬럼 정의 (공정/품목 공용) +export interface ColumnDef { name: string; label: string; width?: number; align?: "left" | "center" | "right"; } +// 공정 테이블 컬럼 정의 (기존 호환) +export type ProcessColumnDef = ColumnDef; + +// 품목 필터 조건 +export interface ItemFilterCondition { + column: string; + operator: "equals" | "contains" | "not_equals"; + value: string; +} + // 전체 Config export interface ItemRoutingConfig { dataSource: ItemRoutingDataSource; @@ -46,6 +56,14 @@ export interface ItemRoutingConfig { autoSelectFirstVersion?: boolean; versionAddButtonText?: string; processAddButtonText?: string; + itemListMode?: "all" | "registered"; + screenCode?: string; + /** 좌측 품목 목록에 표시할 컬럼 */ + itemDisplayColumns?: ColumnDef[]; + /** 품목 추가 모달에 표시할 컬럼 */ + modalDisplayColumns?: ColumnDef[]; + /** 품목 조회 시 사전 필터 조건 */ + itemFilterConditions?: ItemFilterCondition[]; } // 컴포넌트 Props @@ -54,6 +72,7 @@ export interface ItemRoutingComponentProps { formData?: Record; isPreview?: boolean; tableName?: string; + screenId?: number | string; } // 데이터 모델