From e5e7a5c261d08b803a23af03986f530e71805b4f Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 22 May 2026 13:33:16 +0900 Subject: [PATCH] Enhance Cutting Plan and Work Instruction Functionality - Integrated `SmartSelect` component for improved material selection in the cutting plan page, providing a better user experience when no materials are available. - Updated the work instruction modal to include routing options directly selectable by users, enhancing the flexibility of work instruction management. - Adjusted table layouts across various components to accommodate new data fields and improve overall UI consistency. (TASK: ERP-XXX) --- .../src/controllers/outboundController.ts | 7 +- .../WorkInstructionApplyModal.tsx | 48 ++++++++++-- .../production/cutting-plan/page.tsx | 48 +++++++----- .../production/work-instruction/page.tsx | 78 +++++++++---------- .../app/(main)/COMPANY_9/sales/order/page.tsx | 58 +++++++------- .../components/DetailFormModal.tsx | 33 +++++++- 6 files changed, 175 insertions(+), 97 deletions(-) diff --git a/backend-node/src/controllers/outboundController.ts b/backend-node/src/controllers/outboundController.ts index 93940ebb..9d4fa035 100644 --- a/backend-node/src/controllers/outboundController.ts +++ b/backend-node/src/controllers/outboundController.ts @@ -85,11 +85,12 @@ export async function getList(req: AuthenticatedRequest, res: Response) { NULLIF(si.delivery_address, ''), '' ) AS delivery_destination_name, - -- 거래처명 (TASK:ERP-098 항목20): 출고 행 customer_name 우선, - -- 없으면 customer_mng JOIN, 매핑 깨지면 코드 fallback + -- 거래처명 (TASK:ERP-098 항목20): customer_mng JOIN 라벨을 최우선. + -- 출고 생성 시 om.customer_name에 거래처 코드가 저장되는 경우가 있어 + -- JOIN 라벨 → 저장값 → 코드 순으로 fallback COALESCE( - NULLIF(om.customer_name, ''), NULLIF(c.customer_name, ''), + NULLIF(om.customer_name, ''), NULLIF(om.customer_code, ''), '' ) AS customer_name, diff --git a/frontend/app/(main)/COMPANY_9/production/cutting-plan/WorkInstructionApplyModal.tsx b/frontend/app/(main)/COMPANY_9/production/cutting-plan/WorkInstructionApplyModal.tsx index b578e341..901148ad 100644 --- a/frontend/app/(main)/COMPANY_9/production/cutting-plan/WorkInstructionApplyModal.tsx +++ b/frontend/app/(main)/COMPANY_9/production/cutting-plan/WorkInstructionApplyModal.tsx @@ -28,9 +28,12 @@ import { } from "@/components/ui/table"; import { cn } from "@/lib/utils"; +import { SmartSelect } from "@/components/common/SmartSelect"; + import { previewWorkInstructionNo, saveWorkInstruction, getEquipmentList, getEmployeeList, getRoutingVersions, + RoutingVersionData, } from "@/lib/api/workInstruction"; // ─── 공용 다중선택 Popover (설비/작업조/작업자) ──────────────────── @@ -128,6 +131,9 @@ export interface WorkInstructionApplyItem { equipmentIds?: string[]; workTeams?: string[]; workers?: string[]; + // 품목별 라우팅 (작업지시 등록창과 동일하게 직접 선택) + routing?: string; + routingOptions?: RoutingVersionData[]; } export interface WorkInstructionApplyModalProps { @@ -173,14 +179,26 @@ export default function WorkInstructionApplyModal({ getEquipmentList().then((r) => { if (r.success) setEquipmentOptions(r.data || []); }).catch(() => {}); getEmployeeList().then((r) => { if (r.success) setWorkerOptions(r.data || []); }).catch(() => {}); - // 품목별 라우팅 등록 여부 사전 조회 (엣지 1 — 라우팅 미등록 안내). - // 라우팅 세팅 자체는 백엔드 save 가 자동 처리하므로 여기서는 안내 표시용으로만 사용. + // 품목별 라우팅 버전 사전 조회 — 작업지시 등록창과 동일 패턴(getRoutingVersions("__new__", itemCode)). + // 조회 결과를 각 행의 routingOptions 에 채우고 is_default 버전을 초기 선택값으로 세팅. + // routingStatus 는 라우팅 미등록 안내 배너용(엣지 1). const uniqueCodes = [...new Set(initialItems.map((x) => x.itemCode).filter(Boolean))]; for (const code of uniqueCodes) { getRoutingVersions("__new__", code) .then((r) => { const has = !!(r.success && r.data && r.data.length > 0); setRoutingStatus((prev) => ({ ...prev, [code]: has })); + if (r.success && r.data) { + // 작업지시 등록창과 동일: is_default 버전을 기본 선택, 없으면 빈값 + const defaultRv = r.data.find((rv) => rv.is_default); + setItems((prev) => + prev.map((it) => + it.itemCode === code + ? { ...it, routingOptions: r.data, routing: defaultRv?.id || "" } + : it, + ), + ); + } }) .catch(() => { // 조회 실패 시 안내를 띄우지 않음(미등록으로 단정하지 않음) @@ -218,7 +236,8 @@ export default function WorkInstructionApplyModal({ qty: String(i.qty), remark: i.remark || "", sourceTable: i.sourceTable || "cutting_plan", sourceId: i.sourceId || (cuttingPlanId != null ? String(cuttingPlanId) : ""), - routing: null, + // 사용자가 선택한 라우팅 버전. 빈값이면 백엔드 save 가 기본 버전을 자동 resolve(104). + routing: i.routing || null, startDate: i.startDate || "", endDate: i.endDate || "", equipmentIds: (i.equipmentIds || []).join(","), @@ -295,7 +314,7 @@ export default function WorkInstructionApplyModal({

