From fa9f5451f69e347852ac27720f8f70cb0f05ad2b Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 18 May 2026 16:13:29 +0900 Subject: [PATCH] Add Customer Contact Routes and Enhance User Management - Introduced new routes for customer contact management, allowing for the retrieval of customer contact information. - Updated the user management functionality to include validation for the hire date, ensuring proper date format and handling of null values. - Enhanced the save user functionality to accommodate the new hire date field, maintaining existing values when not provided. (TASK: ERP-XXX) --- backend-node/src/app.ts | 2 + .../src/controllers/adminController.ts | 27 +- .../controllers/customerContactController.ts | 50 ++++ .../controllers/itemInspectionController.ts | 4 +- .../controllers/popProductionController.ts | 2 + .../src/controllers/receivingController.ts | 20 +- .../src/controllers/shippingPlanController.ts | 128 ++++----- .../tableCategoryValueController.ts | 84 +++++- .../src/routes/customerContactRoutes.ts | 21 ++ .../src/routes/tableCategoryValueRoutes.ts | 4 + .../src/services/customerContactService.ts | 97 +++++++ .../src/services/tableCategoryValueService.ts | 151 ++++++++++- backend-node/src/types/tableCategoryValue.ts | 10 + .../COMPANY_10/master-data/item-info/page.tsx | 12 +- .../COMPANY_10/master-data/options/page.tsx | 21 +- .../purchase-order/ReleaseRequestModal.tsx | 2 +- .../COMPANY_16/master-data/item-info/page.tsx | 12 +- .../COMPANY_16/master-data/options/page.tsx | 21 +- .../purchase-order/ReleaseRequestModal.tsx | 2 +- .../logistics/inbound-outbound/page.tsx | 19 +- .../COMPANY_28/logistics/inventory/page.tsx | 26 +- .../master-data/department/page.tsx | 19 +- .../COMPANY_28/master-data/item-info/page.tsx | 16 +- .../purchase-order/ReleaseRequestModal.tsx | 2 +- .../COMPANY_28/production/result/page.tsx | 50 +++- .../(main)/COMPANY_28/purchase/order/page.tsx | 10 +- .../quality/item-inspection/page.tsx | 254 +++++++++++++++--- .../(main)/COMPANY_28/sales/order/page.tsx | 137 ++++++++-- .../COMPANY_29/master-data/item-info/page.tsx | 12 +- .../COMPANY_29/master-data/options/page.tsx | 21 +- .../purchase-order/ReleaseRequestModal.tsx | 2 +- .../COMPANY_31/master-data/item-info/page.tsx | 12 +- .../COMPANY_31/master-data/options/page.tsx | 21 +- .../purchase-order/ReleaseRequestModal.tsx | 2 +- .../COMPANY_7/master-data/item-info/page.tsx | 14 +- .../COMPANY_7/master-data/options/page.tsx | 21 +- .../purchase-order/ReleaseRequestModal.tsx | 2 +- .../COMPANY_8/master-data/item-info/page.tsx | 12 +- .../COMPANY_8/master-data/options/page.tsx | 21 +- .../purchase-order/ReleaseRequestModal.tsx | 2 +- .../quality/item-inspection/page.tsx | 101 ++++++- .../COMPANY_9/master-data/item-info/page.tsx | 12 +- .../COMPANY_9/master-data/options/page.tsx | 21 +- .../purchase-order/ReleaseRequestModal.tsx | 2 +- .../components/common/FullscreenDialog.tsx | 10 +- .../common/ShippingPlanBatchModal.tsx | 4 +- frontend/lib/api/itemInspection.ts | 2 + frontend/lib/api/tableCategoryValue.ts | 32 ++- .../components/DetailFormModal.tsx | 204 +++++++++++--- .../components/WorkItemDetailList.tsx | 23 +- .../v2-process-work-standard/types.ts | 1 + frontend/lib/utils/categoryFlatten.ts | 79 ++++++ 52 files changed, 1494 insertions(+), 342 deletions(-) create mode 100644 backend-node/src/controllers/customerContactController.ts create mode 100644 backend-node/src/routes/customerContactRoutes.ts create mode 100644 backend-node/src/services/customerContactService.ts create mode 100644 frontend/lib/utils/categoryFlatten.ts diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index f59c1a52..4b2c5d60 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -104,6 +104,7 @@ import reportRoutes from "./routes/reportRoutes"; import barcodeLabelRoutes from "./routes/barcodeLabelRoutes"; import openApiProxyRoutes from "./routes/openApiProxyRoutes"; // 날씨/환율 API import deliveryRoutes from "./routes/deliveryRoutes"; // 배송/화물 관리 +import customerContactRoutes from "./routes/customerContactRoutes"; // 거래처 담당자 조회 import riskAlertRoutes from "./routes/riskAlertRoutes"; // 리스크/알림 관리 import todoRoutes from "./routes/todoRoutes"; // To-Do 관리 import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리 @@ -349,6 +350,7 @@ app.use("/api/admin/reports", reportRoutes); app.use("/api/admin/barcode-labels", barcodeLabelRoutes); app.use("/api/open-api", openApiProxyRoutes); // 날씨/환율 외부 API app.use("/api/delivery", deliveryRoutes); // 배송/화물 관리 +app.use("/api/customer-contacts", customerContactRoutes); // 거래처 담당자 조회 app.use("/api/risk-alerts", riskAlertRoutes); // 리스크/알림 관리 app.use("/api/todos", todoRoutes); // To-Do 관리 app.use("/api/bookings", bookingRoutes); // 예약 요청 관리 diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index b568038e..6714e668 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -4122,6 +4122,7 @@ interface UserWithDeptRequest { position_code?: string; position_name?: string; end_date?: string | null; + hire_date?: string | null; // 입사일 (등록시각 regdate 와 분리) }; mainDept?: { dept_code: string; @@ -4169,6 +4170,24 @@ export const saveUserWithDept = async ( return; } + // 입사일(hire_date) 형식 검증 — 값이 들어온 경우에만 YYYY-MM-DD 유효 날짜인지 확인 + let hireDate: string | null | undefined = undefined; + if (userInfo.hire_date !== undefined && userInfo.hire_date !== null && userInfo.hire_date !== "") { + const raw = String(userInfo.hire_date).substring(0, 10); + const d = new Date(`${raw}T00:00:00+09:00`); + if (!/^\d{4}-\d{2}-\d{2}$/.test(raw) || isNaN(d.getTime())) { + res.status(400).json({ + success: false, + message: "입사일 형식이 올바르지 않습니다.", + error: { code: "INVALID_HIRE_DATE" }, + }); + return; + } + hireDate = raw; + } else if (userInfo.hire_date === null || userInfo.hire_date === "") { + hireDate = null; // 명시적으로 비운 경우 + } + // 트랜잭션 시작 await client.query("BEGIN"); @@ -4234,6 +4253,8 @@ export const saveUserWithDept = async ( position_code: userInfo.position_code, position_name: positionName, end_date: userInfo.end_date !== undefined ? (userInfo.end_date ? `${userInfo.end_date.substring(0, 10)}T00:00:00+09:00` : null) : undefined, + // 입사일: 페이로드에 hire_date 키가 온 경우에만 갱신(누락 시 기존값 유지) + hire_date: userInfo.hire_date !== undefined ? hireDate ?? null : undefined, company_code: companyCode !== "*" ? companyCode : undefined, }; @@ -4265,8 +4286,8 @@ export const saveUserWithDept = async ( email, tel, cell_phone, sabun, user_type, user_type_name, status, locale, dept_code, dept_name, position_code, position_name, - company_code, end_date, regdate - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, NOW())`, + company_code, end_date, hire_date, regdate + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, COALESCE($19::date, CURRENT_DATE), NOW())`, [ userInfo.user_id, userInfo.user_name, @@ -4286,6 +4307,8 @@ export const saveUserWithDept = async ( positionName, companyCode !== "*" ? companyCode : null, userInfo.end_date ? `${userInfo.end_date.substring(0, 10)}T00:00:00+09:00` : null, + // 입사일: 값 있으면 그 날짜, 없으면 COALESCE 로 CURRENT_DATE(오늘) 기본값 + hireDate ?? null, ] ); } diff --git a/backend-node/src/controllers/customerContactController.ts b/backend-node/src/controllers/customerContactController.ts new file mode 100644 index 00000000..cadb029a --- /dev/null +++ b/backend-node/src/controllers/customerContactController.ts @@ -0,0 +1,50 @@ +/** + * 거래처 담당자(customer_contact) 조회 컨트롤러 + * + * 수주관리 등에서 거래처 선택 시 담당자 리스트/메인담당자 조회용. + * 조회 전용 (DB 스키마 변경 없음). + */ + +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import * as customerContactService from "../services/customerContactService"; + +/** + * GET /api/customer-contacts/:customerId + * 거래처별 담당자 리스트 조회 + * - :customerId 는 customer_mng.customer_code 또는 customer_mng.id + * - company_code 는 인증 토큰에서 추출 (멀티테넌트 격리) + */ +export async function getContactsByCustomer( + req: AuthenticatedRequest, + res: Response, +): Promise { + try { + const companyCode = req.user!.companyCode; + const { customerId } = req.params; + + if (!customerId) { + res.status(400).json({ + success: false, + message: "거래처 식별자가 필요합니다.", + }); + return; + } + + const contacts = await customerContactService.getContactsByCustomer( + companyCode, + customerId, + ); + + res.json({ + success: true, + data: contacts, + }); + } catch (error) { + console.error("거래처 담당자 조회 실패:", error); + res.status(500).json({ + success: false, + message: "거래처 담당자 조회에 실패했습니다.", + }); + } +} diff --git a/backend-node/src/controllers/itemInspectionController.ts b/backend-node/src/controllers/itemInspectionController.ts index d85b46bc..0f8bb734 100644 --- a/backend-node/src/controllers/itemInspectionController.ts +++ b/backend-node/src/controllers/itemInspectionController.ts @@ -20,6 +20,8 @@ interface InspectionRow { apply_process: string | null; classification: string | null; pass_criteria: string | null; + upper_limit: string | null; + lower_limit: string | null; is_required: string | null; is_active: string | null; manager_id: string | null; @@ -98,7 +100,7 @@ export async function getGroupedList(req: AuthenticatedRequest, res: Response) { SELECT id, item_code, item_name, inspection_type, inspection_standard, inspection_standard_id, inspection_item_name, inspection_method, apply_process, classification, - pass_criteria, is_required, is_active, manager_id, memo, + pass_criteria, upper_limit, lower_limit, is_required, is_active, manager_id, memo, sort_order, change_record, created_date, updated_date FROM item_inspection_info WHERE company_code = $1 diff --git a/backend-node/src/controllers/popProductionController.ts b/backend-node/src/controllers/popProductionController.ts index 13b0c722..f8c4140d 100644 --- a/backend-node/src/controllers/popProductionController.ts +++ b/backend-node/src/controllers/popProductionController.ts @@ -3644,6 +3644,8 @@ accepted_results AS ( 'started_at', started_at, 'completed_at', completed_at, 'equipment_code', equipment_code, + 'defect_detail', defect_detail, + 'result_note', result_note, 'batch_id', batch_id ) ORDER BY seq ) AS accepted_results diff --git a/backend-node/src/controllers/receivingController.ts b/backend-node/src/controllers/receivingController.ts index c7592305..8207714a 100644 --- a/backend-node/src/controllers/receivingController.ts +++ b/backend-node/src/controllers/receivingController.ts @@ -167,6 +167,20 @@ export async function create(req: AuthenticatedRequest, res: Response) { await client.query("BEGIN"); + // 담당자(처리자) 한글명 조회 — inventory_history에 manager_id/manager_name 동반 기록용 + // (CLAUDE.md "사용자 식별 표시 필수": DB에는 user_id 저장, 표시는 user_name) + let managerName = userId; + try { + const mgrRes = await client.query( + `SELECT COALESCE(NULLIF(user_name, ''), user_id) AS user_name + FROM user_info WHERE user_id = $1 AND company_code = $2 LIMIT 1`, + [userId, companyCode], + ); + if (mgrRes.rows[0]?.user_name) managerName = mgrRes.rows[0].user_name; + } catch { + /* user_info 조회 실패 시 userId fallback 유지 */ + } + inboundType = await resolveCategoryCode(client, "inbound_mng", "inbound_type", inboundType); // 1. 헤더 — 같은 (company_code, inbound_number) 헤더가 있으면 reuse, 없으면 INSERT (멱등성) @@ -329,8 +343,8 @@ export async function create(req: AuthenticatedRequest, res: Response) { `INSERT INTO inventory_history ( id, company_code, item_code, warehouse_code, location_code, transaction_type, transaction_date, quantity, balance_qty, remark, - writer, created_date - ) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '입고', NOW(), $5, $6, $7, $8, NOW())`, + writer, manager_id, manager_name, created_date + ) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '입고', NOW(), $5, $6, $7, $8, $9, $10, NOW())`, [ companyCode, itemCode, @@ -340,6 +354,8 @@ export async function create(req: AuthenticatedRequest, res: Response) { afterQty, resolvedItemInboundType || "입고", userId, + userId, + managerName, ], ); } diff --git a/backend-node/src/controllers/shippingPlanController.ts b/backend-node/src/controllers/shippingPlanController.ts index 3af32055..31f6c3e8 100644 --- a/backend-node/src/controllers/shippingPlanController.ts +++ b/backend-node/src/controllers/shippingPlanController.ts @@ -121,8 +121,9 @@ async function getNormalizedOrders( dueDate: r.due_date || "", orderQty: Number(r.order_qty || 0), shipQty: Number(r.ship_qty || 0), - // balance_qty가 NULL/0이면 orderQty - shipQty fallback (수주 등록 시 채워지지 않은 데이터 보정) - balanceQty: Number(r.balance_qty) || (Number(r.order_qty || 0) - Number(r.ship_qty || 0)), + // 잔량은 항상 (수주량 - 출하수량) 파생값으로 산출. + // 저장된 balance_qty는 비정규화 필드라 오염될 수 있어 집계에 신뢰하지 않는다. + balanceQty: Math.max(0, Number(r.order_qty || 0) - Number(r.ship_qty || 0)), })); } else { // 마스터 기준 → 거래처 JOIN @@ -166,8 +167,9 @@ async function getNormalizedOrders( dueDate: r.due_date || "", orderQty: Number(r.order_qty || 0), shipQty: Number(r.ship_qty || 0), - // balance_qty가 NULL/0이면 orderQty - shipQty fallback (수주 등록 시 채워지지 않은 데이터 보정) - balanceQty: Number(r.balance_qty) || (Number(r.order_qty || 0) - Number(r.ship_qty || 0)), + // 잔량은 항상 (수주량 - 출하수량) 파생값으로 산출. + // 저장된 balance_qty는 비정규화 필드라 오염될 수 있어 집계에 신뢰하지 않는다. + balanceQty: Math.max(0, Number(r.order_qty || 0) - Number(r.ship_qty || 0)), })); } } @@ -411,38 +413,9 @@ export async function updatePlan(req: AuthenticatedRequest, res: Response) { finalParams ); - // plan_qty가 변경되었고 마스터(sales_order_mng) 기반 출하계획이면 master 재계산 + 자동 전이 - if (qtyDelta !== 0 && masterId) { - // sales_order_mng의 ship_qty/balance_qty를 delta만큼 보정 - await client.query( - `UPDATE sales_order_mng - SET ship_qty = COALESCE(ship_qty, 0) + $1, - balance_qty = COALESCE(order_qty, 0) - (COALESCE(ship_qty, 0) + $1), - updated_date = NOW() - WHERE id = $2 AND company_code = $3`, - [qtyDelta, masterId, companyCode] - ); - - // 자동 상태 전이 (TASK:ERP-047) — 양방향 - // balance_qty <= 0 AND status='CONFIRMED' → 'COMPLETED' - await client.query( - `UPDATE sales_order_mng - SET status = 'COMPLETED', updated_date = NOW() - WHERE id = $1 AND company_code = $2 - AND status = 'CONFIRMED' - AND COALESCE(balance_qty, 0) <= 0`, - [masterId, companyCode] - ); - // balance_qty > 0 AND status='COMPLETED' → 'CONFIRMED' - await client.query( - `UPDATE sales_order_mng - SET status = 'CONFIRMED', updated_date = NOW() - WHERE id = $1 AND company_code = $2 - AND status = 'COMPLETED' - AND COALESCE(balance_qty, 0) > 0`, - [masterId, companyCode] - ); - } + // TASK:ERP-053-A — 출하계획 plan_qty 수정 시 sales_order_mng.ship_qty/balance_qty 가산 보정 제거. + // ship_qty는 "실제 출고 누적"으로 단일화되었으므로 계획수량 변경은 ship_qty에 영향 주지 않는다. + // 계획수량 변경은 shipment_plan.plan_qty 에만 반영. 수주 상태 전이는 실제 출고 시점에서 처리. await client.query("COMMIT"); @@ -507,9 +480,9 @@ export async function getAggregate(req: AuthenticatedRequest, res: Response) { const result: Record = {}; for (const [partCode, partOrders] of partCodeMap) { - // 총수주잔량: 선택된 수주들의 balance_qty 합 + // 총수주잔량: 선택된 수주들의 (수주량 - 출하수량) 합 (파생값 일관 사용) const totalBalance = partOrders.reduce( - (s, o) => s + (o.balanceQty > 0 ? o.balanceQty : o.orderQty - o.shipQty), + (s, o) => s + Math.max(0, o.orderQty - o.shipQty), 0 ); @@ -723,7 +696,7 @@ export async function batchSave(req: AuthenticatedRequest, res: Response) { // 디테일 소스: detail_id로 저장 const detailCheck = await client.query( `SELECT d.id, d.order_no, d.part_code, d.qty, d.ship_qty, d.balance_qty, - m.id AS master_id + m.id AS master_id, m.order_qty AS m_order_qty FROM sales_order_detail d LEFT JOIN sales_order_mng m ON d.order_no = m.order_no AND d.company_code = m.company_code @@ -736,13 +709,25 @@ export async function batchSave(req: AuthenticatedRequest, res: Response) { } const detail = detailCheck.rows[0]; - const qty = Number(detail.qty || 0); - const shipQty = Number(detail.ship_qty || 0); - const balanceQty = detail.balance_qty - ? Number(detail.balance_qty) - : qty - shipQty; + // 수주량: detail.qty 우선, 없으면 마스터 order_qty 폴백 + // (getNormalizedOrders와 동일 규칙 — '0'/공란 함정 제거) + const baseQty = Number(detail.qty) || Number(detail.m_order_qty) || 0; - if (balanceQty > 0 && planQty > balanceQty) { + // TASK:ERP-053-A — ship_qty는 "실제 출고 누적"으로 단일화. + // 출하계획 생성 단계에서는 ship_qty/balance_qty를 가산하지 않는다. + // (실제 출고는 outboundController.ts:288 에서만 ship_qty 반영) + // 과다 출하계획 가드: 미출하량 = 수주량 - 실제출고(ship_qty) - 기존 계획수량 합. + const prevShip = Number(detail.ship_qty) || 0; + const planSumRes = await client.query( + `SELECT COALESCE(SUM(plan_qty), 0) AS plan_sum + FROM shipment_plan + WHERE detail_id = $1 AND company_code = $2`, + [sourceId, companyCode] + ); + const existingPlanSum = Number(planSumRes.rows[0]?.plan_sum || 0); + const balanceQty = baseQty - prevShip - existingPlanSum; // 이번 계획 전 잔여 가능량 + + if (baseQty > 0 && planQty > balanceQty) { throw new Error( `수주번호 ${detail.order_no}: 출하계획량(${planQty})이 미출하량(${balanceQty})을 초과합니다` ); @@ -758,16 +743,8 @@ export async function batchSave(req: AuthenticatedRequest, res: Response) { ); savedPlans.push(insertRes.rows[0]); - // detail ship_qty 업데이트 - await client.query( - `UPDATE sales_order_detail - SET ship_qty = (COALESCE(NULLIF(ship_qty,'')::numeric, 0) + $1)::text, - balance_qty = (COALESCE(NULLIF(qty,'')::numeric, 0) - - COALESCE(NULLIF(ship_qty,'')::numeric, 0) - $1)::text, - updated_date = NOW() - WHERE id = $2 AND company_code = $3`, - [planQty, sourceId, companyCode] - ); + // TASK:ERP-053-A — 출하계획 생성 시 sales_order_detail.ship_qty 가산 제거. + // 계획수량은 shipment_plan.plan_qty 로만 관리. ship_qty는 실제 출고에서만 반영. } else { // 마스터 소스: sales_order_id로 저장 const masterId = Number(sourceId); @@ -783,9 +760,21 @@ export async function batchSave(req: AuthenticatedRequest, res: Response) { } const master = masterCheck.rows[0]; - const balanceQty = Number(master.balance_qty || 0); + const orderQty = Number(master.order_qty || 0); - if (balanceQty > 0 && planQty > balanceQty) { + // TASK:ERP-053-A — ship_qty는 "실제 출고 누적"으로 단일화. + // 과다 출하계획 가드: 미출하량 = 수주량 - 실제출고(ship_qty) - 기존 계획수량 합. + const prevShip = Number(master.ship_qty || 0); + const planSumRes = await client.query( + `SELECT COALESCE(SUM(plan_qty), 0) AS plan_sum + FROM shipment_plan + WHERE sales_order_id = $1 AND company_code = $2`, + [masterId, companyCode] + ); + const existingPlanSum = Number(planSumRes.rows[0]?.plan_sum || 0); + const balanceQty = orderQty - prevShip - existingPlanSum; + + if (orderQty > 0 && planQty > balanceQty) { throw new Error( `수주번호 ${master.order_no}: 출하계획량(${planQty})이 미출하량(${balanceQty})을 초과합니다` ); @@ -801,27 +790,10 @@ export async function batchSave(req: AuthenticatedRequest, res: Response) { ); savedPlans.push(insertRes.rows[0]); - // 마스터 ship_qty 업데이트 - await client.query( - `UPDATE sales_order_mng - SET ship_qty = COALESCE(ship_qty, 0) + $1, - balance_qty = COALESCE(order_qty, 0) - COALESCE(ship_qty, 0) - $1, - updated_date = NOW() - WHERE id = $2 AND company_code = $3`, - [planQty, masterId, companyCode] - ); - - // 자동 상태 전이 (TASK:ERP-047) - // balance_qty <= 0 AND status='CONFIRMED' → 'COMPLETED' - // 같은 트랜잭션 내에서 처리 - await client.query( - `UPDATE sales_order_mng - SET status = 'COMPLETED', updated_date = NOW() - WHERE id = $1 AND company_code = $2 - AND status = 'CONFIRMED' - AND COALESCE(balance_qty, 0) <= 0`, - [masterId, companyCode] - ); + // TASK:ERP-053-A — 출하계획 생성 시 sales_order_mng.ship_qty/balance_qty 가산 제거. + // 계획수량은 shipment_plan.plan_qty 로만 관리. ship_qty는 실제 출고에서만 반영. + // 자동 상태 전이(TASK:ERP-047 COMPLETED)는 실제 출고 시점(outboundController)에서 처리되어야 하므로 + // 출하계획 생성 단계의 stale balance_qty 기반 전이는 제거(중복 가산 해소 후 무의미). } } diff --git a/backend-node/src/controllers/tableCategoryValueController.ts b/backend-node/src/controllers/tableCategoryValueController.ts index 508b3159..09c62d94 100644 --- a/backend-node/src/controllers/tableCategoryValueController.ts +++ b/backend-node/src/controllers/tableCategoryValueController.ts @@ -82,18 +82,26 @@ export const getCategoryValues = async (req: AuthenticatedRequest, res: Response filterCompanyCode, }); - const values = await tableCategoryValueService.getCategoryValues( - tableName, - columnName, - effectiveCompanyCode, - includeInactive, - menuObjid, - topLevelOnly - ); + const [values, useHierarchy] = await Promise.all([ + tableCategoryValueService.getCategoryValues( + tableName, + columnName, + effectiveCompanyCode, + includeInactive, + menuObjid, + topLevelOnly + ), + tableCategoryValueService.getUseHierarchy( + tableName, + columnName, + effectiveCompanyCode + ), + ]); return res.json({ success: true, data: values, + useHierarchy, }); } catch (error: any) { logger.error(`카테고리 값 조회 실패: ${error.message}`); @@ -105,6 +113,66 @@ export const getCategoryValues = async (req: AuthenticatedRequest, res: Response } }; +/** + * 카테고리 컬럼 use_hierarchy 플래그 업데이트 + * + * PUT /api/table-categories/:tableName/:columnName/hierarchy-flag + * + * Body: { useHierarchy: boolean } + */ +export const updateUseHierarchy = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { tableName, columnName } = req.params; + const { useHierarchy } = req.body; + + if (typeof useHierarchy !== "boolean") { + return res.status(400).json({ + success: false, + message: "useHierarchy는 boolean 값이어야 합니다", + }); + } + + if (companyCode === "*") { + return res.status(400).json({ + success: false, + message: "최고관리자(*) 계정에서는 use_hierarchy 설정을 변경할 수 없습니다", + }); + } + + logger.info("use_hierarchy 업데이트 요청", { + tableName, + columnName, + companyCode, + useHierarchy, + }); + + const updated = await tableCategoryValueService.updateUseHierarchy( + tableName, + columnName, + companyCode, + useHierarchy, + userId + ); + + return res.json({ + success: true, + data: { useHierarchy: updated }, + }); + } catch (error: any) { + logger.error(`use_hierarchy 업데이트 실패: ${error.message}`); + return res.status(500).json({ + success: false, + message: error.message || "use_hierarchy 업데이트 중 오류가 발생했습니다", + error: error.message, + }); + } +}; + /** * 카테고리 값 추가 (메뉴 스코프) * diff --git a/backend-node/src/routes/customerContactRoutes.ts b/backend-node/src/routes/customerContactRoutes.ts new file mode 100644 index 00000000..bbdd3627 --- /dev/null +++ b/backend-node/src/routes/customerContactRoutes.ts @@ -0,0 +1,21 @@ +/** + * 거래처 담당자(customer_contact) 조회 라우트 + * + * 수주관리 등에서 거래처 선택 시 담당자 리스트/메인담당자 조회용 경량 API. + * 조회 전용 (DB 스키마 변경 없음). + */ + +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import * as customerContactController from "../controllers/customerContactController"; + +const router = Router(); +router.use(authenticateToken); + +/** + * GET /api/customer-contacts/:customerId + * 거래처별 담당자 리스트 조회 (customerId = customer_code 또는 id) + */ +router.get("/:customerId", customerContactController.getContactsByCustomer); + +export default router; diff --git a/backend-node/src/routes/tableCategoryValueRoutes.ts b/backend-node/src/routes/tableCategoryValueRoutes.ts index b905a3f2..85c8c5d8 100644 --- a/backend-node/src/routes/tableCategoryValueRoutes.ts +++ b/backend-node/src/routes/tableCategoryValueRoutes.ts @@ -15,6 +15,7 @@ import { deleteColumnMappingsByColumn, getSecondLevelMenus, getCategoryLabelsByCodes, + updateUseHierarchy, } from "../controllers/tableCategoryValueController"; import { authenticateToken } from "../middleware/authMiddleware"; @@ -33,6 +34,9 @@ router.get("/:tableName/columns", getCategoryColumns); // 카테고리 값 목록 조회 router.get("/:tableName/:columnName/values", getCategoryValues); +// 카테고리 컬럼 use_hierarchy 플래그 업데이트 +router.put("/:tableName/:columnName/hierarchy-flag", updateUseHierarchy); + // 카테고리 값 추가 router.post("/values", addCategoryValue); diff --git a/backend-node/src/services/customerContactService.ts b/backend-node/src/services/customerContactService.ts new file mode 100644 index 00000000..c44d80b4 --- /dev/null +++ b/backend-node/src/services/customerContactService.ts @@ -0,0 +1,97 @@ +/** + * 거래처 담당자(customer_contact) 조회 서비스 + * + * 수주관리 등에서 거래처 선택 시 해당 거래처의 담당자 리스트와 + * 메인담당자(is_main='Y')를 조회하기 위한 경량 조회 전용 서비스. + * - DB 스키마 변경 없음 (조회 전용) + * - 멀티테넌트 격리: company_code 필터 필수 + * + * 거래처 식별자 매핑: + * - customer_contact.customer_id = customer_mng.id (숫자 PK, text 비교) + * - 프론트(수주관리)의 partner_id 값은 customer_mng.customer_code 이므로 + * customer_code 로도 조회 가능하도록 customer_mng JOIN 으로 해석 + */ + +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; + +export interface CustomerContact { + id: string; + customer_id: string | null; + contact_name: string | null; + contact_phone: string | null; + contact_email: string | null; + department: string | null; + is_main: boolean; + memo: string | null; +} + +/** + * 거래처별 담당자 리스트 조회 + * @param companyCode 로그인 사용자 회사코드 (멀티테넌트 격리) + * @param customerRef 거래처 식별자 — customer_mng.customer_code 또는 customer_mng.id + */ +export async function getContactsByCustomer( + companyCode: string, + customerRef: string, +): Promise { + const pool = getPool(); + + // customer_code(프론트 partner_id) 또는 숫자 id 둘 다 매칭. + // customer_contact.customer_id 는 customer_mng.id(숫자)를 text로 저장. + const conditions: string[] = []; + const params: any[] = []; + let idx = 1; + + if (companyCode && companyCode !== "*") { + conditions.push(`cc.company_code = $${idx}`); + params.push(companyCode); + idx++; + } + + // customerRef 를 customer_mng 에서 해석한 id 목록 또는 직접 일치하는 customer_id + conditions.push(`( + cc.customer_id = $${idx} + OR cc.customer_id IN ( + SELECT cm.id::text FROM customer_mng cm + WHERE (cm.customer_code = $${idx} OR cm.id::text = $${idx}) + ${companyCode && companyCode !== "*" ? `AND cm.company_code = $1` : ""} + ) + )`); + params.push(customerRef); + idx++; + + const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + + const query = ` + SELECT cc.id, + cc.customer_id, + cc.contact_name, + cc.contact_phone, + cc.contact_email, + cc.department, + cc.is_main, + cc.memo + FROM customer_contact cc + ${where} + ORDER BY (cc.is_main = 'Y') DESC, cc.contact_name ASC + `; + + const result = await pool.query(query, params); + logger.info("거래처 담당자 조회", { + companyCode, + customerRef, + count: result.rowCount, + }); + + return result.rows.map((r: any) => ({ + id: r.id, + customer_id: r.customer_id, + contact_name: r.contact_name, + contact_phone: r.contact_phone, + contact_email: r.contact_email, + department: r.department, + is_main: r.is_main === "Y" || r.is_main === true, + memo: r.memo, + })); +} diff --git a/backend-node/src/services/tableCategoryValueService.ts b/backend-node/src/services/tableCategoryValueService.ts index 712bf646..865e3fe1 100644 --- a/backend-node/src/services/tableCategoryValueService.ts +++ b/backend-node/src/services/tableCategoryValueService.ts @@ -262,9 +262,158 @@ class TableCategoryValueService { } } + /** + * 카테고리 컬럼의 use_hierarchy(트리 사용 여부) 플래그 조회 + * + * - 정책 행이 있으면 boolean 반환 + * - 없으면 null 반환 (클라이언트가 자동 감지 fallback 사용) + */ + async getUseHierarchy( + tableName: string, + columnName: string, + companyCode: string + ): Promise { + try { + const pool = getPool(); + + let query: string; + let params: any[]; + + if (companyCode === "*") { + query = ` + SELECT use_hierarchy + FROM category_column_mapping + WHERE table_name = $1 + AND logical_column_name = $2 + ORDER BY (physical_column_name = logical_column_name) DESC + LIMIT 1 + `; + params = [tableName, columnName]; + } else { + query = ` + SELECT use_hierarchy + FROM category_column_mapping + WHERE table_name = $1 + AND logical_column_name = $2 + AND company_code = $3 + ORDER BY (physical_column_name = logical_column_name) DESC + LIMIT 1 + `; + params = [tableName, columnName, companyCode]; + } + + const result = await pool.query(query, params); + if (result.rows.length === 0) { + return null; + } + return Boolean(result.rows[0].use_hierarchy); + } catch (error: any) { + logger.error(`use_hierarchy 조회 실패: ${error.message}`); + return null; + } + } + + /** + * 카테고리 컬럼의 use_hierarchy 플래그 업서트 + * + * - 동일 (table_name, logical_column_name, company_code) 정책 행이 있으면 update + * - 없으면 신규 정책 행 insert (physical_column_name = logical_column_name) + */ + async updateUseHierarchy( + tableName: string, + columnName: string, + companyCode: string, + useHierarchy: boolean, + userId: string + ): Promise { + const pool = getPool(); + + try { + logger.info("use_hierarchy 업데이트", { + tableName, + columnName, + companyCode, + useHierarchy, + }); + + // 최고관리자(*)는 정책 저장 대상이 모호하므로 차단 + if (companyCode === "*") { + throw new Error("최고관리자(*) 계정에서는 use_hierarchy 설정을 변경할 수 없습니다"); + } + + // 1) 먼저 update 시도 + const updateQuery = ` + UPDATE category_column_mapping + SET use_hierarchy = $4, + updated_at = NOW(), + updated_by = $5 + WHERE table_name = $1 + AND logical_column_name = $2 + AND company_code = $3 + `; + const updateResult = await pool.query(updateQuery, [ + tableName, + columnName, + companyCode, + useHierarchy, + userId, + ]); + + if ((updateResult.rowCount || 0) > 0) { + logger.info("use_hierarchy update 완료", { + tableName, + columnName, + companyCode, + rowsUpdated: updateResult.rowCount, + }); + return useHierarchy; + } + + // 2) update 대상이 없으면 정책 행 신규 insert + // mapping_id는 SEQUENCE가 없는 환경 대비 MAX+1 + const insertQuery = ` + INSERT INTO category_column_mapping ( + mapping_id, + table_name, + logical_column_name, + physical_column_name, + company_code, + description, + use_hierarchy, + created_by, + updated_by, + created_at, + updated_at + ) VALUES ( + (SELECT COALESCE(MAX(mapping_id), 0) + 1 FROM category_column_mapping), + $1, $2, $2, $3, 'use_hierarchy 정책 행', $4, $5, $5, NOW(), NOW() + ) + ON CONFLICT DO NOTHING + `; + await pool.query(insertQuery, [ + tableName, + columnName, + companyCode, + useHierarchy, + userId, + ]); + + logger.info("use_hierarchy insert 완료", { + tableName, + columnName, + companyCode, + useHierarchy, + }); + return useHierarchy; + } catch (error: any) { + logger.error(`use_hierarchy 업데이트 실패: ${error.message}`); + throw error; + } + } + /** * 카테고리 값 추가 (메뉴 스코프) - * + * * @param value 카테고리 값 정보 * @param companyCode 회사 코드 * @param userId 생성자 ID diff --git a/backend-node/src/types/tableCategoryValue.ts b/backend-node/src/types/tableCategoryValue.ts index 6f0055e7..c5ecf82a 100644 --- a/backend-node/src/types/tableCategoryValue.ts +++ b/backend-node/src/types/tableCategoryValue.ts @@ -43,3 +43,13 @@ export interface CategoryColumn { valueCount?: number; // 값 개수 } +/** + * 카테고리 값 조회 응답 wrapper + * - values: 계층 구조 변환된 카테고리 값 트리 + * - useHierarchy: category_column_mapping에 저장된 트리 사용 여부 (null이면 미설정 → 클라이언트 fallback 자동 감지) + */ +export interface CategoryValuesResponse { + values: TableCategoryValue[]; + useHierarchy: boolean | null; +} + diff --git a/frontend/app/(main)/COMPANY_10/master-data/item-info/page.tsx b/frontend/app/(main)/COMPANY_10/master-data/item-info/page.tsx index 68552dc6..d6f30d4f 100644 --- a/frontend/app/(main)/COMPANY_10/master-data/item-info/page.tsx +++ b/frontend/app/(main)/COMPANY_10/master-data/item-info/page.tsx @@ -42,6 +42,7 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; +import { pickCategoryOptions } from "@/lib/utils/categoryFlatten"; import { useAuth } from "@/hooks/useAuth"; import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; @@ -331,21 +332,14 @@ export default function ItemInfoPage() { const loadCategories = async () => { try { const optMap: Record = {}; - const flatten = (vals: any[]): { code: string; label: string }[] => { - const result: { code: string; label: string }[] = []; - for (const v of vals) { - result.push({ code: v.valueCode, label: v.valueLabel }); - if (v.children?.length) result.push(...flatten(v.children)); - } - return result; - }; await Promise.all( CATEGORY_COLUMNS.map(async (colName) => { try { const res = await apiClient.get(`/table-categories/${TABLE_NAME}/${colName}/values`); if (res.data?.success && res.data.data?.length > 0) { - optMap[colName] = flatten(res.data.data); + const useHierarchy = res.data?.useHierarchy; + optMap[colName] = pickCategoryOptions(res.data.data, useHierarchy); } } catch { /* skip */ } }) diff --git a/frontend/app/(main)/COMPANY_10/master-data/options/page.tsx b/frontend/app/(main)/COMPANY_10/master-data/options/page.tsx index 6053f27f..71e535c7 100644 --- a/frontend/app/(main)/COMPANY_10/master-data/options/page.tsx +++ b/frontend/app/(main)/COMPANY_10/master-data/options/page.tsx @@ -10,7 +10,8 @@ import { NumberingRuleDesigner } from "@/components/numbering-rule/NumberingRule import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; import { useConfirmDialog } from "@/components/common/ConfirmDialog"; -import { getCategoryValues } from "@/lib/api/tableCategoryValue"; +import { getCategoryValues, updateUseHierarchy } from "@/lib/api/tableCategoryValue"; +import { toast } from "sonner"; const TABS = [ { id: "category", label: "카테고리 설정", icon: Tags }, @@ -85,7 +86,9 @@ export default function OptionsSettingPage() { ) : false; setHasChildRows(hasChild); - setUseHierarchy(hasChild); + // 1순위: 백엔드 정책 값 / 2순위: 자식 행 존재 여부로 자동 fallback + const serverFlag = (res as any)?.useHierarchy; + setUseHierarchy(typeof serverFlag === "boolean" ? serverFlag : hasChild); setDetectingHierarchy(false); })(); return () => { @@ -95,6 +98,7 @@ export default function OptionsSettingPage() { const handleToggleHierarchy = useCallback( async (checked: boolean) => { + if (!selectedColumn || !selectedTableName) return; if (!checked && hasChildRows) { const ok = await confirm( "이미 등록된 하위분류(중/소분류)가 있습니다.\n하위분류 사용을 해제해도 기존 데이터는 삭제되지 않으며, 다시 사용 설정 시 그대로 복원됩니다.\n계속하시겠습니까?", @@ -102,9 +106,18 @@ export default function OptionsSettingPage() { ); if (!ok) return; } - setUseHierarchy(checked); + const columnNameOnly = selectedColumn.includes(".") + ? selectedColumn.split(".").pop()! + : selectedColumn; + const prev = useHierarchy; + setUseHierarchy(checked); // optimistic update + const res = await updateUseHierarchy(selectedTableName, columnNameOnly, checked); + if (!res?.success) { + setUseHierarchy(prev); // 롤백 + toast.error((res as any)?.error || "하위분류 사용 설정 저장에 실패했습니다."); + } }, - [hasChildRows, confirm], + [hasChildRows, confirm, selectedColumn, selectedTableName, useHierarchy], ); const columnNameOnly = selectedColumn diff --git a/frontend/app/(main)/COMPANY_10/outsourcing/purchase-order/ReleaseRequestModal.tsx b/frontend/app/(main)/COMPANY_10/outsourcing/purchase-order/ReleaseRequestModal.tsx index 51fe593b..8dff7a5a 100644 --- a/frontend/app/(main)/COMPANY_10/outsourcing/purchase-order/ReleaseRequestModal.tsx +++ b/frontend/app/(main)/COMPANY_10/outsourcing/purchase-order/ReleaseRequestModal.tsx @@ -161,7 +161,7 @@ export function ReleaseRequestModal({ open, onOpenChange, opoIds, onSubmitted }: 선택한 외주발주의 사급자재(대기 상태)를 외주사별로 자동 그룹핑하여 출고요청합니다. 외주사 1곳당 출고전표 1건이 - 사급출고 + 사급출고 유형으로 출고관리에 등록됩니다. diff --git a/frontend/app/(main)/COMPANY_16/master-data/item-info/page.tsx b/frontend/app/(main)/COMPANY_16/master-data/item-info/page.tsx index 68552dc6..d6f30d4f 100644 --- a/frontend/app/(main)/COMPANY_16/master-data/item-info/page.tsx +++ b/frontend/app/(main)/COMPANY_16/master-data/item-info/page.tsx @@ -42,6 +42,7 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; +import { pickCategoryOptions } from "@/lib/utils/categoryFlatten"; import { useAuth } from "@/hooks/useAuth"; import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; @@ -331,21 +332,14 @@ export default function ItemInfoPage() { const loadCategories = async () => { try { const optMap: Record = {}; - const flatten = (vals: any[]): { code: string; label: string }[] => { - const result: { code: string; label: string }[] = []; - for (const v of vals) { - result.push({ code: v.valueCode, label: v.valueLabel }); - if (v.children?.length) result.push(...flatten(v.children)); - } - return result; - }; await Promise.all( CATEGORY_COLUMNS.map(async (colName) => { try { const res = await apiClient.get(`/table-categories/${TABLE_NAME}/${colName}/values`); if (res.data?.success && res.data.data?.length > 0) { - optMap[colName] = flatten(res.data.data); + const useHierarchy = res.data?.useHierarchy; + optMap[colName] = pickCategoryOptions(res.data.data, useHierarchy); } } catch { /* skip */ } }) diff --git a/frontend/app/(main)/COMPANY_16/master-data/options/page.tsx b/frontend/app/(main)/COMPANY_16/master-data/options/page.tsx index e4ab14fc..960e35f8 100644 --- a/frontend/app/(main)/COMPANY_16/master-data/options/page.tsx +++ b/frontend/app/(main)/COMPANY_16/master-data/options/page.tsx @@ -10,7 +10,8 @@ import { NumberingRuleDesigner } from "@/components/numbering-rule/NumberingRule import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; import { useConfirmDialog } from "@/components/common/ConfirmDialog"; -import { getCategoryValues } from "@/lib/api/tableCategoryValue"; +import { getCategoryValues, updateUseHierarchy } from "@/lib/api/tableCategoryValue"; +import { toast } from "sonner"; const TABS = [ { id: "category", label: "카테고리 설정", icon: Tags }, @@ -87,7 +88,9 @@ export default function OptionsSettingPage() { ) : false; setHasChildRows(hasChild); - setUseHierarchy(hasChild); + // 1순위: 백엔드 정책 값 / 2순위: 자식 행 존재 여부로 자동 fallback + const serverFlag = (res as any)?.useHierarchy; + setUseHierarchy(typeof serverFlag === "boolean" ? serverFlag : hasChild); setDetectingHierarchy(false); })(); return () => { @@ -97,6 +100,7 @@ export default function OptionsSettingPage() { const handleToggleHierarchy = useCallback( async (checked: boolean) => { + if (!selectedColumn || !selectedTableName) return; if (!checked && hasChildRows) { const ok = await confirm( "이미 등록된 하위분류(중/소분류)가 있습니다.\n하위분류 사용을 해제해도 기존 데이터는 삭제되지 않으며, 다시 사용 설정 시 그대로 복원됩니다.\n계속하시겠습니까?", @@ -104,9 +108,18 @@ export default function OptionsSettingPage() { ); if (!ok) return; } - setUseHierarchy(checked); + const columnNameOnly = selectedColumn.includes(".") + ? selectedColumn.split(".").pop()! + : selectedColumn; + const prev = useHierarchy; + setUseHierarchy(checked); // optimistic update + const res = await updateUseHierarchy(selectedTableName, columnNameOnly, checked); + if (!res?.success) { + setUseHierarchy(prev); // 롤백 + toast.error((res as any)?.error || "하위분류 사용 설정 저장에 실패했습니다."); + } }, - [hasChildRows, confirm], + [hasChildRows, confirm, selectedColumn, selectedTableName, useHierarchy], ); const columnNameOnly = selectedColumn diff --git a/frontend/app/(main)/COMPANY_16/outsourcing/purchase-order/ReleaseRequestModal.tsx b/frontend/app/(main)/COMPANY_16/outsourcing/purchase-order/ReleaseRequestModal.tsx index 51fe593b..8dff7a5a 100644 --- a/frontend/app/(main)/COMPANY_16/outsourcing/purchase-order/ReleaseRequestModal.tsx +++ b/frontend/app/(main)/COMPANY_16/outsourcing/purchase-order/ReleaseRequestModal.tsx @@ -161,7 +161,7 @@ export function ReleaseRequestModal({ open, onOpenChange, opoIds, onSubmitted }: 선택한 외주발주의 사급자재(대기 상태)를 외주사별로 자동 그룹핑하여 출고요청합니다. 외주사 1곳당 출고전표 1건이 - 사급출고 + 사급출고 유형으로 출고관리에 등록됩니다. diff --git a/frontend/app/(main)/COMPANY_28/logistics/inbound-outbound/page.tsx b/frontend/app/(main)/COMPANY_28/logistics/inbound-outbound/page.tsx index a80772f1..26bc5694 100644 --- a/frontend/app/(main)/COMPANY_28/logistics/inbound-outbound/page.tsx +++ b/frontend/app/(main)/COMPANY_28/logistics/inbound-outbound/page.tsx @@ -85,6 +85,8 @@ export default function InboundOutboundPage() { // 품목명/단위 캐시 const [itemMap, setItemMap] = useState>({}); const [warehouseMap, setWarehouseMap] = useState>({}); + // 위치코드 → 위치명 (warehouse_location 마스터 매핑, 창고코드→명과 동일 패턴) + const [locationMap, setLocationMap] = useState>({}); const [userMap, setUserMap] = useState>({}); // ════════ 데이터 로드 ════════ @@ -153,6 +155,19 @@ export default function InboundOutboundPage() { setWarehouseMap(whMap); } catch { /* skip */ } + // 위치 정보 조회 (location_code → location_name, 참조데이터이므로 size:0 전체) + try { + const locRes = await apiClient.post(`/table-management/tables/warehouse_location/data`, { + page: 1, size: 0, autoFilter: true, + }); + const locs = locRes.data?.data?.data || locRes.data?.data?.rows || []; + const locMap: Record = {}; + for (const l of locs) { + if (l.location_code) locMap[l.location_code] = l.location_name || l.location_code; + } + setLocationMap(locMap); + } catch { /* skip */ } + // 사용자 정보 조회 (writer → user_name 변환) const writerIds = [...new Set(rows.map((r: any) => r.writer).filter(Boolean))]; if (writerIds.length > 0) { @@ -247,7 +262,7 @@ export default function InboundOutboundPage() { 카테고리: parseRemark(r.remark), 처리일자: fmtDate(r.transaction_date), 창고: warehouseMap[r.warehouse_code] || r.warehouse_code || "", - 위치: r.location_code || "", + 위치: locationMap[r.location_code] || r.location_code || "", 품목코드: r.item_code || "", 품목명: itemMap[r.item_code]?.item_name || "", 수량: Number(r.quantity) || 0, @@ -409,7 +424,7 @@ export default function InboundOutboundPage() { {parseRemark(row.remark) || "-"} {fmtDate(row.transaction_date)} {warehouseMap[row.warehouse_code] || row.warehouse_code || "-"} - {row.location_code || "-"} + {locationMap[row.location_code] || row.location_code || "-"} {row.item_code || "-"} {info?.item_name || "-"} diff --git a/frontend/app/(main)/COMPANY_28/logistics/inventory/page.tsx b/frontend/app/(main)/COMPANY_28/logistics/inventory/page.tsx index 089c0f23..d8e23b72 100644 --- a/frontend/app/(main)/COMPANY_28/logistics/inventory/page.tsx +++ b/frontend/app/(main)/COMPANY_28/logistics/inventory/page.tsx @@ -166,6 +166,9 @@ export default function InventoryStatusPage() { // 사용자 맵 (writer → 이름) const [userMap, setUserMap] = useState>({}); + // 위치코드 → 위치명 (warehouse_location 마스터 매핑, 창고코드→명과 동일 패턴) + const [locationMap, setLocationMap] = useState>({}); + // 카테고리 + 사용자 로드 useEffect(() => { const load = async () => { @@ -204,6 +207,17 @@ export default function InventoryStatusPage() { } setUserMap(map); }).catch(() => {}); + // 위치 마스터 로드 (location_code → location_name, 참조데이터 size:0 전체) + apiClient.post(`/table-management/tables/warehouse_location/data`, { + page: 1, size: 0, autoFilter: true, + }).then((res) => { + const locs = res.data?.data?.data || res.data?.data?.rows || []; + const lMap: Record = {}; + for (const l of locs) { + if (l.location_code) lMap[l.location_code] = l.location_name || l.location_code; + } + setLocationMap(lMap); + }).catch(() => {}); }, []); // 재고 목록 조회 @@ -240,6 +254,7 @@ export default function InventoryStatusPage() { spec: itemInfo?.spec || "", unit: resolve("item_inventory_unit", rawUnit) || rawUnit, warehouse_name: whMap.get(r.warehouse_code) || r.warehouse_code || "", + location_name: locationMap[r.location_code] || r.location_code || "", status: resolve("status", r.status), _isLow: r.safety_qty && Number(r.current_qty) < Number(r.safety_qty), }; @@ -264,6 +279,7 @@ export default function InventoryStatusPage() { warehouse_code: "", warehouse_name: "", location_code: "", + location_name: "", current_qty: "0", safety_qty: "", unit: resolve("item_inventory_unit", rawUnit) || rawUnit, @@ -281,7 +297,7 @@ export default function InventoryStatusPage() { } finally { setStockLoading(false); } - }, [categoryOptions, searchFilters, showMissingItems]); + }, [categoryOptions, searchFilters, showMissingItems, locationMap]); useEffect(() => { fetchStock(); @@ -471,6 +487,12 @@ export default function InventoryStatusPage() { render: (_val: any, row: any) => row.warehouse_name || row.warehouse_code || "", }; } + if (col.key === "location_code") { + return { + ...base, + render: (_val: any, row: any) => row.location_name || row.location_code || "", + }; + } if (col.key === "safety_qty") { return { ...base, @@ -503,7 +525,7 @@ export default function InventoryStatusPage() { 품명: r.item_name, 규격: r.spec || "", 창고: r.warehouse_name || r.warehouse_code, - 위치: r.location_code, + 위치: r.location_name || r.location_code || "", 현재수량: r.current_qty, 안전재고: r.safety_qty, 단위: r.unit, diff --git a/frontend/app/(main)/COMPANY_28/master-data/department/page.tsx b/frontend/app/(main)/COMPANY_28/master-data/department/page.tsx index a24fd804..9da0d79a 100644 --- a/frontend/app/(main)/COMPANY_28/master-data/department/page.tsx +++ b/frontend/app/(main)/COMPANY_28/master-data/department/page.tsx @@ -40,6 +40,7 @@ import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; +import { FormDatePicker } from "@/components/screen/filters/FormDatePicker"; const DEPT_TABLE = "dept_info"; const USER_TABLE = "user_info"; @@ -309,6 +310,8 @@ export default function DepartmentPage() { dept_name: depts.find((d) => d.dept_code === userForm.dept_code)?.dept_name || undefined, status: userForm.status || "active", end_date: userForm.end_date || null, + // 입사일(hire_date) — 선택값 그대로 전송(과거/미래 허용). 미입력 시 백엔드가 오늘로 기본 처리 + hire_date: userForm.hire_date ? userForm.hire_date.substring(0, 10) : null, }, mainDept: userForm.dept_code ? { dept_code: userForm.dept_code, @@ -769,20 +772,18 @@ export default function DepartmentPage() {
입사일 - setUserForm((p) => ({ ...p, regdate: e.target.value }))} - className="h-9" + setUserForm((p) => ({ ...p, hire_date: v }))} + placeholder="입사일을 선택해 주세요" />
퇴사일 - setUserForm((p) => ({ ...p, end_date: e.target.value }))} - className="h-9" + onChange={(v) => setUserForm((p) => ({ ...p, end_date: v }))} + placeholder="퇴사일을 선택해 주세요" />
diff --git a/frontend/app/(main)/COMPANY_28/master-data/item-info/page.tsx b/frontend/app/(main)/COMPANY_28/master-data/item-info/page.tsx index a171b9e0..d694e0dd 100644 --- a/frontend/app/(main)/COMPANY_28/master-data/item-info/page.tsx +++ b/frontend/app/(main)/COMPANY_28/master-data/item-info/page.tsx @@ -47,6 +47,7 @@ import { apiClient } from "@/lib/api/client"; import { useAuth } from "@/hooks/useAuth"; import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; +import { pickCategoryOptions } from "@/lib/utils/categoryFlatten"; import { toast } from "sonner"; // 검색 가능한 카테고리 콤보박스 @@ -339,26 +340,21 @@ export default function ItemInfoPage() { return results; }; - // 카테고리 옵션 로드 + // 카테고리 옵션 로드 — use_hierarchy 정책에 따라 depth1만 / 잎노드만 노출 + // (옵션설정 화면(CategoryValueManager)과 동일한 노출 정책을 적용해 + // 품목등록 단위 셀렉트와 옵션설정>단위가 동일한 목록으로 보이도록 정합화 — TASK:ERP-055) useEffect(() => { const loadCategories = async () => { try { const optMap: Record = {}; - const flatten = (vals: any[]): { code: string; label: string }[] => { - const result: { code: string; label: string }[] = []; - for (const v of vals) { - result.push({ code: v.valueCode, label: v.valueLabel }); - if (v.children?.length) result.push(...flatten(v.children)); - } - return result; - }; await Promise.all( CATEGORY_COLUMNS.map(async (colName) => { try { const res = await apiClient.get(`/table-categories/${TABLE_NAME}/${colName}/values`); if (res.data?.success && res.data.data?.length > 0) { - optMap[colName] = flatten(res.data.data); + const useHierarchy = res.data?.useHierarchy; + optMap[colName] = pickCategoryOptions(res.data.data, useHierarchy); } } catch { /* skip */ } }) diff --git a/frontend/app/(main)/COMPANY_28/outsourcing/purchase-order/ReleaseRequestModal.tsx b/frontend/app/(main)/COMPANY_28/outsourcing/purchase-order/ReleaseRequestModal.tsx index 51fe593b..8dff7a5a 100644 --- a/frontend/app/(main)/COMPANY_28/outsourcing/purchase-order/ReleaseRequestModal.tsx +++ b/frontend/app/(main)/COMPANY_28/outsourcing/purchase-order/ReleaseRequestModal.tsx @@ -161,7 +161,7 @@ export function ReleaseRequestModal({ open, onOpenChange, opoIds, onSubmitted }: 선택한 외주발주의 사급자재(대기 상태)를 외주사별로 자동 그룹핑하여 출고요청합니다. 외주사 1곳당 출고전표 1건이 - 사급출고 + 사급출고 유형으로 출고관리에 등록됩니다. diff --git a/frontend/app/(main)/COMPANY_28/production/result/page.tsx b/frontend/app/(main)/COMPANY_28/production/result/page.tsx index 151bc0bf..9ca9cf03 100644 --- a/frontend/app/(main)/COMPANY_28/production/result/page.tsx +++ b/frontend/app/(main)/COMPANY_28/production/result/page.tsx @@ -4,7 +4,9 @@ * 생산실적관리(PC) — 하드코딩 페이지 * * 좌측: 작업지시 목록 (work_instruction + detail + item_info JOIN) - * 우측: 선택된 작업지시의 공정별 실적 (work_order_process) + * 우측: 선택된 작업지시의 공정별 실적 + * - POP 실적은 work_order_process_result(접수 카드)에 적재되므로 + * /pop/production/processes (wop + wop_result 집계) API로 조회한다. * - 요약 카드 (지시수량/양품/불량/달성률) * - 탭: 실적내역 / 불량내역 * - 상세 모달 @@ -32,7 +34,6 @@ import { exportToExcel } from "@/lib/utils/excelExport"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; const WI_TABLE = "work_instruction"; -const WOP_TABLE = "work_order_process"; const fmtNum = (v: any) => { const n = Number(v); @@ -177,18 +178,49 @@ export default function ProductionResultPage() { useEffect(() => { fetchWiList(); }, [fetchWiList]); // 실적 로드 + // POP 공정별 실적은 work_order_process_result(접수 카드별 실적)에 적재되는데 + // work_order_process 마스터 테이블에는 good_qty/defect_qty/input_qty 컬럼이 없어 + // 마스터를 직접 조회하면 실적이 항상 0으로 표시된다(TASK:ERP-051). + // → POP 화면과 동일하게 /pop/production/processes 를 사용한다. + // 이 API는 work_order_process + work_order_process_result 를 wop 단위로 집계해 + // good_qty/defect_qty/input_qty 등 실적값을 직접 반환하며 company_code 필터가 적용된다. useEffect(() => { if (!selectedWiId) { setProcessData([]); return; } const load = async () => { setProcessLoading(true); try { - const res = await apiClient.post(`/table-management/tables/${WOP_TABLE}/data`, { - page: 1, size: 0, - dataFilter: { enabled: true, filters: [{ columnName: "wo_id", operator: "equals", value: selectedWiId }] }, - autoFilter: true, - sort: { columnName: "seq_no", order: "asc" }, - }); - setProcessData(res.data?.data?.data || res.data?.data?.rows || []); + const res = await apiClient.get("/pop/production/processes"); + const all: any[] = res.data?.data || []; + // 선택된 작업지시(wo_id = work_instruction.id)의 공정만 추출 + seq_no 오름차순 + const rows = all + .filter((r) => String(r.wo_id) === String(selectedWiId)) + .sort((a, b) => (Number(a.seq_no) || 0) - (Number(b.seq_no) || 0)) + .map((r) => { + // 설비/시작·완료/비고는 마스터엔 없고 접수 카드(accepted_results)에 존재 → 대표값 추출 + const cards: any[] = Array.isArray(r.accepted_results) ? r.accepted_results : []; + const firstStarted = cards.find((c) => c.started_at)?.started_at || ""; + const lastCompleted = [...cards].reverse().find((c) => c.completed_at)?.completed_at || ""; + const equip = cards.find((c) => c.equipment_code)?.equipment_code || r.equipment_code || ""; + // 불량내역 탭/엑셀용: 접수 카드들의 defect_detail 병합 + 대표 result_note + const mergedDefect: any[] = []; + for (const c of cards) { + if (!c.defect_detail) continue; + try { + const arr = typeof c.defect_detail === "string" ? JSON.parse(c.defect_detail) : c.defect_detail; + if (Array.isArray(arr)) mergedDefect.push(...arr); + } catch { /* skip */ } + } + const noteCard = cards.find((c) => c.result_note); + return { + ...r, + equipment_code: equip, + started_at: firstStarted, + completed_at: lastCompleted, + defect_detail: mergedDefect.length > 0 ? JSON.stringify(mergedDefect) : null, + result_note: noteCard?.result_note || "", + }; + }); + setProcessData(rows); } catch { setProcessData([]); } finally { setProcessLoading(false); } }; diff --git a/frontend/app/(main)/COMPANY_28/purchase/order/page.tsx b/frontend/app/(main)/COMPANY_28/purchase/order/page.tsx index a9db0052..e2684efe 100644 --- a/frontend/app/(main)/COMPANY_28/purchase/order/page.tsx +++ b/frontend/app/(main)/COMPANY_28/purchase/order/page.tsx @@ -32,6 +32,7 @@ import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; import { SmartSelect } from "@/components/common/SmartSelect"; import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; +import { FormDatePicker } from "@/components/screen/filters/FormDatePicker"; const MASTER_TABLE = "purchase_order_mng"; const DETAIL_TABLE = "purchase_detail"; @@ -911,12 +912,11 @@ export default function PurchaseOrderPage() {
- setMasterForm((p) => ({ ...p, order_date: e.target.value }))} - className="h-9" + onChange={(v) => setMasterForm((p) => ({ ...p, order_date: v }))} disabled={isReadOnly} + placeholder="발주일 선택" />
@@ -1115,7 +1115,7 @@ export default function PurchaseOrderPage() { {isReadOnly ? ( {row.due_date} ) : ( - updateDetailRow(idx, "due_date", e.target.value)} className="h-8 text-xs w-full" /> + updateDetailRow(idx, "due_date", v)} placeholder="납기일 선택" /> )} ); diff --git a/frontend/app/(main)/COMPANY_28/quality/item-inspection/page.tsx b/frontend/app/(main)/COMPANY_28/quality/item-inspection/page.tsx index 5937b600..fccd92c2 100644 --- a/frontend/app/(main)/COMPANY_28/quality/item-inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_28/quality/item-inspection/page.tsx @@ -15,6 +15,7 @@ import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, FileSpreadsheet, Copy, ChevronUp, ChevronDown, ChevronsUpDown, } from "lucide-react"; +import { SmartSelect } from "@/components/common/SmartSelect"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; @@ -42,18 +43,49 @@ const GRID_COLUMNS = [ type InspectionRow = { id: string; + inspection_item: string; // 검사항목 (inspection_standard.inspection_item) — 양방향 필터 그룹핑 키 inspection_standard_id: string; inspection_detail: string; inspection_method: string; apply_process: string; classification: string; acceptance_criteria: string; + upper_limit?: string; // 합격기준 상한치 (판단기준 수치형일 때 사용) + lower_limit?: string; // 합격기준 하한치 (판단기준 수치형일 때 사용) is_required: boolean; judgment_criteria?: string; // 판단기준 라벨 (수치(범위)/텍스트입력/O·X/선택형) selection_options?: string; // 선택형일 때 옵션 (콤마 구분) unit?: string; // 검사 단위 }; +// 판단기준 라벨이 수치형(상·하한 분리 입력 대상)인지 판별 +const isNumericJudgment = (jcLabel?: string) => + jcLabel === "수치(범위)" || jcLabel === "수치" || jcLabel === "범위"; + +// 조회 시 상·하한치 resolve +// - 신규 컬럼 upper_limit/lower_limit 우선 +// - 없으면 레거시 pass_criteria('기준값|오차') 파싱 fallback (하위호환) +const numericLimits = (r: any, jcLabel?: string): { upper: string; lower: string } => { + if (!isNumericJudgment(jcLabel)) return { upper: "", lower: "" }; + const u = r.upper_limit; + const l = r.lower_limit; + if ((u !== null && u !== undefined && u !== "") || (l !== null && l !== undefined && l !== "")) { + return { upper: u == null ? "" : String(u), lower: l == null ? "" : String(l) }; + } + // 레거시 pass_criteria = '기준값|오차' + const pc = String(r.pass_criteria || ""); + if (pc.includes("|")) { + const [stdRaw, tolRaw] = pc.split("|"); + const std = parseFloat(stdRaw); + const tol = tolRaw === "" || tolRaw === undefined ? 0 : parseFloat(tolRaw); + if (!isNaN(std)) { + const t = isNaN(tol) ? 0 : tol; + return { upper: String(std + t), lower: String(std - t) }; + } + } + return { upper: "", lower: "" }; +}; + export default function ItemInspectionInfoPage() { const { user } = useAuth(); const { confirm, ConfirmDialogComponent } = useConfirmDialog(); @@ -83,7 +115,7 @@ export default function ItemInspectionInfoPage() { // FK 옵션 const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; size: string; unit: string }[]>([]); - const [inspOptions, setInspOptions] = useState<{ code: string; label: string; detail: string; method: string; judgment_criteria: string; selection_options: string; unit: string; types: string[] }[]>([]); + const [inspOptions, setInspOptions] = useState<{ code: string; label: string; inspection_item: string; detail: string; method: string; judgment_criteria: string; selection_options: string; unit: string; types: string[] }[]>([]); const [inspTypeCatOptions, setInspTypeCatOptions] = useState<{ code: string; label: string }[]>([]); // 검사유형 목록 (검사기준 카테고리 기반) @@ -146,6 +178,7 @@ export default function ItemInspectionInfoPage() { setInspOptions(insps.map((r: any) => ({ code: r.id, label: r.inspection_criteria || r.inspection_standard || r.id, + inspection_item: r.inspection_item || "", detail: r.inspection_item || r.inspection_criteria || "", method: r.inspection_method || "", judgment_criteria: r.judgment_criteria || "", @@ -346,12 +379,15 @@ export default function ItemInspectionInfoPage() { const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode; rowMap[typeKey].push({ id: crypto.randomUUID(), // 복사본은 새 id 부여 (원본과 분리) + inspection_item: inspOpt?.inspection_item || "", inspection_standard_id: r.inspection_standard_id || "", inspection_detail: r.inspection_item_name || r.inspection_standard || "", inspection_method: mLabel, apply_process: r.apply_process || "", classification: r.classification || "", - acceptance_criteria: r.pass_criteria || "", + acceptance_criteria: isNumericJudgment(jcLabel) ? "" : (r.pass_criteria || ""), + upper_limit: numericLimits(r, jcLabel).upper, + lower_limit: numericLimits(r, jcLabel).lower, is_required: r.is_required === "true" || r.is_required === true, judgment_criteria: jcLabel, selection_options: inspOpt?.selection_options || "", @@ -422,7 +458,9 @@ export default function ItemInspectionInfoPage() { inspection_method: r.inspection_method || "", apply_process: r.apply_process || "", classification: r.classification || "", - pass_criteria: r.acceptance_criteria || "", + pass_criteria: isNumericJudgment(r.judgment_criteria) ? "" : (r.acceptance_criteria || ""), + upper_limit: isNumericJudgment(r.judgment_criteria) ? String(r.upper_limit ?? "").trim() : "", + lower_limit: isNumericJudgment(r.judgment_criteria) ? String(r.lower_limit ?? "").trim() : "", is_required: r.is_required ? "true" : "false", is_active: copyForm.is_active || "사용", manager: copyForm.manager || "", @@ -603,12 +641,15 @@ export default function ItemInspectionInfoPage() { const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode; rowMap[typeKey].push({ id: r.id, + inspection_item: inspOpt?.inspection_item || "", inspection_standard_id: r.inspection_standard_id || "", inspection_detail: r.inspection_item_name || r.inspection_standard || "", inspection_method: mLabel, apply_process: r.apply_process || "", classification: r.classification || "", - acceptance_criteria: r.pass_criteria || "", + acceptance_criteria: isNumericJudgment(jcLabel) ? "" : (r.pass_criteria || ""), + upper_limit: numericLimits(r, jcLabel).upper, + lower_limit: numericLimits(r, jcLabel).lower, is_required: r.is_required === "true" || r.is_required === true, judgment_criteria: jcLabel, selection_options: inspOpt?.selection_options || "", @@ -625,7 +666,7 @@ export default function ItemInspectionInfoPage() { const addInspRow = (typeKey: string) => { setInspectionRows(prev => ({ ...prev, - [typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", classification: "", acceptance_criteria: "", is_required: false }], + [typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_item: "", inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", classification: "", acceptance_criteria: "", upper_limit: "", lower_limit: "", is_required: false }], })); }; const removeInspRow = (typeKey: string, rowId: string) => { @@ -636,6 +677,25 @@ export default function ItemInspectionInfoPage() { ...prev, [typeKey]: (prev[typeKey] || []).map(r => { if (r.id !== rowId) return r; + if (field === "inspection_item") { + // 검사항목 변경 → 현 검사기준이 그 검사항목과 불일치하면 검사기준/관련값 리셋(양방향 필터) + const curOpt = inspOptions.find(o => o.code === r.inspection_standard_id); + const mismatch = !!r.inspection_standard_id && (curOpt?.inspection_item || "").trim() !== String(value || "").trim(); + if (!mismatch) return { ...r, inspection_item: value }; + return { + ...r, + inspection_item: value, + inspection_standard_id: "", + inspection_detail: "", + inspection_method: "", + judgment_criteria: "", + selection_options: "", + unit: "", + acceptance_criteria: "", + upper_limit: "", + lower_limit: "", + }; + } if (field === "inspection_standard_id") { const opt = inspOptions.find(o => o.code === value); const methodCode = opt?.method || ""; @@ -649,32 +709,59 @@ export default function ItemInspectionInfoPage() { return { ...r, inspection_standard_id: value, + // 검사기준 선택 → 검사항목 자동 동기화(양방향 필터: 검사기준 먼저 선택 시 검사항목 Fix) + inspection_item: opt?.inspection_item || r.inspection_item || "", inspection_detail: opt?.detail || "", inspection_method: methodLabel, judgment_criteria: jcLabel, selection_options: opt?.selection_options || "", unit: unitLabel, acceptance_criteria: "", // 판단기준 변경 시 초기화 + upper_limit: "", + lower_limit: "", }; } return { ...r, [field]: value }; }), })); }; - const getFilteredInspOptions = (typeKey: string) => { + // 검사유형 탭 기준으로 1차 필터된 검사기준 옵션 + const getTypeFilteredInspOptions = (typeKey: string) => { const typeDef = INSPECTION_TYPES.find(t => t.key === typeKey); if (!typeDef) return inspOptions; const matchCodes = inspTypeCatOptions.filter(cat => typeDef.matchLabels.some(ml => cat.label.includes(ml))).map(cat => cat.code); if (matchCodes.length === 0) return inspOptions; return inspOptions.filter(opt => opt.types.some(t => matchCodes.includes(t))); }; + + // 검사항목 셀렉트 옵션: 해당 검사유형 탭에 속한 검사기준의 distinct inspection_item + const getInspectionItemOptions = (typeKey: string) => { + const seen = new Set(); + const out: { code: string; label: string }[] = []; + for (const o of getTypeFilteredInspOptions(typeKey)) { + const it = (o.inspection_item || "").trim(); + if (!it || seen.has(it)) continue; + seen.add(it); + out.push({ code: it, label: it }); + } + return out.sort((a, b) => a.label.localeCompare(b.label)); + }; + + // 검사기준 셀렉트 옵션 (양방향 필터): + // 검사항목이 선택돼 있으면 그 검사항목에 해당하는 검사기준만 노출 + const getFilteredInspOptions = (typeKey: string, inspectionItem?: string) => { + const base = getTypeFilteredInspOptions(typeKey); + const it = (inspectionItem || "").trim(); + if (!it) return base; + return base.filter(opt => (opt.inspection_item || "").trim() === it); + }; const toggleCollapse = (typeKey: string) => { setCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] })); }; /* ═══════════════════ 복사 모달용 검사항목 행 관리 (등록 폼과 평행) ═══════════════════ */ const addCopyInspRow = (typeKey: string) => { setCopyInspectionRows(prev => ({ ...prev, - [typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", classification: "", acceptance_criteria: "", is_required: false }], + [typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_item: "", inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", classification: "", acceptance_criteria: "", upper_limit: "", lower_limit: "", is_required: false }], })); }; const removeCopyInspRow = (typeKey: string, rowId: string) => { @@ -685,6 +772,16 @@ export default function ItemInspectionInfoPage() { ...prev, [typeKey]: (prev[typeKey] || []).map(r => { if (r.id !== rowId) return r; + if (field === "inspection_item") { + const curOpt = inspOptions.find(o => o.code === r.inspection_standard_id); + const mismatch = !!r.inspection_standard_id && (curOpt?.inspection_item || "").trim() !== String(value || "").trim(); + if (!mismatch) return { ...r, inspection_item: value }; + return { + ...r, inspection_item: value, inspection_standard_id: "", inspection_detail: "", + inspection_method: "", judgment_criteria: "", selection_options: "", unit: "", + acceptance_criteria: "", upper_limit: "", lower_limit: "", + }; + } if (field === "inspection_standard_id") { const opt = inspOptions.find(o => o.code === value); const methodCode = opt?.method || ""; @@ -696,12 +793,15 @@ export default function ItemInspectionInfoPage() { return { ...r, inspection_standard_id: value, + inspection_item: opt?.inspection_item || r.inspection_item || "", inspection_detail: opt?.detail || "", inspection_method: methodLabel, judgment_criteria: jcLabel, selection_options: opt?.selection_options || "", unit: unitLabel, acceptance_criteria: "", + upper_limit: "", + lower_limit: "", }; } return { ...r, [field]: value }; @@ -712,6 +812,36 @@ export default function ItemInspectionInfoPage() { const handleSave = async () => { if (!form.item_code) { toast.error("품목코드는 필수예요"); return; } + // 검증: 검사기준 필수 + 수치형이면 상·하한 둘 다 필수 & 상한 > 하한 + { + const enabled = INSPECTION_TYPES.filter(t => !!form[t.key]); + for (const t of enabled) { + for (const r of (inspectionRows[t.key] || [])) { + if (!r.inspection_standard_id) { + toast.error(`[${t.label}] 검사기준을 선택해주세요`); + return; + } + if (isNumericJudgment(r.judgment_criteria)) { + const lo = String(r.lower_limit ?? "").trim(); + const up = String(r.upper_limit ?? "").trim(); + if (lo === "" || up === "") { + toast.error(`[${t.label}] 수치형 합격기준은 상한치·하한치를 모두 입력해주세요`); + return; + } + const loN = parseFloat(lo); + const upN = parseFloat(up); + if (isNaN(loN) || isNaN(upN)) { + toast.error(`[${t.label}] 상한치·하한치는 숫자여야 합니다`); + return; + } + if (upN <= loN) { + toast.error(`[${t.label}] 상한치는 하한치보다 커야 합니다`); + return; + } + } + } + } + } setSaving(true); try { if (editMode) { @@ -739,7 +869,11 @@ export default function ItemInspectionInfoPage() { rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, inspection_standard_id: r.inspection_standard_id || "", inspection_item_name: r.inspection_detail || "", - inspection_method: r.inspection_method || "", pass_criteria: r.acceptance_criteria || "", + inspection_method: r.inspection_method || "", + // 수치형: 상·하한 컬럼 저장, pass_criteria 비움 / 그 외: pass_criteria 유지 + pass_criteria: isNumericJudgment(r.judgment_criteria) ? "" : (r.acceptance_criteria || ""), + upper_limit: isNumericJudgment(r.judgment_criteria) ? String(r.upper_limit ?? "").trim() : "", + lower_limit: isNumericJudgment(r.judgment_criteria) ? String(r.lower_limit ?? "").trim() : "", apply_process: r.apply_process || "", classification: r.classification || "", is_required: r.is_required ? "true" : "false", is_active: form.is_active || "사용", manager_id: form.manager_id || "", memo: form.remarks || "", @@ -940,9 +1074,19 @@ export default function ItemInspectionInfoPage() { const itemMapping = excelItemProcessMappings.find(m => m.itemCode === itemCode); let passCriteria = ""; + let upperLimit = ""; + let lowerLimit = ""; const jcLabel = inspOpt ? (judgmentCatOptions.find(c => c.code === inspOpt.judgment_criteria)?.label || inspOpt.judgment_criteria) : ""; - if (jcLabel === "수치(범위)") { - passCriteria = `${row.standard_value || ""}|${row.tolerance || ""}`; + if (isNumericJudgment(jcLabel)) { + // 엑셀 템플릿은 기준값/오차 입력 → 상·하한으로 변환 저장 + const std = parseFloat(String(row.standard_value || "")); + const tol = parseFloat(String(row.tolerance || "0")); + if (!isNaN(std)) { + const t = isNaN(tol) ? 0 : tol; + lowerLimit = String(std - t); + upperLimit = String(std + t); + } + passCriteria = ""; } else { passCriteria = row.acceptance_criteria || ""; } @@ -966,6 +1110,8 @@ export default function ItemInspectionInfoPage() { inspection_method: inspOpt?.method || "", apply_process: applyProcess, pass_criteria: passCriteria, + upper_limit: upperLimit, + lower_limit: lowerLimit, is_required: row.is_required === "Y" ? "true" : "false", is_active: "사용", }); @@ -1237,6 +1383,16 @@ export default function ItemInspectionInfoPage() { })()} {(() => { + const insp = inspOptions.find(o => o.code === row.inspection_standard_id); + const jcLabel = judgmentCatOptions.find(c => c.code === (insp?.judgment_criteria || ""))?.label || ""; + // 수치형: upper/lower 컬럼 우선, 없으면 레거시 pass_criteria 파싱 + if (isNumericJudgment(jcLabel)) { + const lim = numericLimits(row, jcLabel); + if (lim.lower !== "" || lim.upper !== "") { + return `${lim.lower || "-"} ~ ${lim.upper || "-"}`; + } + return "-"; + } const pc = row.pass_criteria; if (!pc) return "-"; if (pc.includes("|")) { @@ -1418,6 +1574,7 @@ export default function ItemInspectionInfoPage() { + 검사항목 검사기준 선택 검사기준 상세 검사방법 @@ -1432,14 +1589,26 @@ export default function ItemInspectionInfoPage() { {(!inspectionRows[key] || inspectionRows[key].length === 0) ? ( - 항목추가 버튼으로 검사항목을 추가하세요 + 항목추가 버튼으로 검사항목을 추가하세요 ) : inspectionRows[key].map((row) => ( - + updateInspRow(key, row.id, "inspection_item", v)} + options={getInspectionItemOptions(key)} + placeholder="검사항목 선택" + className="h-8 text-xs" + /> + + + updateInspRow(key, row.id, "inspection_standard_id", v)} + options={getFilteredInspOptions(key, row.inspection_item).map(o => ({ code: o.code, label: o.label }))} + placeholder={getFilteredInspOptions(key, row.inspection_item).length === 0 ? "해당 검사기준 없음" : "검사기준 선택"} + className="h-8 text-xs" + /> @@ -1481,19 +1650,11 @@ export default function ItemInspectionInfoPage() { X (불합격) - ) : row.judgment_criteria === "수치(범위)" ? ( + ) : isNumericJudgment(row.judgment_criteria) ? (
- { - const parts = (row.acceptance_criteria || "||").split("|"); - parts[0] = e.target.value; - updateInspRow(key, row.id, "acceptance_criteria", parts.join("|")); - }} placeholder="기준값" disabled={!row.inspection_standard_id} /> - ± - { - const parts = (row.acceptance_criteria || "||").split("|"); - parts[1] = e.target.value; - updateInspRow(key, row.id, "acceptance_criteria", parts.join("|")); - }} placeholder="오차" disabled={!row.inspection_standard_id} /> + updateInspRow(key, row.id, "lower_limit", e.target.value)} placeholder="하한치" disabled={!row.inspection_standard_id} /> + ~ + updateInspRow(key, row.id, "upper_limit", e.target.value)} placeholder="상한치" disabled={!row.inspection_standard_id} /> {row.unit && {row.unit}}
) : ( @@ -1687,6 +1848,7 @@ export default function ItemInspectionInfoPage() {
+ 검사항목 검사기준 선택 검사기준 상세 검사방법 @@ -1701,14 +1863,26 @@ export default function ItemInspectionInfoPage() { {(!copyInspectionRows[key] || copyInspectionRows[key].length === 0) ? ( - 항목추가 버튼으로 검사항목을 추가하세요 + 항목추가 버튼으로 검사항목을 추가하세요 ) : copyInspectionRows[key].map((row) => ( - + updateCopyInspRow(key, row.id, "inspection_item", v)} + options={getInspectionItemOptions(key)} + placeholder="검사항목" + className="h-7 text-[10px]" + /> + + + updateCopyInspRow(key, row.id, "inspection_standard_id", v)} + options={getFilteredInspOptions(key, row.inspection_item).map(o => ({ code: o.code, label: o.label }))} + placeholder={getFilteredInspOptions(key, row.inspection_item).length === 0 ? "해당 없음" : "검사기준"} + className="h-7 text-[10px]" + /> @@ -1750,19 +1924,11 @@ export default function ItemInspectionInfoPage() { X (불합격) - ) : row.judgment_criteria === "수치(범위)" ? ( + ) : isNumericJudgment(row.judgment_criteria) ? (
- { - const parts = (row.acceptance_criteria || "||").split("|"); - parts[0] = e.target.value; - updateCopyInspRow(key, row.id, "acceptance_criteria", parts.join("|")); - }} placeholder="기준" disabled={!row.inspection_standard_id} /> - ± - { - const parts = (row.acceptance_criteria || "||").split("|"); - parts[1] = e.target.value; - updateCopyInspRow(key, row.id, "acceptance_criteria", parts.join("|")); - }} placeholder="±" disabled={!row.inspection_standard_id} /> + updateCopyInspRow(key, row.id, "lower_limit", e.target.value)} placeholder="하한" disabled={!row.inspection_standard_id} /> + ~ + updateCopyInspRow(key, row.id, "upper_limit", e.target.value)} placeholder="상한" disabled={!row.inspection_standard_id} />
) : ( updateCopyInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준" disabled={!row.inspection_standard_id} /> diff --git a/frontend/app/(main)/COMPANY_28/sales/order/page.tsx b/frontend/app/(main)/COMPANY_28/sales/order/page.tsx index 179e82e0..8bcf57f0 100644 --- a/frontend/app/(main)/COMPANY_28/sales/order/page.tsx +++ b/frontend/app/(main)/COMPANY_28/sales/order/page.tsx @@ -232,6 +232,11 @@ export default function SalesOrderPage() { // 납품처 목록 const [deliveryOptions, setDeliveryOptions] = useState<{ code: string; label: string }[]>([]); + // 거래처 담당자 목록 (customer_contact). 미선택 거래처 시 빈 배열 + const [contactOptions, setContactOptions] = useState<{ code: string; label: string }[]>([]); + // 거래처 담당자 로딩 여부 (안내문 표시용) + const [contactsLoaded, setContactsLoaded] = useState(false); + // 테이블 설정 const ts = useTableSettings("c16-sales-order", DETAIL_TABLE, GRID_COLUMNS_CONFIG); @@ -564,22 +569,79 @@ export default function SalesOrderPage() { return found?.label || code; }; - const loadDeliveryOptions = async (customerCode: string) => { + // 납품처 옵션 로드 + 메인납품처(is_default='Y') 자동선택 + // autoSelect=false (수정모달)면 옵션만 로드하고 기존 납품처 값 보존 + const loadDeliveryOptions = async (customerCode: string, autoSelect = true) => { if (!customerCode) { setDeliveryOptions([]); return; } try { const res = await apiClient.post(`/table-management/tables/delivery_destination/data`, { - page: 1, size: 100, + page: 1, size: 0, dataFilter: { enabled: true, filters: [{ columnName: "customer_code", operator: "equals", value: customerCode }] }, autoFilter: true, }); const rows = res.data?.data?.data || res.data?.data?.rows || []; - setDeliveryOptions(rows.map((r: any) => ({ + const opts = rows.map((r: any) => ({ code: r.destination_code || r.id, label: `${r.destination_name}${r.address ? ` (${r.address})` : ""}`, - }))); + })); + setDeliveryOptions(opts); + + if (!autoSelect) return; + + // 메인납품처 자동선택 (is_default: 'Y'/true). 복수면 첫 번째 1건 + const def = rows.find((r: any) => r.is_default === "Y" || r.is_default === true || r.is_default === "true"); + if (def) { + const code = def.destination_code || def.id; + const addr = def.address || ""; + setMasterForm((p) => ({ + ...p, + delivery_partner_id: code, + ...(addr ? { delivery_address: addr } : {}), + })); + } } catch { setDeliveryOptions([]); } }; + // 거래처 담당자(customer_contact) 로드 + 메인담당자(is_main) 자동선택 + // autoSelect=false (수정모달)면 옵션만 로드하고 기존 담당자 값 보존 + const loadCustomerContacts = async (customerCode: string, autoSelect = true) => { + if (!customerCode) { + setContactOptions([]); + setContactsLoaded(false); + return; + } + try { + const res = await apiClient.get(`/customer-contacts/${encodeURIComponent(customerCode)}`); + const contacts = (res.data?.data || []) as Array<{ + id: string; contact_name: string; department: string | null; + contact_phone: string | null; is_main: boolean; + }>; + const opts = contacts.map((c) => ({ + code: c.id, + label: `${c.contact_name || "(이름없음)"}${c.department ? ` (${c.department})` : ""}`, + })); + setContactOptions(opts); + setContactsLoaded(true); + + if (!autoSelect) return; + + // 메인담당자 자동선택. 없으면 미선택 유지(에러 아님) + const main = contacts.find((c) => c.is_main); + const validIds = new Set(opts.map((o) => o.code)); + setMasterForm((p) => { + if (main) return { ...p, manager_id: main.id }; + // 거래처 변경으로 기존 담당자가 새 목록에 없으면 초기화 + if (p.manager_id && !validIds.has(p.manager_id)) { + return { ...p, manager_id: "" }; + } + return p; + }); + } catch { + setContactOptions([]); + setContactsLoaded(true); + } + }; + // 등록 모달 열기 const openRegisterModal = async () => { const defaultSellMode = categoryOptions["sell_mode"]?.[0]?.code || ""; @@ -591,6 +653,8 @@ export default function SalesOrderPage() { }); setDetailRows([]); setDeliveryOptions([]); + setContactOptions([]); + setContactsLoaded(false); setIsEditMode(false); setOrderNoRuleId(null); setOrderNoPreview(null); @@ -629,8 +693,27 @@ export default function SalesOrderPage() { const detailData = detailRes.data?.data?.data || detailRes.data?.data?.rows || []; setMasterForm(masterData || {}); + // 수정모달: 거래처 담당자/납품처 옵션은 로드하되 저장값 보존(autoSelect=false) + const editPartner = masterData?.partner_id || ""; + if (editPartner) { + void loadDeliveryOptions(editPartner, false); + void loadCustomerContacts(editPartner, false); + } else { + setDeliveryOptions([]); + setContactOptions([]); + setContactsLoaded(false); + } + // 재고단위 정규화: 과거 라벨로 저장된 데이터도 코드로 변환해 셀렉트가 매칭되도록 + const unitOpts = categoryOptions["item_inventory_unit"] || []; + const normalizeUnit = (u: string) => { + if (!u) return ""; + if (unitOpts.some((o) => o.code === u)) return u; // 이미 코드 + const byLabel = unitOpts.find((o) => o.label === u); + return byLabel ? byLabel.code : u; + }; const initialRows = detailData.map((d: any, i: number) => ({ ...d, + unit: normalizeUnit(d.unit || ""), _id: d.id || `row_${i}`, pkg_options: [] as any[], })); @@ -952,7 +1035,9 @@ export default function SalesOrderPage() { pkg_code: "", pkg_qty_per_unit: "0", pkg_options: [] as Array<{ pkg_code: string; pkg_name: string; pkg_type: string; pkg_qty_per_unit: number }>, - unit: getCategoryLabel("item_inventory_unit", item.inventory_unit) || item.inventory_unit || "", + // 재고단위는 카테고리 코드를 그대로 저장 (셀렉트가 코드로 매칭 → 라벨 표시). + // 라벨을 저장하면 셀렉트 옵션 코드와 불일치하여 미표시됨 (ERP-054 ③ 재현 원인) + unit: item.inventory_unit || "", qty: "1", pack_count: "0", unit_price: unitPrice, @@ -1541,9 +1626,12 @@ export default function SalesOrderPage() { delete next.partner_id; delete next.delivery_partner_id; delete next.delivery_address; + delete next.manager_id; return next; }); setDeliveryOptions([]); + setContactOptions([]); + setContactsLoaded(false); }}> @@ -1585,20 +1673,39 @@ export default function SalesOrderPage() { { setMasterForm((p) => ({ ...p, partner_id: v, delivery_partner_id: "" })); loadDeliveryOptions(v); recalcPrices(masterForm.price_mode || "", v); }} + onValueChange={(v) => { setMasterForm((p) => ({ ...p, partner_id: v, delivery_partner_id: "", manager_id: "" })); loadDeliveryOptions(v); loadCustomerContacts(v); recalcPrices(masterForm.price_mode || "", v); }} placeholder="거래처 선택" />
- - +
+ + {masterForm.manager_id && ( + + )} +
+ {contactOptions.length > 0 ? ( + setMasterForm((p) => ({ ...p, manager_id: v }))} + placeholder="담당자 선택" + /> + ) : ( +
+ {!masterForm.partner_id + ? "거래처를 먼저 선택해주세요" + : contactsLoaded + ? "등록된 담당자 없음" + : "담당자 불러오는 중..."} +
+ )}
diff --git a/frontend/app/(main)/COMPANY_29/master-data/item-info/page.tsx b/frontend/app/(main)/COMPANY_29/master-data/item-info/page.tsx index 68552dc6..d6f30d4f 100644 --- a/frontend/app/(main)/COMPANY_29/master-data/item-info/page.tsx +++ b/frontend/app/(main)/COMPANY_29/master-data/item-info/page.tsx @@ -42,6 +42,7 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; +import { pickCategoryOptions } from "@/lib/utils/categoryFlatten"; import { useAuth } from "@/hooks/useAuth"; import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; @@ -331,21 +332,14 @@ export default function ItemInfoPage() { const loadCategories = async () => { try { const optMap: Record = {}; - const flatten = (vals: any[]): { code: string; label: string }[] => { - const result: { code: string; label: string }[] = []; - for (const v of vals) { - result.push({ code: v.valueCode, label: v.valueLabel }); - if (v.children?.length) result.push(...flatten(v.children)); - } - return result; - }; await Promise.all( CATEGORY_COLUMNS.map(async (colName) => { try { const res = await apiClient.get(`/table-categories/${TABLE_NAME}/${colName}/values`); if (res.data?.success && res.data.data?.length > 0) { - optMap[colName] = flatten(res.data.data); + const useHierarchy = res.data?.useHierarchy; + optMap[colName] = pickCategoryOptions(res.data.data, useHierarchy); } } catch { /* skip */ } }) diff --git a/frontend/app/(main)/COMPANY_29/master-data/options/page.tsx b/frontend/app/(main)/COMPANY_29/master-data/options/page.tsx index 6053f27f..71e535c7 100644 --- a/frontend/app/(main)/COMPANY_29/master-data/options/page.tsx +++ b/frontend/app/(main)/COMPANY_29/master-data/options/page.tsx @@ -10,7 +10,8 @@ import { NumberingRuleDesigner } from "@/components/numbering-rule/NumberingRule import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; import { useConfirmDialog } from "@/components/common/ConfirmDialog"; -import { getCategoryValues } from "@/lib/api/tableCategoryValue"; +import { getCategoryValues, updateUseHierarchy } from "@/lib/api/tableCategoryValue"; +import { toast } from "sonner"; const TABS = [ { id: "category", label: "카테고리 설정", icon: Tags }, @@ -85,7 +86,9 @@ export default function OptionsSettingPage() { ) : false; setHasChildRows(hasChild); - setUseHierarchy(hasChild); + // 1순위: 백엔드 정책 값 / 2순위: 자식 행 존재 여부로 자동 fallback + const serverFlag = (res as any)?.useHierarchy; + setUseHierarchy(typeof serverFlag === "boolean" ? serverFlag : hasChild); setDetectingHierarchy(false); })(); return () => { @@ -95,6 +98,7 @@ export default function OptionsSettingPage() { const handleToggleHierarchy = useCallback( async (checked: boolean) => { + if (!selectedColumn || !selectedTableName) return; if (!checked && hasChildRows) { const ok = await confirm( "이미 등록된 하위분류(중/소분류)가 있습니다.\n하위분류 사용을 해제해도 기존 데이터는 삭제되지 않으며, 다시 사용 설정 시 그대로 복원됩니다.\n계속하시겠습니까?", @@ -102,9 +106,18 @@ export default function OptionsSettingPage() { ); if (!ok) return; } - setUseHierarchy(checked); + const columnNameOnly = selectedColumn.includes(".") + ? selectedColumn.split(".").pop()! + : selectedColumn; + const prev = useHierarchy; + setUseHierarchy(checked); // optimistic update + const res = await updateUseHierarchy(selectedTableName, columnNameOnly, checked); + if (!res?.success) { + setUseHierarchy(prev); // 롤백 + toast.error((res as any)?.error || "하위분류 사용 설정 저장에 실패했습니다."); + } }, - [hasChildRows, confirm], + [hasChildRows, confirm, selectedColumn, selectedTableName, useHierarchy], ); const columnNameOnly = selectedColumn diff --git a/frontend/app/(main)/COMPANY_29/outsourcing/purchase-order/ReleaseRequestModal.tsx b/frontend/app/(main)/COMPANY_29/outsourcing/purchase-order/ReleaseRequestModal.tsx index 51fe593b..8dff7a5a 100644 --- a/frontend/app/(main)/COMPANY_29/outsourcing/purchase-order/ReleaseRequestModal.tsx +++ b/frontend/app/(main)/COMPANY_29/outsourcing/purchase-order/ReleaseRequestModal.tsx @@ -161,7 +161,7 @@ export function ReleaseRequestModal({ open, onOpenChange, opoIds, onSubmitted }: 선택한 외주발주의 사급자재(대기 상태)를 외주사별로 자동 그룹핑하여 출고요청합니다. 외주사 1곳당 출고전표 1건이 - 사급출고 + 사급출고 유형으로 출고관리에 등록됩니다. diff --git a/frontend/app/(main)/COMPANY_31/master-data/item-info/page.tsx b/frontend/app/(main)/COMPANY_31/master-data/item-info/page.tsx index 8f13b464..ebe41baf 100644 --- a/frontend/app/(main)/COMPANY_31/master-data/item-info/page.tsx +++ b/frontend/app/(main)/COMPANY_31/master-data/item-info/page.tsx @@ -42,6 +42,7 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; +import { pickCategoryOptions } from "@/lib/utils/categoryFlatten"; import { useAuth } from "@/hooks/useAuth"; import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; @@ -332,21 +333,14 @@ export default function ItemInfoPage() { const loadCategories = async () => { try { const optMap: Record = {}; - const flatten = (vals: any[]): { code: string; label: string }[] => { - const result: { code: string; label: string }[] = []; - for (const v of vals) { - result.push({ code: v.valueCode, label: v.valueLabel }); - if (v.children?.length) result.push(...flatten(v.children)); - } - return result; - }; await Promise.all( CATEGORY_COLUMNS.map(async (colName) => { try { const res = await apiClient.get(`/table-categories/${TABLE_NAME}/${colName}/values`); if (res.data?.success && res.data.data?.length > 0) { - optMap[colName] = flatten(res.data.data); + const useHierarchy = res.data?.useHierarchy; + optMap[colName] = pickCategoryOptions(res.data.data, useHierarchy); } } catch { /* skip */ } }) diff --git a/frontend/app/(main)/COMPANY_31/master-data/options/page.tsx b/frontend/app/(main)/COMPANY_31/master-data/options/page.tsx index 6053f27f..71e535c7 100644 --- a/frontend/app/(main)/COMPANY_31/master-data/options/page.tsx +++ b/frontend/app/(main)/COMPANY_31/master-data/options/page.tsx @@ -10,7 +10,8 @@ import { NumberingRuleDesigner } from "@/components/numbering-rule/NumberingRule import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; import { useConfirmDialog } from "@/components/common/ConfirmDialog"; -import { getCategoryValues } from "@/lib/api/tableCategoryValue"; +import { getCategoryValues, updateUseHierarchy } from "@/lib/api/tableCategoryValue"; +import { toast } from "sonner"; const TABS = [ { id: "category", label: "카테고리 설정", icon: Tags }, @@ -85,7 +86,9 @@ export default function OptionsSettingPage() { ) : false; setHasChildRows(hasChild); - setUseHierarchy(hasChild); + // 1순위: 백엔드 정책 값 / 2순위: 자식 행 존재 여부로 자동 fallback + const serverFlag = (res as any)?.useHierarchy; + setUseHierarchy(typeof serverFlag === "boolean" ? serverFlag : hasChild); setDetectingHierarchy(false); })(); return () => { @@ -95,6 +98,7 @@ export default function OptionsSettingPage() { const handleToggleHierarchy = useCallback( async (checked: boolean) => { + if (!selectedColumn || !selectedTableName) return; if (!checked && hasChildRows) { const ok = await confirm( "이미 등록된 하위분류(중/소분류)가 있습니다.\n하위분류 사용을 해제해도 기존 데이터는 삭제되지 않으며, 다시 사용 설정 시 그대로 복원됩니다.\n계속하시겠습니까?", @@ -102,9 +106,18 @@ export default function OptionsSettingPage() { ); if (!ok) return; } - setUseHierarchy(checked); + const columnNameOnly = selectedColumn.includes(".") + ? selectedColumn.split(".").pop()! + : selectedColumn; + const prev = useHierarchy; + setUseHierarchy(checked); // optimistic update + const res = await updateUseHierarchy(selectedTableName, columnNameOnly, checked); + if (!res?.success) { + setUseHierarchy(prev); // 롤백 + toast.error((res as any)?.error || "하위분류 사용 설정 저장에 실패했습니다."); + } }, - [hasChildRows, confirm], + [hasChildRows, confirm, selectedColumn, selectedTableName, useHierarchy], ); const columnNameOnly = selectedColumn diff --git a/frontend/app/(main)/COMPANY_31/outsourcing/purchase-order/ReleaseRequestModal.tsx b/frontend/app/(main)/COMPANY_31/outsourcing/purchase-order/ReleaseRequestModal.tsx index 51fe593b..8dff7a5a 100644 --- a/frontend/app/(main)/COMPANY_31/outsourcing/purchase-order/ReleaseRequestModal.tsx +++ b/frontend/app/(main)/COMPANY_31/outsourcing/purchase-order/ReleaseRequestModal.tsx @@ -161,7 +161,7 @@ export function ReleaseRequestModal({ open, onOpenChange, opoIds, onSubmitted }: 선택한 외주발주의 사급자재(대기 상태)를 외주사별로 자동 그룹핑하여 출고요청합니다. 외주사 1곳당 출고전표 1건이 - 사급출고 + 사급출고 유형으로 출고관리에 등록됩니다. diff --git a/frontend/app/(main)/COMPANY_7/master-data/item-info/page.tsx b/frontend/app/(main)/COMPANY_7/master-data/item-info/page.tsx index 8f13b464..7ba34a33 100644 --- a/frontend/app/(main)/COMPANY_7/master-data/item-info/page.tsx +++ b/frontend/app/(main)/COMPANY_7/master-data/item-info/page.tsx @@ -42,6 +42,7 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; +import { pickCategoryOptions } from "@/lib/utils/categoryFlatten"; import { useAuth } from "@/hooks/useAuth"; import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; @@ -327,26 +328,19 @@ export default function ItemInfoPage() { return results; }; - // 카테고리 옵션 로드 + // 카테고리 옵션 로드 — use_hierarchy 정책에 따라 depth1만 / 잎노드만 노출 useEffect(() => { const loadCategories = async () => { try { const optMap: Record = {}; - const flatten = (vals: any[]): { code: string; label: string }[] => { - const result: { code: string; label: string }[] = []; - for (const v of vals) { - result.push({ code: v.valueCode, label: v.valueLabel }); - if (v.children?.length) result.push(...flatten(v.children)); - } - return result; - }; await Promise.all( CATEGORY_COLUMNS.map(async (colName) => { try { const res = await apiClient.get(`/table-categories/${TABLE_NAME}/${colName}/values`); if (res.data?.success && res.data.data?.length > 0) { - optMap[colName] = flatten(res.data.data); + const useHierarchy = res.data?.useHierarchy; + optMap[colName] = pickCategoryOptions(res.data.data, useHierarchy); } } catch { /* skip */ } }) diff --git a/frontend/app/(main)/COMPANY_7/master-data/options/page.tsx b/frontend/app/(main)/COMPANY_7/master-data/options/page.tsx index 6053f27f..71e535c7 100644 --- a/frontend/app/(main)/COMPANY_7/master-data/options/page.tsx +++ b/frontend/app/(main)/COMPANY_7/master-data/options/page.tsx @@ -10,7 +10,8 @@ import { NumberingRuleDesigner } from "@/components/numbering-rule/NumberingRule import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; import { useConfirmDialog } from "@/components/common/ConfirmDialog"; -import { getCategoryValues } from "@/lib/api/tableCategoryValue"; +import { getCategoryValues, updateUseHierarchy } from "@/lib/api/tableCategoryValue"; +import { toast } from "sonner"; const TABS = [ { id: "category", label: "카테고리 설정", icon: Tags }, @@ -85,7 +86,9 @@ export default function OptionsSettingPage() { ) : false; setHasChildRows(hasChild); - setUseHierarchy(hasChild); + // 1순위: 백엔드 정책 값 / 2순위: 자식 행 존재 여부로 자동 fallback + const serverFlag = (res as any)?.useHierarchy; + setUseHierarchy(typeof serverFlag === "boolean" ? serverFlag : hasChild); setDetectingHierarchy(false); })(); return () => { @@ -95,6 +98,7 @@ export default function OptionsSettingPage() { const handleToggleHierarchy = useCallback( async (checked: boolean) => { + if (!selectedColumn || !selectedTableName) return; if (!checked && hasChildRows) { const ok = await confirm( "이미 등록된 하위분류(중/소분류)가 있습니다.\n하위분류 사용을 해제해도 기존 데이터는 삭제되지 않으며, 다시 사용 설정 시 그대로 복원됩니다.\n계속하시겠습니까?", @@ -102,9 +106,18 @@ export default function OptionsSettingPage() { ); if (!ok) return; } - setUseHierarchy(checked); + const columnNameOnly = selectedColumn.includes(".") + ? selectedColumn.split(".").pop()! + : selectedColumn; + const prev = useHierarchy; + setUseHierarchy(checked); // optimistic update + const res = await updateUseHierarchy(selectedTableName, columnNameOnly, checked); + if (!res?.success) { + setUseHierarchy(prev); // 롤백 + toast.error((res as any)?.error || "하위분류 사용 설정 저장에 실패했습니다."); + } }, - [hasChildRows, confirm], + [hasChildRows, confirm, selectedColumn, selectedTableName, useHierarchy], ); const columnNameOnly = selectedColumn diff --git a/frontend/app/(main)/COMPANY_7/outsourcing/purchase-order/ReleaseRequestModal.tsx b/frontend/app/(main)/COMPANY_7/outsourcing/purchase-order/ReleaseRequestModal.tsx index 51fe593b..8dff7a5a 100644 --- a/frontend/app/(main)/COMPANY_7/outsourcing/purchase-order/ReleaseRequestModal.tsx +++ b/frontend/app/(main)/COMPANY_7/outsourcing/purchase-order/ReleaseRequestModal.tsx @@ -161,7 +161,7 @@ export function ReleaseRequestModal({ open, onOpenChange, opoIds, onSubmitted }: 선택한 외주발주의 사급자재(대기 상태)를 외주사별로 자동 그룹핑하여 출고요청합니다. 외주사 1곳당 출고전표 1건이 - 사급출고 + 사급출고 유형으로 출고관리에 등록됩니다. diff --git a/frontend/app/(main)/COMPANY_8/master-data/item-info/page.tsx b/frontend/app/(main)/COMPANY_8/master-data/item-info/page.tsx index a171b9e0..8d371f0a 100644 --- a/frontend/app/(main)/COMPANY_8/master-data/item-info/page.tsx +++ b/frontend/app/(main)/COMPANY_8/master-data/item-info/page.tsx @@ -44,6 +44,7 @@ import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; import { useConfirmDialog } from "@/components/common/ConfirmDialog"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; +import { pickCategoryOptions } from "@/lib/utils/categoryFlatten"; import { useAuth } from "@/hooks/useAuth"; import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; @@ -344,21 +345,14 @@ export default function ItemInfoPage() { const loadCategories = async () => { try { const optMap: Record = {}; - const flatten = (vals: any[]): { code: string; label: string }[] => { - const result: { code: string; label: string }[] = []; - for (const v of vals) { - result.push({ code: v.valueCode, label: v.valueLabel }); - if (v.children?.length) result.push(...flatten(v.children)); - } - return result; - }; await Promise.all( CATEGORY_COLUMNS.map(async (colName) => { try { const res = await apiClient.get(`/table-categories/${TABLE_NAME}/${colName}/values`); if (res.data?.success && res.data.data?.length > 0) { - optMap[colName] = flatten(res.data.data); + const useHierarchy = res.data?.useHierarchy; + optMap[colName] = pickCategoryOptions(res.data.data, useHierarchy); } } catch { /* skip */ } }) diff --git a/frontend/app/(main)/COMPANY_8/master-data/options/page.tsx b/frontend/app/(main)/COMPANY_8/master-data/options/page.tsx index 6053f27f..71e535c7 100644 --- a/frontend/app/(main)/COMPANY_8/master-data/options/page.tsx +++ b/frontend/app/(main)/COMPANY_8/master-data/options/page.tsx @@ -10,7 +10,8 @@ import { NumberingRuleDesigner } from "@/components/numbering-rule/NumberingRule import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; import { useConfirmDialog } from "@/components/common/ConfirmDialog"; -import { getCategoryValues } from "@/lib/api/tableCategoryValue"; +import { getCategoryValues, updateUseHierarchy } from "@/lib/api/tableCategoryValue"; +import { toast } from "sonner"; const TABS = [ { id: "category", label: "카테고리 설정", icon: Tags }, @@ -85,7 +86,9 @@ export default function OptionsSettingPage() { ) : false; setHasChildRows(hasChild); - setUseHierarchy(hasChild); + // 1순위: 백엔드 정책 값 / 2순위: 자식 행 존재 여부로 자동 fallback + const serverFlag = (res as any)?.useHierarchy; + setUseHierarchy(typeof serverFlag === "boolean" ? serverFlag : hasChild); setDetectingHierarchy(false); })(); return () => { @@ -95,6 +98,7 @@ export default function OptionsSettingPage() { const handleToggleHierarchy = useCallback( async (checked: boolean) => { + if (!selectedColumn || !selectedTableName) return; if (!checked && hasChildRows) { const ok = await confirm( "이미 등록된 하위분류(중/소분류)가 있습니다.\n하위분류 사용을 해제해도 기존 데이터는 삭제되지 않으며, 다시 사용 설정 시 그대로 복원됩니다.\n계속하시겠습니까?", @@ -102,9 +106,18 @@ export default function OptionsSettingPage() { ); if (!ok) return; } - setUseHierarchy(checked); + const columnNameOnly = selectedColumn.includes(".") + ? selectedColumn.split(".").pop()! + : selectedColumn; + const prev = useHierarchy; + setUseHierarchy(checked); // optimistic update + const res = await updateUseHierarchy(selectedTableName, columnNameOnly, checked); + if (!res?.success) { + setUseHierarchy(prev); // 롤백 + toast.error((res as any)?.error || "하위분류 사용 설정 저장에 실패했습니다."); + } }, - [hasChildRows, confirm], + [hasChildRows, confirm, selectedColumn, selectedTableName, useHierarchy], ); const columnNameOnly = selectedColumn diff --git a/frontend/app/(main)/COMPANY_8/outsourcing/purchase-order/ReleaseRequestModal.tsx b/frontend/app/(main)/COMPANY_8/outsourcing/purchase-order/ReleaseRequestModal.tsx index 51fe593b..8dff7a5a 100644 --- a/frontend/app/(main)/COMPANY_8/outsourcing/purchase-order/ReleaseRequestModal.tsx +++ b/frontend/app/(main)/COMPANY_8/outsourcing/purchase-order/ReleaseRequestModal.tsx @@ -161,7 +161,7 @@ export function ReleaseRequestModal({ open, onOpenChange, opoIds, onSubmitted }: 선택한 외주발주의 사급자재(대기 상태)를 외주사별로 자동 그룹핑하여 출고요청합니다. 외주사 1곳당 출고전표 1건이 - 사급출고 + 사급출고 유형으로 출고관리에 등록됩니다. diff --git a/frontend/app/(main)/COMPANY_8/quality/item-inspection/page.tsx b/frontend/app/(main)/COMPANY_8/quality/item-inspection/page.tsx index 5937b600..d61d2649 100644 --- a/frontend/app/(main)/COMPANY_8/quality/item-inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_8/quality/item-inspection/page.tsx @@ -13,8 +13,11 @@ import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/componen import { Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ClipboardList, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, FileSpreadsheet, Copy, - ChevronUp, ChevronDown, ChevronsUpDown, + GripVertical, ChevronUp, ChevronDown, ChevronsUpDown, } from "lucide-react"; +import { DndContext, closestCenter, PointerSensor, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core"; +import { SortableContext, verticalListSortingStrategy, useSortable, arrayMove } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; @@ -26,6 +29,7 @@ import { useConfirmDialog } from "@/components/common/ConfirmDialog"; import { SmartExcelUploadModal } from "@/components/common/SmartExcelUpload"; import type { SmartExcelUploadConfig, ParsedSheetData } from "@/components/common/SmartExcelUpload"; import { getInspectionGrouped, type InspectionGroupApi } from "@/lib/api/itemInspection"; +import { getProcessList } from "@/lib/api/processInfo"; const TABLE_NAME = "item_inspection_info"; const ITEM_TABLE = "item_info"; @@ -54,10 +58,36 @@ type InspectionRow = { unit?: string; // 검사 단위 }; +function SortableInspectionTableRow({ id, children }: { id: string; children: (dragHandle: React.ReactNode) => React.ReactNode }) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + const handle = ( + + ); + return ( + + {children(handle)} + + ); +} + export default function ItemInspectionInfoPage() { const { user } = useAuth(); const { confirm, ConfirmDialogComponent } = useConfirmDialog(); const ts = useTableSettings("c16-item-inspection", TABLE_NAME, GRID_COLUMNS); + const dndSensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } })); const [data, setData] = useState([]); const [serverGroups, setServerGroups] = useState([]); @@ -111,6 +141,9 @@ export default function ItemInspectionInfoPage() { // 기본 라우팅 공정 목록 (적용공정 Select용) const [processOptions, setProcessOptions] = useState<{ code: string; name: string }[]>([]); + // 전체 공정 마스터 라벨 매핑 (라우팅 외 공정 fallback용) — process_code → process_name + const [processMasterMap, setProcessMasterMap] = useState>({}); + // 엑셀 업로드 모달 const [excelUploadOpen, setExcelUploadOpen] = useState(false); @@ -209,6 +242,18 @@ export default function ItemInspectionInfoPage() { code: u.user_id || u.id, label: `${u.user_name || u.name || u.user_id}${u.dept_name ? ` (${u.dept_name})` : ""}`, }))); + + // 공정 마스터 라벨 매핑 — 라우팅 외 공정 코드 fallback용 + try { + const procRes = await getProcessList(); + if (procRes.success && procRes.data) { + const map: Record = {}; + for (const p of procRes.data) { + if (p.process_code) map[p.process_code] = p.process_name || p.process_code; + } + setProcessMasterMap(map); + } + } catch { /* skip */ } } catch { /* skip */ } }; loadOptions(); @@ -631,6 +676,15 @@ export default function ItemInspectionInfoPage() { const removeInspRow = (typeKey: string, rowId: string) => { setInspectionRows(prev => ({ ...prev, [typeKey]: (prev[typeKey] || []).filter(r => r.id !== rowId) })); }; + const reorderInspRows = (typeKey: string, fromId: string, toId: string) => { + setInspectionRows(prev => { + const list = prev[typeKey] || []; + const fromIdx = list.findIndex(r => r.id === fromId); + const toIdx = list.findIndex(r => r.id === toId); + if (fromIdx < 0 || toIdx < 0 || fromIdx === toIdx) return prev; + return { ...prev, [typeKey]: arrayMove(list, fromIdx, toIdx) }; + }); + }; const updateInspRow = (typeKey: string, rowId: string, field: string, value: any) => { setInspectionRows(prev => ({ ...prev, @@ -1225,7 +1279,9 @@ export default function ItemInspectionInfoPage() { } // processOptions (모달용)에서 찾기 const proc = processOptions.find(p => p.code === code); - return proc?.name || code; + if (proc?.name) return proc.name; + // process_master 전체 매핑에서 fallback + return processMasterMap[code] || code; })()} {row.classification || "-"} @@ -1418,6 +1474,7 @@ export default function ItemInspectionInfoPage() {
+ 검사기준 선택 검사기준 상세 검사방법 @@ -1432,9 +1489,30 @@ export default function ItemInspectionInfoPage() { {(!inspectionRows[key] || inspectionRows[key].length === 0) ? ( - 항목추가 버튼으로 검사항목을 추가하세요 - ) : inspectionRows[key].map((row) => ( - + 항목추가 버튼으로 검사항목을 추가하세요 + ) : ( + { + const { active, over } = e; + if (over && active.id !== over.id) { + reorderInspRows(key, String(active.id), String(over.id)); + } + }} + > + r.id)} strategy={verticalListSortingStrategy}> + {inspectionRows[key].map((row) => { + // 적용공정 Select options 동적 보강: 저장된 코드가 라우팅 옵션에 없으면 fallback 라벨로 옵션 추가 + const selectedProc = row.apply_process || ""; + const inOptions = processOptions.some(p => p.code === selectedProc); + const fallbackProcOptions = (selectedProc && !inOptions) + ? [...processOptions, { code: selectedProc, name: (processMasterMap[selectedProc] || selectedProc) + (processOptions.length > 0 ? " (라우팅 외)" : "") }] + : processOptions; + return ( + + {(dragHandle) => (<> + {dragHandle} - {processOptions.length > 0 ? ( + {fallbackProcOptions.length > 0 ? (
diff --git a/frontend/app/(main)/COMPANY_9/master-data/item-info/page.tsx b/frontend/app/(main)/COMPANY_9/master-data/item-info/page.tsx index 6dcd8387..b6a524c9 100644 --- a/frontend/app/(main)/COMPANY_9/master-data/item-info/page.tsx +++ b/frontend/app/(main)/COMPANY_9/master-data/item-info/page.tsx @@ -42,6 +42,7 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; +import { pickCategoryOptions } from "@/lib/utils/categoryFlatten"; import { useAuth } from "@/hooks/useAuth"; import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; @@ -337,21 +338,14 @@ export default function ItemInfoPage() { const loadCategories = async () => { try { const optMap: Record = {}; - const flatten = (vals: any[]): { code: string; label: string }[] => { - const result: { code: string; label: string }[] = []; - for (const v of vals) { - result.push({ code: v.valueCode, label: v.valueLabel }); - if (v.children?.length) result.push(...flatten(v.children)); - } - return result; - }; await Promise.all( CATEGORY_COLUMNS.map(async (colName) => { try { const res = await apiClient.get(`/table-categories/${TABLE_NAME}/${colName}/values`); if (res.data?.success && res.data.data?.length > 0) { - optMap[colName] = flatten(res.data.data); + const useHierarchy = res.data?.useHierarchy; + optMap[colName] = pickCategoryOptions(res.data.data, useHierarchy); } } catch { /* skip */ } }) diff --git a/frontend/app/(main)/COMPANY_9/master-data/options/page.tsx b/frontend/app/(main)/COMPANY_9/master-data/options/page.tsx index 6053f27f..71e535c7 100644 --- a/frontend/app/(main)/COMPANY_9/master-data/options/page.tsx +++ b/frontend/app/(main)/COMPANY_9/master-data/options/page.tsx @@ -10,7 +10,8 @@ import { NumberingRuleDesigner } from "@/components/numbering-rule/NumberingRule import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; import { useConfirmDialog } from "@/components/common/ConfirmDialog"; -import { getCategoryValues } from "@/lib/api/tableCategoryValue"; +import { getCategoryValues, updateUseHierarchy } from "@/lib/api/tableCategoryValue"; +import { toast } from "sonner"; const TABS = [ { id: "category", label: "카테고리 설정", icon: Tags }, @@ -85,7 +86,9 @@ export default function OptionsSettingPage() { ) : false; setHasChildRows(hasChild); - setUseHierarchy(hasChild); + // 1순위: 백엔드 정책 값 / 2순위: 자식 행 존재 여부로 자동 fallback + const serverFlag = (res as any)?.useHierarchy; + setUseHierarchy(typeof serverFlag === "boolean" ? serverFlag : hasChild); setDetectingHierarchy(false); })(); return () => { @@ -95,6 +98,7 @@ export default function OptionsSettingPage() { const handleToggleHierarchy = useCallback( async (checked: boolean) => { + if (!selectedColumn || !selectedTableName) return; if (!checked && hasChildRows) { const ok = await confirm( "이미 등록된 하위분류(중/소분류)가 있습니다.\n하위분류 사용을 해제해도 기존 데이터는 삭제되지 않으며, 다시 사용 설정 시 그대로 복원됩니다.\n계속하시겠습니까?", @@ -102,9 +106,18 @@ export default function OptionsSettingPage() { ); if (!ok) return; } - setUseHierarchy(checked); + const columnNameOnly = selectedColumn.includes(".") + ? selectedColumn.split(".").pop()! + : selectedColumn; + const prev = useHierarchy; + setUseHierarchy(checked); // optimistic update + const res = await updateUseHierarchy(selectedTableName, columnNameOnly, checked); + if (!res?.success) { + setUseHierarchy(prev); // 롤백 + toast.error((res as any)?.error || "하위분류 사용 설정 저장에 실패했습니다."); + } }, - [hasChildRows, confirm], + [hasChildRows, confirm, selectedColumn, selectedTableName, useHierarchy], ); const columnNameOnly = selectedColumn diff --git a/frontend/app/(main)/COMPANY_9/outsourcing/purchase-order/ReleaseRequestModal.tsx b/frontend/app/(main)/COMPANY_9/outsourcing/purchase-order/ReleaseRequestModal.tsx index 51fe593b..8dff7a5a 100644 --- a/frontend/app/(main)/COMPANY_9/outsourcing/purchase-order/ReleaseRequestModal.tsx +++ b/frontend/app/(main)/COMPANY_9/outsourcing/purchase-order/ReleaseRequestModal.tsx @@ -161,7 +161,7 @@ export function ReleaseRequestModal({ open, onOpenChange, opoIds, onSubmitted }: 선택한 외주발주의 사급자재(대기 상태)를 외주사별로 자동 그룹핑하여 출고요청합니다. 외주사 1곳당 출고전표 1건이 - 사급출고 + 사급출고 유형으로 출고관리에 등록됩니다. diff --git a/frontend/components/common/FullscreenDialog.tsx b/frontend/components/common/FullscreenDialog.tsx index c723f2c5..29aaa95e 100644 --- a/frontend/components/common/FullscreenDialog.tsx +++ b/frontend/components/common/FullscreenDialog.tsx @@ -61,12 +61,10 @@ export function FullscreenDialog({
- {typeof title === "string" ? {title} : title} - {description && ( - typeof description === "string" - ? {description} - : description - )} + {/* title은 string/JSX 무관하게 항상 DialogTitle로 감싼다 + (Radix 접근성: DialogContent에 DialogTitle 필수) */} + {title} + {description && {description}}
+ )} +
+ updateField("inspection_code", v)} + options={inspCodeOptions} + placeholder="검사코드 선택" + className="mt-1" />
- - +
+ + {formData.inspection_method && ( + + )} +
+ updateField("inspection_method", v)} + options={inspMethodOptions} + placeholder="검사방법 선택" + className="mt-1" + />
+
+
+ + {formData.unit && ( + + )} +
+ updateField("unit", v)} + options={inspUnitOptions} + placeholder="단위 선택" + className="mt-1" + /> +
- {/* 기준값 ± 오차범위 */} + {/* 하한 ~ 상한 (선택, 숫자) */}
-
+ +
updateField("base_value", e.target.value)} - placeholder="기준값" + value={formData.lower_limit ?? ""} + onChange={(e) => updateField("lower_limit", e.target.value)} + placeholder="하한" className="h-8 text-xs sm:h-10 sm:text-sm" />
- ± + ~
updateField("tolerance", e.target.value)} - placeholder="오차범위" + value={formData.upper_limit ?? ""} + onChange={(e) => updateField("upper_limit", e.target.value)} + placeholder="상한" className="h-8 text-xs sm:h-10 sm:text-sm" />
diff --git a/frontend/lib/registry/components/v2-process-work-standard/components/WorkItemDetailList.tsx b/frontend/lib/registry/components/v2-process-work-standard/components/WorkItemDetailList.tsx index 54d4edc3..bca495b4 100644 --- a/frontend/lib/registry/components/v2-process-work-standard/components/WorkItemDetailList.tsx +++ b/frontend/lib/registry/components/v2-process-work-standard/components/WorkItemDetailList.tsx @@ -20,11 +20,26 @@ export function getDetailContentSummary(detail: Partial): string } return base; } + // 미적용(수동입력) 요약 — TASK:ERP-061 + // 정규화 저장분: content가 라벨 변환된 정규화값 조합(SoT 파생)이므로 그대로 우선 사용 + // (이 컴포넌트엔 카테고리 옵션이 없어 raw inspection_method/unit 직접 노출 시 CAT_ 누출 위험 → content 우선) + // 구 데이터(content만 있고 정규화 컬럼 빈값): 기존 base_value/tolerance fallback 유지(회귀 방지) + const isNormalized = + !!detail.inspection_item_name || + !!detail.inspection_code || + (detail.lower_limit !== undefined && detail.lower_limit !== null && `${detail.lower_limit}` !== "") || + (detail.upper_limit !== undefined && detail.upper_limit !== null && `${detail.upper_limit}` !== ""); const parts: string[] = []; - if (detail.content) parts.push(detail.content); - if (detail.inspection_method) parts.push(`[${detail.inspection_method}]`); - if (detail.base_value) { - parts.push(`(기준: ${detail.base_value}${detail.tolerance ? ` ±${detail.tolerance}` : ""} ${detail.unit || ""})`); + if (isNormalized) { + // content는 저장 시 "검사항목명 | 코드 | 방법 | 하한~상한 단위" 형태(라벨 변환 완료) + if (detail.content) parts.push(detail.content); + else if (detail.inspection_item_name) parts.push(detail.inspection_item_name); + } else { + if (detail.content) parts.push(detail.content); + if (detail.inspection_method) parts.push(`[${detail.inspection_method}]`); + if (detail.base_value) { + parts.push(`(기준: ${detail.base_value}${detail.tolerance ? ` ±${detail.tolerance}` : ""} ${detail.unit || ""})`); + } } if (detail.inspection_count_apply === "Y" && detail.inspection_count) { parts.push(`(횟수 ${detail.inspection_count}회)`); diff --git a/frontend/lib/registry/components/v2-process-work-standard/types.ts b/frontend/lib/registry/components/v2-process-work-standard/types.ts index 19ed8735..993b6d0e 100644 --- a/frontend/lib/registry/components/v2-process-work-standard/types.ts +++ b/frontend/lib/registry/components/v2-process-work-standard/types.ts @@ -94,6 +94,7 @@ export interface WorkItemDetail { // 검사항목(inspection) 전용 process_inspection_apply?: string; // "apply" | "none" + inspection_item_name?: string; // 미적용 수동입력 검사항목명 (표시/복원용, DB 미저장 — content가 조합 저장됨) inspection_code?: string; inspection_method?: string; unit?: string; diff --git a/frontend/lib/utils/categoryFlatten.ts b/frontend/lib/utils/categoryFlatten.ts new file mode 100644 index 00000000..7c971e83 --- /dev/null +++ b/frontend/lib/utils/categoryFlatten.ts @@ -0,0 +1,79 @@ +/** + * TASK:ERP-049 카테고리 옵션 평탄화 공통 헬퍼 + * + * 정책: + * - use_hierarchy=false → depth=1(루트) 노드만 옵션화. 부모 카테고리만 노출하고 자식은 숨김. + * - use_hierarchy=true → 잎노드(children 없는 노드)만 옵션화. 부모는 그룹 헤더 역할이라 선택 불가. + * + * 어떤 경우에도 "자식을 가진 부모 카테고리"는 셀렉트 옵션에 포함되지 않는다. + */ + +export interface CategoryNodeLike { + valueCode: string; + valueLabel: string; + children?: CategoryNodeLike[]; + // depth/parentValueId는 옵션에 영향 없음 (계층은 children 유무로 판단) +} + +export interface CategoryOption { + code: string; + label: string; +} + +/** + * 카테고리 트리에서 셀렉트 옵션 배열을 생성한다. + * + * @param values 백엔드 응답의 카테고리 트리 (buildHierarchy 결과) + * @param useHierarchy 트리 사용 여부 (null=미설정 → 자동 fallback) + */ +export function pickCategoryOptions( + values: CategoryNodeLike[] | undefined | null, + useHierarchy: boolean | null | undefined, +): CategoryOption[] { + if (!Array.isArray(values) || values.length === 0) return []; + + // 자동 fallback: 어떤 노드라도 children을 가지면 true로 추정 + const effectiveHierarchy = + typeof useHierarchy === "boolean" + ? useHierarchy + : values.some((v) => Array.isArray(v.children) && v.children.length > 0); + + if (!effectiveHierarchy) { + // depth=1(루트)만 — buildHierarchy 결과의 최상위 배열 = 루트들 + return values.map((v) => ({ code: v.valueCode, label: v.valueLabel })); + } + + // 잎노드만 (children이 없는 노드) + const result: CategoryOption[] = []; + const visit = (nodes: CategoryNodeLike[]) => { + for (const v of nodes) { + const kids = Array.isArray(v.children) ? v.children : []; + if (kids.length === 0) { + result.push({ code: v.valueCode, label: v.valueLabel }); + } else { + visit(kids); + } + } + }; + visit(values); + return result; +} + +/** + * 레거시 호환: 기존 flatten 패턴(부모+자식 전체)을 그대로 평탄화한다. + * 새 코드는 pickCategoryOptions을 사용할 것. + */ +export function flattenAllCategoryNodes(values: CategoryNodeLike[] | undefined | null): CategoryOption[] { + if (!Array.isArray(values) || values.length === 0) return []; + const result: CategoryOption[] = []; + const visit = (nodes: CategoryNodeLike[]) => { + for (const v of nodes) { + result.push({ code: v.valueCode, label: v.valueLabel }); + if (Array.isArray(v.children) && v.children.length > 0) { + visit(v.children); + } + } + }; + visit(values); + return result; +}