From 3df9a39ebea1461174935f74ab4c5352f74888ea Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 13 Mar 2026 11:26:59 +0900 Subject: [PATCH] feat: implement registered items management in process work standard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added new endpoints for managing registered items, including retrieval, registration, and batch registration. - Enhanced the existing processWorkStandardController to support filtering and additional columns in item queries. - Updated the processWorkStandardRoutes to include routes for registered items management. - Introduced a new documentation file detailing the design and structure of the POP 작업진행 관리 system. These changes aim to improve the management of registered items within the process work standard, enhancing usability and functionality. Made-with: Cursor --- .../processWorkStandardController.ts | 235 +++++- .../src/routes/processWorkStandardRoutes.ts | 6 + docs/POP_작업진행_설계서.md | 620 +++++++++++++++ .../V2ItemRoutingConfigPanel.tsx | 707 +++++++----------- .../v2-item-routing/ItemRoutingComponent.tsx | 638 ++++++++-------- .../v2-item-routing/ItemRoutingRenderer.tsx | 3 +- .../components/v2-item-routing/config.ts | 11 + .../v2-item-routing/hooks/useItemRouting.ts | 235 ++++-- .../components/v2-item-routing/types.ts | 27 +- 9 files changed, 1684 insertions(+), 798 deletions(-) create mode 100644 docs/POP_작업진행_설계서.md 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; } // 데이터 모델