품목 목록

- +
순번 @@ -304,6 +323,7 @@ export default function WorkInstructionApplyModal({ 품목명 규격 수량 + 라우팅 시작일 완료예정일 설비 @@ -328,6 +348,24 @@ export default function WorkInstructionApplyModal({ value={item.qty} onChange={(e) => setItems((prev) => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /> + + {/* 작업지시 등록창과 동일하게 품목별 라우팅 버전 직접 선택. 옵션 0건이면 비활성. */} + {(item.routingOptions || []).length > 0 ? ( + + setItems((prev) => prev.map((it, i) => i === idx ? { ...it, routing: v } : it))} + options={(item.routingOptions || []).map((rv) => ({ + code: rv.id, + label: `${rv.version_name || "라우팅"}${rv.is_default ? " (기본)" : ""} - ${rv.processes.length}공정`, + }))} + placeholder="라우팅 선택" + className="h-7 text-[13px]" + /> + ) : ( + 라우팅 미등록 + )} + - + 품목이 없습니다 diff --git a/frontend/app/(main)/COMPANY_9/production/cutting-plan/page.tsx b/frontend/app/(main)/COMPANY_9/production/cutting-plan/page.tsx index 5d84fefc..620398c1 100644 --- a/frontend/app/(main)/COMPANY_9/production/cutting-plan/page.tsx +++ b/frontend/app/(main)/COMPANY_9/production/cutting-plan/page.tsx @@ -24,6 +24,7 @@ import { import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from "@/components/ui/dialog"; +import { SmartSelect } from "@/components/common/SmartSelect"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; import { apiClient } from "@/lib/api/client"; @@ -1454,17 +1455,19 @@ export default function CuttingPlanPage() { {/* 원자재 선택 */}
- + {materials.length === 0 ? ( +
+ 등록된 원자재가 없습니다 +
+ ) : ( + ({ code: String(m.id), label: m.name }))} + placeholder="-- 원자재 선택 --" + className="h-7 w-[220px] text-xs flex-none" + /> + )} {mat1 && (
@@ -1480,16 +1483,19 @@ export default function CuttingPlanPage() { {showMat2 ? ( <> - + {materials.length === 0 ? ( +
+ 등록된 원자재가 없습니다 +
+ ) : ( + ({ code: String(m.id), label: m.name }))} + placeholder="-- 선택 --" + className="h-7 w-[180px] text-[11px] flex-none border-violet-300" + /> + )} {mat2 && (
diff --git a/frontend/app/(main)/COMPANY_9/production/work-instruction/page.tsx b/frontend/app/(main)/COMPANY_9/production/work-instruction/page.tsx index 7cb6ab2e..b6c8c667 100644 --- a/frontend/app/(main)/COMPANY_9/production/work-instruction/page.tsx +++ b/frontend/app/(main)/COMPANY_9/production/work-instruction/page.tsx @@ -800,21 +800,21 @@ export default function WorkInstructionPage() {

품목 목록

-
+
순번 - 품목코드 - 품목명 - 규격 - 수량 - 라우팅 - 시작일 - 완료예정일 - 설비 - 작업조 - 작업자 - 비고 + 품목코드 + 품목명 + 규격 + 수량 + 라우팅 + 시작일 + 완료예정일 + 설비 + 작업조 + 작업자 + 비고 @@ -823,9 +823,9 @@ export default function WorkInstructionPage() { {idx + 1} {item.itemCode} - {item.itemName || item.itemCode} + {item.itemName || item.itemCode} {item.spec || "-"} - setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /> + setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /> - setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, startDate: e.target.value } : it))} /> + setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, startDate: e.target.value } : it))} /> - setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, endDate: e.target.value } : it))} /> + setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, endDate: e.target.value } : it))} /> - setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /> + setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /> ))} @@ -924,25 +924,25 @@ export default function WorkInstructionPage() { {editItems.length}건
-
+
순번 {editOrder?.batch_no ? ( 배치번호 ) : null} - 품목코드 - 품목명 - 규격 - 수량 - 라우팅 - 공정작업기준 - 시작일 - 완료예정일 - 설비 - 작업조 - 작업자 - 비고 + 품목코드 + 품목명 + 규격 + 수량 + 라우팅 + 공정작업기준 + 시작일 + 완료예정일 + 설비 + 작업조 + 작업자 + 비고 @@ -956,9 +956,9 @@ export default function WorkInstructionPage() { {editOrder.batch_no} ) : null} {item.itemCode} - {item.itemName || "-"} + {item.itemName || "-"}{item.spec || "-"} - setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /> + setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /> setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, startDate: e.target.value } : it))} /> + setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, startDate: e.target.value } : it))} /> - setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, endDate: e.target.value } : it))} /> + setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, endDate: e.target.value } : it))} /> - setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /> + setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /> ))} diff --git a/frontend/app/(main)/COMPANY_9/sales/order/page.tsx b/frontend/app/(main)/COMPANY_9/sales/order/page.tsx index 7513b377..152a82b4 100644 --- a/frontend/app/(main)/COMPANY_9/sales/order/page.tsx +++ b/frontend/app/(main)/COMPANY_9/sales/order/page.tsx @@ -429,7 +429,9 @@ export default function JeilGlassOrderPage() { if (!previewOrderNo) { previewOrderNo = `ORD-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${String(Date.now()).slice(-4)}`; } - setMasterForm({ order_no: previewOrderNo, manager_id: user?.userId || "" }); + // status는 화면 Select 기본 표시값("수주")과 실제 저장값을 일치시킨다. + // 비우면 백엔드가 'WAITING'으로 보정 → 출하계획 일괄 등록 가드에 차단됨. + setMasterForm({ order_no: previewOrderNo, manager_id: user?.userId || "", status: "수주" }); setModalDetailRows([]); setSelectedDestId(""); setDestinations([]); @@ -1231,22 +1233,22 @@ export default function JeilGlassOrderPage() { - No - 구분 - 품명 - 규격 - 가로 - 세로 - 두께 - 면적(㎡) - 단위 - 포장재 - 수량 - 포장수량 - 단가 - 금액 - 납기일 - 납품장소 + No + 구분 + 품명 + 규격 + 가로 + 세로 + 두께 + 면적(㎡) + 단위 + 포장재 + 수량 + 포장수량 + 단가 + 금액 + 납기일 + 납품장소 @@ -1293,7 +1295,7 @@ export default function JeilGlassOrderPage() { {row.part_name || "-"} ) : ( updateDetailRow(idx, "part_name", e.target.value)} - className="h-8 text-sm" placeholder="품명" /> + className="h-9 text-sm w-full" placeholder="품명" /> )} {/* 규격: 품목검색 → 읽기전용, 행추가 → 입력 */} @@ -1302,20 +1304,20 @@ export default function JeilGlassOrderPage() { {row.spec || "-"} ) : ( updateDetailRow(idx, "spec", e.target.value)} - className="h-8 text-sm" placeholder="규격" /> + className="h-9 text-sm w-full" placeholder="규격" /> )} updateDetailRow(idx, "width", parseNumber(e.target.value))} - className="h-8 text-sm text-right" placeholder="mm" /> + className="h-9 text-sm text-right w-full" placeholder="mm" /> updateDetailRow(idx, "height", parseNumber(e.target.value))} - className="h-8 text-sm text-right" placeholder="mm" /> + className="h-9 text-sm text-right w-full" placeholder="mm" /> updateDetailRow(idx, "thickness", e.target.value)} - className="h-8 text-sm text-right" placeholder="mm" /> + className="h-9 text-sm text-right w-full" placeholder="mm" /> {row.area || "-"} @@ -1326,14 +1328,14 @@ export default function JeilGlassOrderPage() { {row.unit || "-"} ) : ( updateDetailRow(idx, "unit", e.target.value)} - className="h-8 text-sm" placeholder="㎡" /> + className="h-9 text-sm w-full" placeholder="㎡" /> )} {/* 포장재: 등록된 옵션이 있으면 셀렉트, 없으면 안내 */} {(row.pkg_options && row.pkg_options.length > 0) ? ( updateDetailRow(idx, "qty", parseNumber(e.target.value))} - className="h-8 text-sm text-right" /> + className="h-9 text-sm text-right w-full" placeholder="수량" /> updateDetailRow(idx, "pack_count", e.target.value)} - className="h-8 text-sm text-right font-mono" disabled={!row.pkg_code} /> + className="h-9 text-sm text-right font-mono w-full" disabled={!row.pkg_code} /> updateDetailRow(idx, "unit_price", parseNumber(e.target.value))} - className="h-8 text-sm text-right" /> + className="h-9 text-sm text-right w-full" placeholder="단가" /> {row.amount ? Number(row.amount).toLocaleString() : ""} @@ -1366,7 +1368,7 @@ export default function JeilGlassOrderPage() { updateDetailRow(idx, "delivery_location", e.target.value)} - className="h-8 text-sm" placeholder="납품장소" /> + className="h-9 text-sm w-full" placeholder="납품장소" /> ))} diff --git a/frontend/lib/registry/components/v2-process-work-standard/components/DetailFormModal.tsx b/frontend/lib/registry/components/v2-process-work-standard/components/DetailFormModal.tsx index 525697d8..ace1a075 100644 --- a/frontend/lib/registry/components/v2-process-work-standard/components/DetailFormModal.tsx +++ b/frontend/lib/registry/components/v2-process-work-standard/components/DetailFormModal.tsx @@ -147,6 +147,8 @@ export function DetailFormModal({ const [equipInspChecked, setEquipInspChecked] = useState>(new Set()); // 공정에 지정된 설비 건수 — 0건(설비 미지정) vs 점검항목 0건 메시지 구분용 — TASK:ERP-102 const [equipProcEquipCount, setEquipProcEquipCount] = useState(0); + // 설비 점검항목의 점검방법 카테고리 옵션 (코드→라벨 변환용) — TASK:ERP-102 후속 + const [equipMethodOptions, setEquipMethodOptions] = useState([]); // 공정 설비 목록 (자재투입 자재별 설비 연결용) — TASK:ERP-022 const [processEquipments, setProcessEquipments] = useState([]); @@ -435,6 +437,34 @@ export function DetailFormModal({ })(); }, [open, formData.detail_type, formData.equip_inspection_apply, selectedProcessCode]); + // 설비 점검항목의 점검방법 카테고리 옵션 로드 — TASK:ERP-102 후속 + // equipment_inspection_item.inspection_method 가 카테고리 코드(CAT_...)로 저장됨 → 라벨 변환용 + useEffect(() => { + if (!open || formData.detail_type !== "equip_inspection") return; + if (equipMethodOptions.length) return; + const flatten = (arr: any[]): CatOption[] => { + const out: CatOption[] = []; + const walk = (list: any[]) => { + for (const v of list || []) { + out.push({ code: v.valueCode, label: v.valueLabel }); + if (v.children?.length) walk(v.children); + } + }; + walk(arr || []); + return out; + }; + (async () => { + try { + const res = await apiClient.get( + "/table-categories/equipment_inspection_item/inspection_method/values" + ); + if (res.data?.data?.length) setEquipMethodOptions(flatten(res.data.data)); + } catch { + /* 카테고리 미정의 시 빈 옵션 — 코드 fallback 으로 표시 */ + } + })(); + }, [open, formData.detail_type, equipMethodOptions.length]); + // 검사항목 미적용(수동입력) 경로 셀렉트용 카테고리 옵션 로드 — TASK:ERP-061 // 품목검사정보 화면(quality/item-inspection)과 동일 출처(inspection_standard) 사용 useEffect(() => { @@ -1262,7 +1292,8 @@ export function DetailFormModal({ - + {/* 점검방법은 카테고리 코드(CAT_...)로 저장 → 라벨 변환, 매핑 없으면 코드 fallback — TASK:ERP-102 후속 */} + );
{item.equipment_name || item.equipment_code || "-"} {item.inspection_item || "-"}{item.inspection_method || "-"}{resolveCat(equipMethodOptions, item.inspection_method) || "-"} {item.lower_limit || item.upper_limit ? `${item.lower_limit || ""} ~ ${item.upper_limit || ""}${item.unit ? ` ${item.unit}` : ""}` : "-"}