diff --git a/backend-node/src/controllers/processInfoController.ts b/backend-node/src/controllers/processInfoController.ts index f3213773..f57a6613 100644 --- a/backend-node/src/controllers/processInfoController.ts +++ b/backend-node/src/controllers/processInfoController.ts @@ -382,7 +382,31 @@ export async function getRoutingDetails(req: AuthenticatedRequest, res: Response [versionId, companyCode] ); - return res.json({ success: true, data: result.rows }); + const rows = result.rows; + const detailIds = rows.map((r: any) => r.id).filter(Boolean); + let mappingByDetail: Record = {}; + if (detailIds.length > 0) { + const mapRes = await pool.query( + `SELECT routing_detail_id, subcontractor_code + FROM item_routing_subcontractor + WHERE routing_detail_id = ANY($1::uuid[]) + ORDER BY seq_order`, + [detailIds] + ); + for (const m of mapRes.rows) { + const key = String(m.routing_detail_id); + if (!mappingByDetail[key]) mappingByDetail[key] = []; + mappingByDetail[key].push(m.subcontractor_code); + } + } + const enriched = rows.map((r: any) => { + const list = mappingByDetail[String(r.id)] || []; + // 레거시 폴백: 매핑이 비어있고 legacy 단일 컬럼에 값이 있으면 배열로 포장 + if (list.length === 0 && r.outsource_supplier) list.push(r.outsource_supplier); + return { ...r, outsource_supplier_list: list }; + }); + + return res.json({ success: true, data: enriched }); } catch (error: any) { logger.error("라우팅 상세 조회 실패", { error: error.message }); return res.status(500).json({ success: false, message: error.message }); @@ -400,6 +424,15 @@ export async function saveRoutingDetails(req: AuthenticatedRequest, res: Respons try { await client.query("BEGIN"); + // 기존 상세의 외주업체 매핑을 먼저 제거 + await client.query( + `DELETE FROM item_routing_subcontractor + WHERE routing_detail_id IN ( + SELECT id FROM item_routing_detail WHERE routing_version_id=$1 AND company_code=$2 + )`, + [versionId, companyCode] + ); + // 기존 상세 삭제 후 재입력 await client.query( `DELETE FROM item_routing_detail WHERE routing_version_id=$1 AND company_code=$2`, @@ -407,11 +440,26 @@ export async function saveRoutingDetails(req: AuthenticatedRequest, res: Respons ); for (const d of details) { - await client.query( + const suppliers: string[] = Array.isArray(d.outsource_supplier_list) + ? d.outsource_supplier_list.filter((s: any) => typeof s === "string" && s.trim() !== "") + : (d.outsource_supplier ? [d.outsource_supplier] : []); + const primaryLegacy = suppliers[0] || d.outsource_supplier || ""; + + const insertRes = await client.query( `INSERT INTO item_routing_detail (id, company_code, routing_version_id, seq_no, process_code, is_required, is_fixed_order, work_type, standard_time, outsource_supplier, writer) - VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, - [companyCode, versionId, d.seq_no, d.process_code, d.is_required || "Y", d.is_fixed_order || "Y", d.work_type || "내부", d.standard_time || "0", d.outsource_supplier || "", writer] + VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING id`, + [companyCode, versionId, d.seq_no, d.process_code, d.is_required || "Y", d.is_fixed_order || "Y", d.work_type || "내부", d.standard_time || "0", primaryLegacy, writer] ); + const newDetailId = insertRes.rows[0].id; + + for (let i = 0; i < suppliers.length; i++) { + await client.query( + `INSERT INTO item_routing_subcontractor (id, company_code, routing_detail_id, subcontractor_code, seq_order) + VALUES (gen_random_uuid(), $1, $2, $3, $4)`, + [companyCode, newDetailId, suppliers[i], i] + ); + } } await client.query("COMMIT"); diff --git a/backend-node/src/services/bomService.ts b/backend-node/src/services/bomService.ts index 4178dc92..b9c349ce 100644 --- a/backend-node/src/services/bomService.ts +++ b/backend-node/src/services/bomService.ts @@ -60,8 +60,9 @@ export async function getBomHeader(bomId: string, tableName?: string) { const sql = ` SELECT b.*, i.item_name, i.item_number, i.division as item_type, - COALESCE(b.unit, i.unit) as unit, + COALESCE(NULLIF(b.unit, ''), NULLIF(i.unit, ''), NULLIF(i.inventory_unit, '')) as unit, i.unit as item_unit, + i.inventory_unit as item_inventory_unit, i.division, i.size, i.material FROM ${table} b LEFT JOIN item_info i ON b.item_id = i.id diff --git a/backend-node/src/services/productionPlanService.ts b/backend-node/src/services/productionPlanService.ts index 27f30522..8959d0ad 100644 --- a/backend-node/src/services/productionPlanService.ts +++ b/backend-node/src/services/productionPlanService.ts @@ -694,13 +694,16 @@ export async function mergeSchedules( [companyCode, ...scheduleIds] ); - // 병합된 스케줄 생성 + // 병합된 스케줄 생성 (PP-YYYYMMDD-NNNN 형식) + const todayStr = new Date().toISOString().split("T")[0].replace(/-/g, ""); const planNoResult = await client.query( - `SELECT COALESCE(MAX(CAST(REPLACE(plan_no, 'PP-', '') AS INTEGER)), 0) + 1 AS next_no - FROM production_plan_mng WHERE company_code = $1`, - [companyCode] + `SELECT COUNT(*) + 1 AS next_no + FROM production_plan_mng + WHERE company_code = $1 AND plan_no LIKE $2`, + [companyCode, `PP-${todayStr}-%`] ); - const planNo = `PP-${String(planNoResult.rows[0].next_no || 1).padStart(6, "0")}`; + const nextNo = parseInt(planNoResult.rows[0].next_no, 10) || 1; + const planNo = `PP-${todayStr}-${String(nextNo).padStart(4, "0")}`; const insertResult = await client.query( `INSERT INTO production_plan_mng ( @@ -1017,13 +1020,16 @@ export async function splitSchedule( [originalQty - splitQty, splitBy, planId, companyCode] ); - // 분할된 새 계획 생성 + // 분할된 새 계획 생성 (PP-YYYYMMDD-NNNN 형식) + const todayStr = new Date().toISOString().split("T")[0].replace(/-/g, ""); const planNoResult = await client.query( - `SELECT COALESCE(MAX(CAST(REPLACE(plan_no, 'PP-', '') AS INTEGER)), 0) + 1 AS next_no - FROM production_plan_mng WHERE company_code = $1`, - [companyCode] + `SELECT COUNT(*) + 1 AS next_no + FROM production_plan_mng + WHERE company_code = $1 AND plan_no LIKE $2`, + [companyCode, `PP-${todayStr}-%`] ); - const planNo = `PP-${String(planNoResult.rows[0].next_no || 1).padStart(6, "0")}`; + const nextNo = parseInt(planNoResult.rows[0].next_no, 10) || 1; + const planNo = `PP-${todayStr}-${String(nextNo).padStart(4, "0")}`; const insertResult = await client.query( `INSERT INTO production_plan_mng ( diff --git a/frontend/app/(main)/COMPANY_10/logistics/warehouse/page.tsx b/frontend/app/(main)/COMPANY_10/logistics/warehouse/page.tsx index 2a194939..6c7b71b9 100644 --- a/frontend/app/(main)/COMPANY_10/logistics/warehouse/page.tsx +++ b/frontend/app/(main)/COMPANY_10/logistics/warehouse/page.tsx @@ -74,7 +74,7 @@ const WAREHOUSE_COLUMNS = [ { key: "warehouse_code", label: "창고코드" }, { key: "warehouse_name", label: "창고명" }, { key: "warehouse_type", label: "유형" }, - { key: "manager", label: "관리자" }, + { key: "manager_name", label: "관리자" }, { key: "status", label: "상태" }, ]; const LOCATION_TABLE = "warehouse_location"; @@ -239,6 +239,8 @@ export default function WarehouseManagementPage() { const raw = res.data?.data?.data || res.data?.data?.rows || []; const data = raw.map((r: any) => ({ ...r, + _warehouse_type_code: r.warehouse_type, + _status_code: r.status, warehouse_type: resolveCategory(categoryOptions, "warehouse_type", r.warehouse_type), status: resolveCategory(categoryOptions, "status", r.status), })); @@ -344,7 +346,11 @@ export default function WarehouseManagementPage() { const openWarehouseEditModal = (row: any) => { setWarehouseEditMode(true); - setWarehouseForm({ ...row }); + setWarehouseForm({ + ...row, + warehouse_type: row._warehouse_type_code ?? row.warehouse_type ?? "", + status: row._status_code ?? row.status ?? "", + }); setWarehouseModalOpen(true); }; @@ -374,10 +380,10 @@ export default function WarehouseManagementPage() { warehouse_code: finalWarehouseCode, warehouse_name: warehouseForm.warehouse_name?.trim(), warehouse_type: warehouseForm.warehouse_type || "", - manager: warehouseForm.manager || "", - address: warehouseForm.address || "", + manager_name: warehouseForm.manager_name || "", + contact: warehouseForm.contact || "", status: warehouseForm.status || "", - description: warehouseForm.description || "", + memo: warehouseForm.memo || "", }; // 신규 등록 시 창고코드 중복 체크 @@ -729,7 +735,7 @@ export default function WarehouseManagementPage() { 창고코드: r.warehouse_code, 창고명: r.warehouse_name, 유형: r.warehouse_type, - 관리자: r.manager, + 관리자: r.manager_name, 상태: r.status, })), "창고정보" @@ -1041,9 +1047,9 @@ export default function WarehouseManagementPage() {
- setWarehouseForm((prev) => ({ ...prev, manager: e.target.value })) + setWarehouseForm((prev) => ({ ...prev, manager_name: e.target.value })) } placeholder="관리자를 입력해주세요" /> @@ -1069,24 +1075,24 @@ export default function WarehouseManagementPage() {
- {/* 주소 (전체 너비) */} + {/* 연락처 (전체 너비) */}
- + - setWarehouseForm((prev) => ({ ...prev, address: e.target.value })) + setWarehouseForm((prev) => ({ ...prev, contact: e.target.value })) } - placeholder="주소를 입력해주세요" + placeholder="연락처를 입력해주세요" />
{/* 비고 (전체 너비) */}
- setWarehouseForm((prev) => ({ ...prev, description: e.target.value })) + setWarehouseForm((prev) => ({ ...prev, memo: e.target.value })) } placeholder="비고를 입력해주세요" /> diff --git a/frontend/app/(main)/COMPANY_10/production/plan-management/page.tsx b/frontend/app/(main)/COMPANY_10/production/plan-management/page.tsx index c1ee134d..6eae857e 100644 --- a/frontend/app/(main)/COMPANY_10/production/plan-management/page.tsx +++ b/frontend/app/(main)/COMPANY_10/production/plan-management/page.tsx @@ -185,9 +185,6 @@ export default function ProductionPlanManagementPage() { const [modalQuantity, setModalQuantity] = useState(0); const [modalStartDate, setModalStartDate] = useState(""); const [modalEndDate, setModalEndDate] = useState(""); - const [modalManager, setModalManager] = useState(""); - const [modalWorkOrderNo, setModalWorkOrderNo] = useState(""); - const [modalRemarks, setModalRemarks] = useState(""); const [modalEquipmentId, setModalEquipmentId] = useState(""); // 미리보기 데이터 @@ -200,7 +197,10 @@ export default function ProductionPlanManagementPage() { const [selectedPlanIds, setSelectedPlanIds] = useState>(new Set()); // useConfirmDialog - const { confirm, ConfirmDialogComponent } = useConfirmDialog(); + const { confirm, ConfirmDialogComponent, isConfirmOpenRef } = useConfirmDialog(); + + // 수량 지정 분할 입력값 + const [customSplitQty, setCustomSplitQty] = useState(""); // ========== 데이터 로드 ========== @@ -694,10 +694,8 @@ export default function ProductionPlanManagementPage() { setModalQuantity(Number(plan.plan_qty)); setModalStartDate(plan.start_date?.split("T")[0] || ""); setModalEndDate(plan.end_date?.split("T")[0] || ""); - setModalManager((plan as any).manager_name || ""); - setModalWorkOrderNo((plan as any).work_order_no || ""); - setModalRemarks(plan.remarks || ""); setModalEquipmentId((plan as any).equipment_code || (plan.equipment_id ? String(plan.equipment_id) : "")); + setCustomSplitQty(""); setScheduleModalOpen(true); }, []); @@ -709,9 +707,6 @@ export default function ProductionPlanManagementPage() { plan_qty: modalQuantity, start_date: modalStartDate, end_date: modalEndDate, - manager_name: modalManager, - work_order_no: modalWorkOrderNo, - remarks: modalRemarks, equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null, equipment_name: modalEquipmentId && modalEquipmentId !== "none" ? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null @@ -721,13 +716,14 @@ export default function ProductionPlanManagementPage() { toast.success("생산계획이 수정되었습니다"); setScheduleModalOpen(false); fetchPlans(); + fetchOrderSummary(); } } catch (err: any) { - toast.error("수정 실패: " + (err.message || "")); + toast.error("수정 실패: " + (err?.response?.data?.message || err.message || "")); } finally { setSaving(false); } - }, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalManager, modalWorkOrderNo, modalRemarks, modalEquipmentId, fetchPlans]); + }, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId, equipmentList, fetchPlans, fetchOrderSummary]); const handleDeletePlan = useCallback(async () => { if (!selectedPlan) return; @@ -741,24 +737,158 @@ export default function ProductionPlanManagementPage() { toast.success("삭제되었습니다"); setScheduleModalOpen(false); fetchPlans(); + fetchOrderSummary(); } catch (err: any) { - toast.error("삭제 실패: " + (err.message || "")); + toast.error("삭제 실패: " + (err?.response?.data?.message || err.message || "")); } - }, [selectedPlan, fetchPlans, confirm]); + }, [selectedPlan, fetchPlans, fetchOrderSummary, confirm]); + + // 에러 메시지 추출 헬퍼 + const extractErrMsg = (err: any): string => { + return err?.response?.data?.message || err?.message || ""; + }; + + // modalQuantity/일정/설비가 DB의 selectedPlan 값과 다른지 확인 (dirty 체크) + const isModalDirty = useCallback((): boolean => { + if (!selectedPlan) return false; + const planQty = Number(selectedPlan.plan_qty) || 0; + const planStart = selectedPlan.start_date?.split("T")[0] || ""; + const planEnd = selectedPlan.end_date?.split("T")[0] || ""; + const planEq = (selectedPlan as any).equipment_code || (selectedPlan.equipment_id ? String(selectedPlan.equipment_id) : ""); + return ( + planQty !== Number(modalQuantity) || + planStart !== modalStartDate || + planEnd !== modalEndDate || + planEq !== modalEquipmentId + ); + }, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId]); + + // dirty 상태면 자동 저장 후 selectedPlan 을 최신 값으로 갱신 + const ensureSavedBeforeSplit = useCallback(async (): Promise => { + if (!selectedPlan) return false; + if (!isModalDirty()) return true; + try { + const res = await updatePlan(selectedPlan.id, { + plan_qty: modalQuantity, + start_date: modalStartDate, + end_date: modalEndDate, + equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null, + equipment_name: modalEquipmentId && modalEquipmentId !== "none" + ? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null + : null, + } as any); + if (!res.success) { + toast.error("저장 실패로 분할이 중단되었습니다"); + return false; + } + // selectedPlan 을 최신 값으로 동기화 (이후 로직에서 plan_qty 를 참조) + setSelectedPlan((prev) => prev ? ({ + ...prev, + plan_qty: modalQuantity, + start_date: modalStartDate, + end_date: modalEndDate, + equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null, + } as any) : prev); + return true; + } catch (err: any) { + toast.error("저장 실패로 분할이 중단되었습니다: " + extractErrMsg(err)); + return false; + } + }, [selectedPlan, isModalDirty, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId, equipmentList]); + + // 균등 분할 (2/3/4분할 버튼) + const handleSplitSchedule = useCallback(async (splitCount: number) => { + if (!selectedPlan || splitCount < 2) return; + // 모달 입력값 기준 (이후 자동 저장되므로 modalQuantity 가 진실) + const originalQty = Number(modalQuantity) || 0; + if (originalQty < splitCount) { + toast.error(`${splitCount}분할하려면 수량이 ${splitCount} 이상이어야 합니다`); + return; + } + if (selectedPlan.status && selectedPlan.status !== "planned") { + toast.error("계획 상태인 건만 분할할 수 있습니다"); + return; + } + const ok = await confirm(`이 계획을 ${splitCount}개로 균등 분할하시겠습니까?`, { + description: `수량 ${originalQty}이(가) ${splitCount}개로 나뉩니다.`, + confirmText: "분할", + }); + if (!ok) return; + + // dirty 면 자동 저장 + const saved = await ensureSavedBeforeSplit(); + if (!saved) return; + + const eachQty = Math.floor(originalQty / splitCount); + if (eachQty <= 0) { + toast.error("분할 수량이 부족합니다"); + return; + } + + let successCount = 0; + try { + // N-1회 호출: 매번 eachQty만큼 원본에서 떼어내 새 plan 생성 + for (let i = 0; i < splitCount - 1; i++) { + const res = await splitSchedule(selectedPlan.id, eachQty); + if (!res.success) throw new Error("분할 응답 실패"); + successCount++; + } + toast.success(`계획이 ${splitCount}개로 분할되었습니다`); + setScheduleModalOpen(false); + fetchPlans(); + fetchOrderSummary(); + } catch (err: any) { + const msg = extractErrMsg(err); + if (successCount > 0) { + toast.error(`분할 일부 실패 (${successCount + 1}개 생성됨): ${msg}`); + } else { + toast.error("분할 실패: " + msg); + } + fetchPlans(); + fetchOrderSummary(); + } + }, [selectedPlan, modalQuantity, fetchPlans, fetchOrderSummary, confirm, ensureSavedBeforeSplit]); + + // 수량 지정 분할 (원본에서 입력 수량만큼 떼어내기) + const handleCustomSplit = useCallback(async () => { + if (!selectedPlan) return; + const splitQty = Number(customSplitQty); + const originalQty = Number(modalQuantity) || 0; + if (!splitQty || splitQty < 1) { + toast.error("떼어낼 수량을 1 이상으로 입력하세요"); + return; + } + if (splitQty >= originalQty) { + toast.error("떼어낼 수량은 원본 수량보다 작아야 합니다"); + return; + } + if (selectedPlan.status && selectedPlan.status !== "planned") { + toast.error("계획 상태인 건만 분할할 수 있습니다"); + return; + } + const ok = await confirm(`이 계획에서 ${splitQty}만큼 떼어내시겠습니까?`, { + description: `원본 ${originalQty} → 원본 ${originalQty - splitQty} + 신규 ${splitQty}`, + confirmText: "분할", + }); + if (!ok) return; + + const saved = await ensureSavedBeforeSplit(); + if (!saved) return; - const handleSplitSchedule = useCallback(async (splitQty: number) => { - if (!selectedPlan || splitQty <= 0) return; try { const res = await splitSchedule(selectedPlan.id, splitQty); - if (res.success) { - toast.success("계획이 분할되었습니다"); - setScheduleModalOpen(false); - fetchPlans(); - } + if (!res.success) throw new Error("분할 응답 실패"); + toast.success(`${splitQty} 수량이 분리되었습니다`); + setCustomSplitQty(""); + setScheduleModalOpen(false); + fetchPlans(); + fetchOrderSummary(); } catch (err: any) { - toast.error("분할 실패: " + (err.message || "")); + toast.error("분할 실패: " + extractErrMsg(err)); + fetchPlans(); + fetchOrderSummary(); } - }, [selectedPlan, fetchPlans]); + }, [selectedPlan, modalQuantity, customSplitQty, fetchPlans, fetchOrderSummary, confirm, ensureSavedBeforeSplit]); // 병합 핸들러 const handleMergeSchedules = useCallback(async () => { @@ -780,11 +910,12 @@ export default function ProductionPlanManagementPage() { toast.success("계획이 병합되었습니다"); setSelectedPlanIds(new Set()); fetchPlans(); + fetchOrderSummary(); } } catch (err: any) { - toast.error("병합 실패: " + (err.message || "")); + toast.error("병합 실패: " + (err?.response?.data?.message || err.message || "")); } - }, [selectedPlanIds, rightTab, fetchPlans, confirm]); + }, [selectedPlanIds, rightTab, fetchPlans, fetchOrderSummary, confirm]); // 타임라인 이벤트 드래그 이동 const handleEventMove = useCallback(async (eventId: string | number, newStart: string, newEnd: string) => { @@ -796,11 +927,12 @@ export default function ProductionPlanManagementPage() { if (res.success) { toast.success("일정이 변경되었습니다"); fetchPlans(); + fetchOrderSummary(); } } catch (err: any) { toast.error("일정 변경 실패: " + (err.message || "")); } - }, [fetchPlans]); + }, [fetchPlans, fetchOrderSummary]); // 타임라인 이벤트 리사이즈 const handleEventResize = useCallback(async (eventId: string | number, newStart: string, newEnd: string) => { @@ -812,11 +944,12 @@ export default function ProductionPlanManagementPage() { if (res.success) { toast.success("기간이 변경되었습니다"); fetchPlans(); + fetchOrderSummary(); } } catch (err: any) { toast.error("기간 변경 실패: " + (err.message || "")); } - }, [fetchPlans]); + }, [fetchPlans, fetchOrderSummary]); // 불러오기 처리 const handleImportOrderItems = useCallback(async () => { @@ -1463,8 +1596,26 @@ export default function ProductionPlanManagementPage() { {/* ========== 모달들 ========== */} {/* 스케줄 상세/편집 모달 */} - - + { + // confirm 다이얼로그가 열려 있는 동안 발생하는 닫힘 이벤트(포커스 이탈 등)는 무시 + if (!v && isConfirmOpenRef.current) return; + setScheduleModalOpen(v); + }} + > + { + if (isConfirmOpenRef.current) e.preventDefault(); + }} + onInteractOutside={(e) => { + if (isConfirmOpenRef.current) e.preventDefault(); + }} + onFocusOutside={(e) => { + if (isConfirmOpenRef.current) e.preventDefault(); + }} + > @@ -1554,37 +1705,67 @@ export default function ProductionPlanManagementPage() { 계획 분할

+
+ {[2, 3, 4].map((n) => { + const canSplit = + modalQuantity >= n && + (selectedPlan?.status === "planned" || !selectedPlan?.status); + return ( + + ); + })} +
+
+

+ 하나의 생산계획을 선택한 개수만큼 균등 분할합니다. (수량 부족 또는 완료 상태는 불가) +

+ {/* 수량 지정 분할 */} +
+ + { + const v = e.target.value; + if (v === "") setCustomSplitQty(""); + else setCustomSplitQty(Math.max(0, Math.floor(Number(v) || 0))); + }} + className="h-7 w-28 text-xs" + placeholder="떼어낼 수량" + min={1} + max={Math.max(0, modalQuantity - 1)} + step={1} + /> + + / {modalQuantity} +
-

하나의 생산계획을 여러 개로 분할합니다.

- - -
-

추가 정보

-
-
- - setModalManager(e.target.value)} className="h-9 text-xs" placeholder="담당자명" /> -
-
- - setModalWorkOrderNo(e.target.value)} className="h-9 text-xs" placeholder="자동생성" /> -
-
- - setModalRemarks(e.target.value)} className="h-9 text-xs" placeholder="비고사항 입력" /> -
-
+

+ 입력한 수량만큼 떼어내 새 계획을 생성합니다. (1 이상, 원본 수량 미만) +

)} diff --git a/frontend/app/(main)/COMPANY_10/quality/item-inspection/page.tsx b/frontend/app/(main)/COMPANY_10/quality/item-inspection/page.tsx index 15fe6741..15118ffc 100644 --- a/frontend/app/(main)/COMPANY_10/quality/item-inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_10/quality/item-inspection/page.tsx @@ -977,12 +977,13 @@ export default function ItemInspectionInfoPage() { 판단기준 합격기준 필수 + 단위 {selectedTabRows.length === 0 ? ( - 등록된 검사항목이 없어요 + 등록된 검사항목이 없어요 ) : selectedTabRows.map((row: any) => ( @@ -1015,6 +1016,14 @@ export default function ItemInspectionInfoPage() { 필수 ) : "-"} + + {(() => { + const insp = inspOptions.find(o => o.code === row.inspection_standard_id); + const unitCode = insp?.unit || ""; + const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode; + return unitLabel || "-"; + })()} + ))} @@ -1179,12 +1188,13 @@ export default function ItemInspectionInfoPage() { 판단기준 합격기준 (판단기준별) 필수 + 단위 {(!inspectionRows[key] || inspectionRows[key].length === 0) ? ( - 항목추가 버튼으로 검사항목을 추가하세요 + 항목추가 버튼으로 검사항목을 추가하세요 ) : inspectionRows[key].map((row) => ( @@ -1250,6 +1260,7 @@ export default function ItemInspectionInfoPage() { )} updateInspRow(key, row.id, "is_required", !!v)} /> + {row.unit || "-"} diff --git a/frontend/app/(main)/COMPANY_16/logistics/warehouse/page.tsx b/frontend/app/(main)/COMPANY_16/logistics/warehouse/page.tsx index 2a194939..6c7b71b9 100644 --- a/frontend/app/(main)/COMPANY_16/logistics/warehouse/page.tsx +++ b/frontend/app/(main)/COMPANY_16/logistics/warehouse/page.tsx @@ -74,7 +74,7 @@ const WAREHOUSE_COLUMNS = [ { key: "warehouse_code", label: "창고코드" }, { key: "warehouse_name", label: "창고명" }, { key: "warehouse_type", label: "유형" }, - { key: "manager", label: "관리자" }, + { key: "manager_name", label: "관리자" }, { key: "status", label: "상태" }, ]; const LOCATION_TABLE = "warehouse_location"; @@ -239,6 +239,8 @@ export default function WarehouseManagementPage() { const raw = res.data?.data?.data || res.data?.data?.rows || []; const data = raw.map((r: any) => ({ ...r, + _warehouse_type_code: r.warehouse_type, + _status_code: r.status, warehouse_type: resolveCategory(categoryOptions, "warehouse_type", r.warehouse_type), status: resolveCategory(categoryOptions, "status", r.status), })); @@ -344,7 +346,11 @@ export default function WarehouseManagementPage() { const openWarehouseEditModal = (row: any) => { setWarehouseEditMode(true); - setWarehouseForm({ ...row }); + setWarehouseForm({ + ...row, + warehouse_type: row._warehouse_type_code ?? row.warehouse_type ?? "", + status: row._status_code ?? row.status ?? "", + }); setWarehouseModalOpen(true); }; @@ -374,10 +380,10 @@ export default function WarehouseManagementPage() { warehouse_code: finalWarehouseCode, warehouse_name: warehouseForm.warehouse_name?.trim(), warehouse_type: warehouseForm.warehouse_type || "", - manager: warehouseForm.manager || "", - address: warehouseForm.address || "", + manager_name: warehouseForm.manager_name || "", + contact: warehouseForm.contact || "", status: warehouseForm.status || "", - description: warehouseForm.description || "", + memo: warehouseForm.memo || "", }; // 신규 등록 시 창고코드 중복 체크 @@ -729,7 +735,7 @@ export default function WarehouseManagementPage() { 창고코드: r.warehouse_code, 창고명: r.warehouse_name, 유형: r.warehouse_type, - 관리자: r.manager, + 관리자: r.manager_name, 상태: r.status, })), "창고정보" @@ -1041,9 +1047,9 @@ export default function WarehouseManagementPage() {
- setWarehouseForm((prev) => ({ ...prev, manager: e.target.value })) + setWarehouseForm((prev) => ({ ...prev, manager_name: e.target.value })) } placeholder="관리자를 입력해주세요" /> @@ -1069,24 +1075,24 @@ export default function WarehouseManagementPage() {
- {/* 주소 (전체 너비) */} + {/* 연락처 (전체 너비) */}
- + - setWarehouseForm((prev) => ({ ...prev, address: e.target.value })) + setWarehouseForm((prev) => ({ ...prev, contact: e.target.value })) } - placeholder="주소를 입력해주세요" + placeholder="연락처를 입력해주세요" />
{/* 비고 (전체 너비) */}
- setWarehouseForm((prev) => ({ ...prev, description: e.target.value })) + setWarehouseForm((prev) => ({ ...prev, memo: e.target.value })) } placeholder="비고를 입력해주세요" /> diff --git a/frontend/app/(main)/COMPANY_16/production/plan-management/page.tsx b/frontend/app/(main)/COMPANY_16/production/plan-management/page.tsx index c1ee134d..6eae857e 100644 --- a/frontend/app/(main)/COMPANY_16/production/plan-management/page.tsx +++ b/frontend/app/(main)/COMPANY_16/production/plan-management/page.tsx @@ -185,9 +185,6 @@ export default function ProductionPlanManagementPage() { const [modalQuantity, setModalQuantity] = useState(0); const [modalStartDate, setModalStartDate] = useState(""); const [modalEndDate, setModalEndDate] = useState(""); - const [modalManager, setModalManager] = useState(""); - const [modalWorkOrderNo, setModalWorkOrderNo] = useState(""); - const [modalRemarks, setModalRemarks] = useState(""); const [modalEquipmentId, setModalEquipmentId] = useState(""); // 미리보기 데이터 @@ -200,7 +197,10 @@ export default function ProductionPlanManagementPage() { const [selectedPlanIds, setSelectedPlanIds] = useState>(new Set()); // useConfirmDialog - const { confirm, ConfirmDialogComponent } = useConfirmDialog(); + const { confirm, ConfirmDialogComponent, isConfirmOpenRef } = useConfirmDialog(); + + // 수량 지정 분할 입력값 + const [customSplitQty, setCustomSplitQty] = useState(""); // ========== 데이터 로드 ========== @@ -694,10 +694,8 @@ export default function ProductionPlanManagementPage() { setModalQuantity(Number(plan.plan_qty)); setModalStartDate(plan.start_date?.split("T")[0] || ""); setModalEndDate(plan.end_date?.split("T")[0] || ""); - setModalManager((plan as any).manager_name || ""); - setModalWorkOrderNo((plan as any).work_order_no || ""); - setModalRemarks(plan.remarks || ""); setModalEquipmentId((plan as any).equipment_code || (plan.equipment_id ? String(plan.equipment_id) : "")); + setCustomSplitQty(""); setScheduleModalOpen(true); }, []); @@ -709,9 +707,6 @@ export default function ProductionPlanManagementPage() { plan_qty: modalQuantity, start_date: modalStartDate, end_date: modalEndDate, - manager_name: modalManager, - work_order_no: modalWorkOrderNo, - remarks: modalRemarks, equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null, equipment_name: modalEquipmentId && modalEquipmentId !== "none" ? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null @@ -721,13 +716,14 @@ export default function ProductionPlanManagementPage() { toast.success("생산계획이 수정되었습니다"); setScheduleModalOpen(false); fetchPlans(); + fetchOrderSummary(); } } catch (err: any) { - toast.error("수정 실패: " + (err.message || "")); + toast.error("수정 실패: " + (err?.response?.data?.message || err.message || "")); } finally { setSaving(false); } - }, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalManager, modalWorkOrderNo, modalRemarks, modalEquipmentId, fetchPlans]); + }, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId, equipmentList, fetchPlans, fetchOrderSummary]); const handleDeletePlan = useCallback(async () => { if (!selectedPlan) return; @@ -741,24 +737,158 @@ export default function ProductionPlanManagementPage() { toast.success("삭제되었습니다"); setScheduleModalOpen(false); fetchPlans(); + fetchOrderSummary(); } catch (err: any) { - toast.error("삭제 실패: " + (err.message || "")); + toast.error("삭제 실패: " + (err?.response?.data?.message || err.message || "")); } - }, [selectedPlan, fetchPlans, confirm]); + }, [selectedPlan, fetchPlans, fetchOrderSummary, confirm]); + + // 에러 메시지 추출 헬퍼 + const extractErrMsg = (err: any): string => { + return err?.response?.data?.message || err?.message || ""; + }; + + // modalQuantity/일정/설비가 DB의 selectedPlan 값과 다른지 확인 (dirty 체크) + const isModalDirty = useCallback((): boolean => { + if (!selectedPlan) return false; + const planQty = Number(selectedPlan.plan_qty) || 0; + const planStart = selectedPlan.start_date?.split("T")[0] || ""; + const planEnd = selectedPlan.end_date?.split("T")[0] || ""; + const planEq = (selectedPlan as any).equipment_code || (selectedPlan.equipment_id ? String(selectedPlan.equipment_id) : ""); + return ( + planQty !== Number(modalQuantity) || + planStart !== modalStartDate || + planEnd !== modalEndDate || + planEq !== modalEquipmentId + ); + }, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId]); + + // dirty 상태면 자동 저장 후 selectedPlan 을 최신 값으로 갱신 + const ensureSavedBeforeSplit = useCallback(async (): Promise => { + if (!selectedPlan) return false; + if (!isModalDirty()) return true; + try { + const res = await updatePlan(selectedPlan.id, { + plan_qty: modalQuantity, + start_date: modalStartDate, + end_date: modalEndDate, + equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null, + equipment_name: modalEquipmentId && modalEquipmentId !== "none" + ? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null + : null, + } as any); + if (!res.success) { + toast.error("저장 실패로 분할이 중단되었습니다"); + return false; + } + // selectedPlan 을 최신 값으로 동기화 (이후 로직에서 plan_qty 를 참조) + setSelectedPlan((prev) => prev ? ({ + ...prev, + plan_qty: modalQuantity, + start_date: modalStartDate, + end_date: modalEndDate, + equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null, + } as any) : prev); + return true; + } catch (err: any) { + toast.error("저장 실패로 분할이 중단되었습니다: " + extractErrMsg(err)); + return false; + } + }, [selectedPlan, isModalDirty, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId, equipmentList]); + + // 균등 분할 (2/3/4분할 버튼) + const handleSplitSchedule = useCallback(async (splitCount: number) => { + if (!selectedPlan || splitCount < 2) return; + // 모달 입력값 기준 (이후 자동 저장되므로 modalQuantity 가 진실) + const originalQty = Number(modalQuantity) || 0; + if (originalQty < splitCount) { + toast.error(`${splitCount}분할하려면 수량이 ${splitCount} 이상이어야 합니다`); + return; + } + if (selectedPlan.status && selectedPlan.status !== "planned") { + toast.error("계획 상태인 건만 분할할 수 있습니다"); + return; + } + const ok = await confirm(`이 계획을 ${splitCount}개로 균등 분할하시겠습니까?`, { + description: `수량 ${originalQty}이(가) ${splitCount}개로 나뉩니다.`, + confirmText: "분할", + }); + if (!ok) return; + + // dirty 면 자동 저장 + const saved = await ensureSavedBeforeSplit(); + if (!saved) return; + + const eachQty = Math.floor(originalQty / splitCount); + if (eachQty <= 0) { + toast.error("분할 수량이 부족합니다"); + return; + } + + let successCount = 0; + try { + // N-1회 호출: 매번 eachQty만큼 원본에서 떼어내 새 plan 생성 + for (let i = 0; i < splitCount - 1; i++) { + const res = await splitSchedule(selectedPlan.id, eachQty); + if (!res.success) throw new Error("분할 응답 실패"); + successCount++; + } + toast.success(`계획이 ${splitCount}개로 분할되었습니다`); + setScheduleModalOpen(false); + fetchPlans(); + fetchOrderSummary(); + } catch (err: any) { + const msg = extractErrMsg(err); + if (successCount > 0) { + toast.error(`분할 일부 실패 (${successCount + 1}개 생성됨): ${msg}`); + } else { + toast.error("분할 실패: " + msg); + } + fetchPlans(); + fetchOrderSummary(); + } + }, [selectedPlan, modalQuantity, fetchPlans, fetchOrderSummary, confirm, ensureSavedBeforeSplit]); + + // 수량 지정 분할 (원본에서 입력 수량만큼 떼어내기) + const handleCustomSplit = useCallback(async () => { + if (!selectedPlan) return; + const splitQty = Number(customSplitQty); + const originalQty = Number(modalQuantity) || 0; + if (!splitQty || splitQty < 1) { + toast.error("떼어낼 수량을 1 이상으로 입력하세요"); + return; + } + if (splitQty >= originalQty) { + toast.error("떼어낼 수량은 원본 수량보다 작아야 합니다"); + return; + } + if (selectedPlan.status && selectedPlan.status !== "planned") { + toast.error("계획 상태인 건만 분할할 수 있습니다"); + return; + } + const ok = await confirm(`이 계획에서 ${splitQty}만큼 떼어내시겠습니까?`, { + description: `원본 ${originalQty} → 원본 ${originalQty - splitQty} + 신규 ${splitQty}`, + confirmText: "분할", + }); + if (!ok) return; + + const saved = await ensureSavedBeforeSplit(); + if (!saved) return; - const handleSplitSchedule = useCallback(async (splitQty: number) => { - if (!selectedPlan || splitQty <= 0) return; try { const res = await splitSchedule(selectedPlan.id, splitQty); - if (res.success) { - toast.success("계획이 분할되었습니다"); - setScheduleModalOpen(false); - fetchPlans(); - } + if (!res.success) throw new Error("분할 응답 실패"); + toast.success(`${splitQty} 수량이 분리되었습니다`); + setCustomSplitQty(""); + setScheduleModalOpen(false); + fetchPlans(); + fetchOrderSummary(); } catch (err: any) { - toast.error("분할 실패: " + (err.message || "")); + toast.error("분할 실패: " + extractErrMsg(err)); + fetchPlans(); + fetchOrderSummary(); } - }, [selectedPlan, fetchPlans]); + }, [selectedPlan, modalQuantity, customSplitQty, fetchPlans, fetchOrderSummary, confirm, ensureSavedBeforeSplit]); // 병합 핸들러 const handleMergeSchedules = useCallback(async () => { @@ -780,11 +910,12 @@ export default function ProductionPlanManagementPage() { toast.success("계획이 병합되었습니다"); setSelectedPlanIds(new Set()); fetchPlans(); + fetchOrderSummary(); } } catch (err: any) { - toast.error("병합 실패: " + (err.message || "")); + toast.error("병합 실패: " + (err?.response?.data?.message || err.message || "")); } - }, [selectedPlanIds, rightTab, fetchPlans, confirm]); + }, [selectedPlanIds, rightTab, fetchPlans, fetchOrderSummary, confirm]); // 타임라인 이벤트 드래그 이동 const handleEventMove = useCallback(async (eventId: string | number, newStart: string, newEnd: string) => { @@ -796,11 +927,12 @@ export default function ProductionPlanManagementPage() { if (res.success) { toast.success("일정이 변경되었습니다"); fetchPlans(); + fetchOrderSummary(); } } catch (err: any) { toast.error("일정 변경 실패: " + (err.message || "")); } - }, [fetchPlans]); + }, [fetchPlans, fetchOrderSummary]); // 타임라인 이벤트 리사이즈 const handleEventResize = useCallback(async (eventId: string | number, newStart: string, newEnd: string) => { @@ -812,11 +944,12 @@ export default function ProductionPlanManagementPage() { if (res.success) { toast.success("기간이 변경되었습니다"); fetchPlans(); + fetchOrderSummary(); } } catch (err: any) { toast.error("기간 변경 실패: " + (err.message || "")); } - }, [fetchPlans]); + }, [fetchPlans, fetchOrderSummary]); // 불러오기 처리 const handleImportOrderItems = useCallback(async () => { @@ -1463,8 +1596,26 @@ export default function ProductionPlanManagementPage() { {/* ========== 모달들 ========== */} {/* 스케줄 상세/편집 모달 */} - - + { + // confirm 다이얼로그가 열려 있는 동안 발생하는 닫힘 이벤트(포커스 이탈 등)는 무시 + if (!v && isConfirmOpenRef.current) return; + setScheduleModalOpen(v); + }} + > + { + if (isConfirmOpenRef.current) e.preventDefault(); + }} + onInteractOutside={(e) => { + if (isConfirmOpenRef.current) e.preventDefault(); + }} + onFocusOutside={(e) => { + if (isConfirmOpenRef.current) e.preventDefault(); + }} + > @@ -1554,37 +1705,67 @@ export default function ProductionPlanManagementPage() { 계획 분할

+
+ {[2, 3, 4].map((n) => { + const canSplit = + modalQuantity >= n && + (selectedPlan?.status === "planned" || !selectedPlan?.status); + return ( + + ); + })} +
+
+

+ 하나의 생산계획을 선택한 개수만큼 균등 분할합니다. (수량 부족 또는 완료 상태는 불가) +

+ {/* 수량 지정 분할 */} +
+ + { + const v = e.target.value; + if (v === "") setCustomSplitQty(""); + else setCustomSplitQty(Math.max(0, Math.floor(Number(v) || 0))); + }} + className="h-7 w-28 text-xs" + placeholder="떼어낼 수량" + min={1} + max={Math.max(0, modalQuantity - 1)} + step={1} + /> + + / {modalQuantity} +
-

하나의 생산계획을 여러 개로 분할합니다.

- - -
-

추가 정보

-
-
- - setModalManager(e.target.value)} className="h-9 text-xs" placeholder="담당자명" /> -
-
- - setModalWorkOrderNo(e.target.value)} className="h-9 text-xs" placeholder="자동생성" /> -
-
- - setModalRemarks(e.target.value)} className="h-9 text-xs" placeholder="비고사항 입력" /> -
-
+

+ 입력한 수량만큼 떼어내 새 계획을 생성합니다. (1 이상, 원본 수량 미만) +

)} diff --git a/frontend/app/(main)/COMPANY_16/quality/item-inspection/page.tsx b/frontend/app/(main)/COMPANY_16/quality/item-inspection/page.tsx index 15fe6741..15118ffc 100644 --- a/frontend/app/(main)/COMPANY_16/quality/item-inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_16/quality/item-inspection/page.tsx @@ -977,12 +977,13 @@ export default function ItemInspectionInfoPage() { 판단기준 합격기준 필수 + 단위
{selectedTabRows.length === 0 ? ( - 등록된 검사항목이 없어요 + 등록된 검사항목이 없어요 ) : selectedTabRows.map((row: any) => ( @@ -1015,6 +1016,14 @@ export default function ItemInspectionInfoPage() { 필수 ) : "-"} + + {(() => { + const insp = inspOptions.find(o => o.code === row.inspection_standard_id); + const unitCode = insp?.unit || ""; + const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode; + return unitLabel || "-"; + })()} + ))} @@ -1179,12 +1188,13 @@ export default function ItemInspectionInfoPage() { 판단기준 합격기준 (판단기준별) 필수 + 단위 {(!inspectionRows[key] || inspectionRows[key].length === 0) ? ( - 항목추가 버튼으로 검사항목을 추가하세요 + 항목추가 버튼으로 검사항목을 추가하세요 ) : inspectionRows[key].map((row) => ( @@ -1250,6 +1260,7 @@ export default function ItemInspectionInfoPage() { )} updateInspRow(key, row.id, "is_required", !!v)} /> + {row.unit || "-"} diff --git a/frontend/app/(main)/COMPANY_29/logistics/warehouse/page.tsx b/frontend/app/(main)/COMPANY_29/logistics/warehouse/page.tsx index 2a194939..6c7b71b9 100644 --- a/frontend/app/(main)/COMPANY_29/logistics/warehouse/page.tsx +++ b/frontend/app/(main)/COMPANY_29/logistics/warehouse/page.tsx @@ -74,7 +74,7 @@ const WAREHOUSE_COLUMNS = [ { key: "warehouse_code", label: "창고코드" }, { key: "warehouse_name", label: "창고명" }, { key: "warehouse_type", label: "유형" }, - { key: "manager", label: "관리자" }, + { key: "manager_name", label: "관리자" }, { key: "status", label: "상태" }, ]; const LOCATION_TABLE = "warehouse_location"; @@ -239,6 +239,8 @@ export default function WarehouseManagementPage() { const raw = res.data?.data?.data || res.data?.data?.rows || []; const data = raw.map((r: any) => ({ ...r, + _warehouse_type_code: r.warehouse_type, + _status_code: r.status, warehouse_type: resolveCategory(categoryOptions, "warehouse_type", r.warehouse_type), status: resolveCategory(categoryOptions, "status", r.status), })); @@ -344,7 +346,11 @@ export default function WarehouseManagementPage() { const openWarehouseEditModal = (row: any) => { setWarehouseEditMode(true); - setWarehouseForm({ ...row }); + setWarehouseForm({ + ...row, + warehouse_type: row._warehouse_type_code ?? row.warehouse_type ?? "", + status: row._status_code ?? row.status ?? "", + }); setWarehouseModalOpen(true); }; @@ -374,10 +380,10 @@ export default function WarehouseManagementPage() { warehouse_code: finalWarehouseCode, warehouse_name: warehouseForm.warehouse_name?.trim(), warehouse_type: warehouseForm.warehouse_type || "", - manager: warehouseForm.manager || "", - address: warehouseForm.address || "", + manager_name: warehouseForm.manager_name || "", + contact: warehouseForm.contact || "", status: warehouseForm.status || "", - description: warehouseForm.description || "", + memo: warehouseForm.memo || "", }; // 신규 등록 시 창고코드 중복 체크 @@ -729,7 +735,7 @@ export default function WarehouseManagementPage() { 창고코드: r.warehouse_code, 창고명: r.warehouse_name, 유형: r.warehouse_type, - 관리자: r.manager, + 관리자: r.manager_name, 상태: r.status, })), "창고정보" @@ -1041,9 +1047,9 @@ export default function WarehouseManagementPage() {
- setWarehouseForm((prev) => ({ ...prev, manager: e.target.value })) + setWarehouseForm((prev) => ({ ...prev, manager_name: e.target.value })) } placeholder="관리자를 입력해주세요" /> @@ -1069,24 +1075,24 @@ export default function WarehouseManagementPage() {
- {/* 주소 (전체 너비) */} + {/* 연락처 (전체 너비) */}
- + - setWarehouseForm((prev) => ({ ...prev, address: e.target.value })) + setWarehouseForm((prev) => ({ ...prev, contact: e.target.value })) } - placeholder="주소를 입력해주세요" + placeholder="연락처를 입력해주세요" />
{/* 비고 (전체 너비) */}
- setWarehouseForm((prev) => ({ ...prev, description: e.target.value })) + setWarehouseForm((prev) => ({ ...prev, memo: e.target.value })) } placeholder="비고를 입력해주세요" /> diff --git a/frontend/app/(main)/COMPANY_29/production/plan-management/page.tsx b/frontend/app/(main)/COMPANY_29/production/plan-management/page.tsx index c1ee134d..6eae857e 100644 --- a/frontend/app/(main)/COMPANY_29/production/plan-management/page.tsx +++ b/frontend/app/(main)/COMPANY_29/production/plan-management/page.tsx @@ -185,9 +185,6 @@ export default function ProductionPlanManagementPage() { const [modalQuantity, setModalQuantity] = useState(0); const [modalStartDate, setModalStartDate] = useState(""); const [modalEndDate, setModalEndDate] = useState(""); - const [modalManager, setModalManager] = useState(""); - const [modalWorkOrderNo, setModalWorkOrderNo] = useState(""); - const [modalRemarks, setModalRemarks] = useState(""); const [modalEquipmentId, setModalEquipmentId] = useState(""); // 미리보기 데이터 @@ -200,7 +197,10 @@ export default function ProductionPlanManagementPage() { const [selectedPlanIds, setSelectedPlanIds] = useState>(new Set()); // useConfirmDialog - const { confirm, ConfirmDialogComponent } = useConfirmDialog(); + const { confirm, ConfirmDialogComponent, isConfirmOpenRef } = useConfirmDialog(); + + // 수량 지정 분할 입력값 + const [customSplitQty, setCustomSplitQty] = useState(""); // ========== 데이터 로드 ========== @@ -694,10 +694,8 @@ export default function ProductionPlanManagementPage() { setModalQuantity(Number(plan.plan_qty)); setModalStartDate(plan.start_date?.split("T")[0] || ""); setModalEndDate(plan.end_date?.split("T")[0] || ""); - setModalManager((plan as any).manager_name || ""); - setModalWorkOrderNo((plan as any).work_order_no || ""); - setModalRemarks(plan.remarks || ""); setModalEquipmentId((plan as any).equipment_code || (plan.equipment_id ? String(plan.equipment_id) : "")); + setCustomSplitQty(""); setScheduleModalOpen(true); }, []); @@ -709,9 +707,6 @@ export default function ProductionPlanManagementPage() { plan_qty: modalQuantity, start_date: modalStartDate, end_date: modalEndDate, - manager_name: modalManager, - work_order_no: modalWorkOrderNo, - remarks: modalRemarks, equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null, equipment_name: modalEquipmentId && modalEquipmentId !== "none" ? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null @@ -721,13 +716,14 @@ export default function ProductionPlanManagementPage() { toast.success("생산계획이 수정되었습니다"); setScheduleModalOpen(false); fetchPlans(); + fetchOrderSummary(); } } catch (err: any) { - toast.error("수정 실패: " + (err.message || "")); + toast.error("수정 실패: " + (err?.response?.data?.message || err.message || "")); } finally { setSaving(false); } - }, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalManager, modalWorkOrderNo, modalRemarks, modalEquipmentId, fetchPlans]); + }, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId, equipmentList, fetchPlans, fetchOrderSummary]); const handleDeletePlan = useCallback(async () => { if (!selectedPlan) return; @@ -741,24 +737,158 @@ export default function ProductionPlanManagementPage() { toast.success("삭제되었습니다"); setScheduleModalOpen(false); fetchPlans(); + fetchOrderSummary(); } catch (err: any) { - toast.error("삭제 실패: " + (err.message || "")); + toast.error("삭제 실패: " + (err?.response?.data?.message || err.message || "")); } - }, [selectedPlan, fetchPlans, confirm]); + }, [selectedPlan, fetchPlans, fetchOrderSummary, confirm]); + + // 에러 메시지 추출 헬퍼 + const extractErrMsg = (err: any): string => { + return err?.response?.data?.message || err?.message || ""; + }; + + // modalQuantity/일정/설비가 DB의 selectedPlan 값과 다른지 확인 (dirty 체크) + const isModalDirty = useCallback((): boolean => { + if (!selectedPlan) return false; + const planQty = Number(selectedPlan.plan_qty) || 0; + const planStart = selectedPlan.start_date?.split("T")[0] || ""; + const planEnd = selectedPlan.end_date?.split("T")[0] || ""; + const planEq = (selectedPlan as any).equipment_code || (selectedPlan.equipment_id ? String(selectedPlan.equipment_id) : ""); + return ( + planQty !== Number(modalQuantity) || + planStart !== modalStartDate || + planEnd !== modalEndDate || + planEq !== modalEquipmentId + ); + }, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId]); + + // dirty 상태면 자동 저장 후 selectedPlan 을 최신 값으로 갱신 + const ensureSavedBeforeSplit = useCallback(async (): Promise => { + if (!selectedPlan) return false; + if (!isModalDirty()) return true; + try { + const res = await updatePlan(selectedPlan.id, { + plan_qty: modalQuantity, + start_date: modalStartDate, + end_date: modalEndDate, + equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null, + equipment_name: modalEquipmentId && modalEquipmentId !== "none" + ? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null + : null, + } as any); + if (!res.success) { + toast.error("저장 실패로 분할이 중단되었습니다"); + return false; + } + // selectedPlan 을 최신 값으로 동기화 (이후 로직에서 plan_qty 를 참조) + setSelectedPlan((prev) => prev ? ({ + ...prev, + plan_qty: modalQuantity, + start_date: modalStartDate, + end_date: modalEndDate, + equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null, + } as any) : prev); + return true; + } catch (err: any) { + toast.error("저장 실패로 분할이 중단되었습니다: " + extractErrMsg(err)); + return false; + } + }, [selectedPlan, isModalDirty, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId, equipmentList]); + + // 균등 분할 (2/3/4분할 버튼) + const handleSplitSchedule = useCallback(async (splitCount: number) => { + if (!selectedPlan || splitCount < 2) return; + // 모달 입력값 기준 (이후 자동 저장되므로 modalQuantity 가 진실) + const originalQty = Number(modalQuantity) || 0; + if (originalQty < splitCount) { + toast.error(`${splitCount}분할하려면 수량이 ${splitCount} 이상이어야 합니다`); + return; + } + if (selectedPlan.status && selectedPlan.status !== "planned") { + toast.error("계획 상태인 건만 분할할 수 있습니다"); + return; + } + const ok = await confirm(`이 계획을 ${splitCount}개로 균등 분할하시겠습니까?`, { + description: `수량 ${originalQty}이(가) ${splitCount}개로 나뉩니다.`, + confirmText: "분할", + }); + if (!ok) return; + + // dirty 면 자동 저장 + const saved = await ensureSavedBeforeSplit(); + if (!saved) return; + + const eachQty = Math.floor(originalQty / splitCount); + if (eachQty <= 0) { + toast.error("분할 수량이 부족합니다"); + return; + } + + let successCount = 0; + try { + // N-1회 호출: 매번 eachQty만큼 원본에서 떼어내 새 plan 생성 + for (let i = 0; i < splitCount - 1; i++) { + const res = await splitSchedule(selectedPlan.id, eachQty); + if (!res.success) throw new Error("분할 응답 실패"); + successCount++; + } + toast.success(`계획이 ${splitCount}개로 분할되었습니다`); + setScheduleModalOpen(false); + fetchPlans(); + fetchOrderSummary(); + } catch (err: any) { + const msg = extractErrMsg(err); + if (successCount > 0) { + toast.error(`분할 일부 실패 (${successCount + 1}개 생성됨): ${msg}`); + } else { + toast.error("분할 실패: " + msg); + } + fetchPlans(); + fetchOrderSummary(); + } + }, [selectedPlan, modalQuantity, fetchPlans, fetchOrderSummary, confirm, ensureSavedBeforeSplit]); + + // 수량 지정 분할 (원본에서 입력 수량만큼 떼어내기) + const handleCustomSplit = useCallback(async () => { + if (!selectedPlan) return; + const splitQty = Number(customSplitQty); + const originalQty = Number(modalQuantity) || 0; + if (!splitQty || splitQty < 1) { + toast.error("떼어낼 수량을 1 이상으로 입력하세요"); + return; + } + if (splitQty >= originalQty) { + toast.error("떼어낼 수량은 원본 수량보다 작아야 합니다"); + return; + } + if (selectedPlan.status && selectedPlan.status !== "planned") { + toast.error("계획 상태인 건만 분할할 수 있습니다"); + return; + } + const ok = await confirm(`이 계획에서 ${splitQty}만큼 떼어내시겠습니까?`, { + description: `원본 ${originalQty} → 원본 ${originalQty - splitQty} + 신규 ${splitQty}`, + confirmText: "분할", + }); + if (!ok) return; + + const saved = await ensureSavedBeforeSplit(); + if (!saved) return; - const handleSplitSchedule = useCallback(async (splitQty: number) => { - if (!selectedPlan || splitQty <= 0) return; try { const res = await splitSchedule(selectedPlan.id, splitQty); - if (res.success) { - toast.success("계획이 분할되었습니다"); - setScheduleModalOpen(false); - fetchPlans(); - } + if (!res.success) throw new Error("분할 응답 실패"); + toast.success(`${splitQty} 수량이 분리되었습니다`); + setCustomSplitQty(""); + setScheduleModalOpen(false); + fetchPlans(); + fetchOrderSummary(); } catch (err: any) { - toast.error("분할 실패: " + (err.message || "")); + toast.error("분할 실패: " + extractErrMsg(err)); + fetchPlans(); + fetchOrderSummary(); } - }, [selectedPlan, fetchPlans]); + }, [selectedPlan, modalQuantity, customSplitQty, fetchPlans, fetchOrderSummary, confirm, ensureSavedBeforeSplit]); // 병합 핸들러 const handleMergeSchedules = useCallback(async () => { @@ -780,11 +910,12 @@ export default function ProductionPlanManagementPage() { toast.success("계획이 병합되었습니다"); setSelectedPlanIds(new Set()); fetchPlans(); + fetchOrderSummary(); } } catch (err: any) { - toast.error("병합 실패: " + (err.message || "")); + toast.error("병합 실패: " + (err?.response?.data?.message || err.message || "")); } - }, [selectedPlanIds, rightTab, fetchPlans, confirm]); + }, [selectedPlanIds, rightTab, fetchPlans, fetchOrderSummary, confirm]); // 타임라인 이벤트 드래그 이동 const handleEventMove = useCallback(async (eventId: string | number, newStart: string, newEnd: string) => { @@ -796,11 +927,12 @@ export default function ProductionPlanManagementPage() { if (res.success) { toast.success("일정이 변경되었습니다"); fetchPlans(); + fetchOrderSummary(); } } catch (err: any) { toast.error("일정 변경 실패: " + (err.message || "")); } - }, [fetchPlans]); + }, [fetchPlans, fetchOrderSummary]); // 타임라인 이벤트 리사이즈 const handleEventResize = useCallback(async (eventId: string | number, newStart: string, newEnd: string) => { @@ -812,11 +944,12 @@ export default function ProductionPlanManagementPage() { if (res.success) { toast.success("기간이 변경되었습니다"); fetchPlans(); + fetchOrderSummary(); } } catch (err: any) { toast.error("기간 변경 실패: " + (err.message || "")); } - }, [fetchPlans]); + }, [fetchPlans, fetchOrderSummary]); // 불러오기 처리 const handleImportOrderItems = useCallback(async () => { @@ -1463,8 +1596,26 @@ export default function ProductionPlanManagementPage() { {/* ========== 모달들 ========== */} {/* 스케줄 상세/편집 모달 */} - - + { + // confirm 다이얼로그가 열려 있는 동안 발생하는 닫힘 이벤트(포커스 이탈 등)는 무시 + if (!v && isConfirmOpenRef.current) return; + setScheduleModalOpen(v); + }} + > + { + if (isConfirmOpenRef.current) e.preventDefault(); + }} + onInteractOutside={(e) => { + if (isConfirmOpenRef.current) e.preventDefault(); + }} + onFocusOutside={(e) => { + if (isConfirmOpenRef.current) e.preventDefault(); + }} + > @@ -1554,37 +1705,67 @@ export default function ProductionPlanManagementPage() { 계획 분할

+
+ {[2, 3, 4].map((n) => { + const canSplit = + modalQuantity >= n && + (selectedPlan?.status === "planned" || !selectedPlan?.status); + return ( + + ); + })} +
+
+

+ 하나의 생산계획을 선택한 개수만큼 균등 분할합니다. (수량 부족 또는 완료 상태는 불가) +

+ {/* 수량 지정 분할 */} +
+ + { + const v = e.target.value; + if (v === "") setCustomSplitQty(""); + else setCustomSplitQty(Math.max(0, Math.floor(Number(v) || 0))); + }} + className="h-7 w-28 text-xs" + placeholder="떼어낼 수량" + min={1} + max={Math.max(0, modalQuantity - 1)} + step={1} + /> + + / {modalQuantity} +
-

하나의 생산계획을 여러 개로 분할합니다.

- - -
-

추가 정보

-
-
- - setModalManager(e.target.value)} className="h-9 text-xs" placeholder="담당자명" /> -
-
- - setModalWorkOrderNo(e.target.value)} className="h-9 text-xs" placeholder="자동생성" /> -
-
- - setModalRemarks(e.target.value)} className="h-9 text-xs" placeholder="비고사항 입력" /> -
-
+

+ 입력한 수량만큼 떼어내 새 계획을 생성합니다. (1 이상, 원본 수량 미만) +

)} diff --git a/frontend/app/(main)/COMPANY_29/quality/item-inspection/page.tsx b/frontend/app/(main)/COMPANY_29/quality/item-inspection/page.tsx index 15fe6741..15118ffc 100644 --- a/frontend/app/(main)/COMPANY_29/quality/item-inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_29/quality/item-inspection/page.tsx @@ -977,12 +977,13 @@ export default function ItemInspectionInfoPage() { 판단기준 합격기준 필수 + 단위
{selectedTabRows.length === 0 ? ( - 등록된 검사항목이 없어요 + 등록된 검사항목이 없어요 ) : selectedTabRows.map((row: any) => ( @@ -1015,6 +1016,14 @@ export default function ItemInspectionInfoPage() { 필수 ) : "-"} + + {(() => { + const insp = inspOptions.find(o => o.code === row.inspection_standard_id); + const unitCode = insp?.unit || ""; + const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode; + return unitLabel || "-"; + })()} + ))} @@ -1179,12 +1188,13 @@ export default function ItemInspectionInfoPage() { 판단기준 합격기준 (판단기준별) 필수 + 단위 {(!inspectionRows[key] || inspectionRows[key].length === 0) ? ( - 항목추가 버튼으로 검사항목을 추가하세요 + 항목추가 버튼으로 검사항목을 추가하세요 ) : inspectionRows[key].map((row) => ( @@ -1250,6 +1260,7 @@ export default function ItemInspectionInfoPage() { )} updateInspRow(key, row.id, "is_required", !!v)} /> + {row.unit || "-"} diff --git a/frontend/app/(main)/COMPANY_30/logistics/warehouse/page.tsx b/frontend/app/(main)/COMPANY_30/logistics/warehouse/page.tsx index 96b3d47e..c148cbf6 100644 --- a/frontend/app/(main)/COMPANY_30/logistics/warehouse/page.tsx +++ b/frontend/app/(main)/COMPANY_30/logistics/warehouse/page.tsx @@ -74,7 +74,7 @@ const WAREHOUSE_COLUMNS = [ { key: "warehouse_code", label: "창고코드" }, { key: "warehouse_name", label: "창고명" }, { key: "warehouse_type", label: "유형" }, - { key: "manager", label: "관리자" }, + { key: "manager_name", label: "관리자" }, { key: "status", label: "상태" }, ]; const LOCATION_TABLE = "warehouse_location"; @@ -239,6 +239,8 @@ export default function WarehouseManagementPage() { const raw = res.data?.data?.data || res.data?.data?.rows || []; const data = raw.map((r: any) => ({ ...r, + _warehouse_type_code: r.warehouse_type, + _status_code: r.status, warehouse_type: resolveCategory(categoryOptions, "warehouse_type", r.warehouse_type), status: resolveCategory(categoryOptions, "status", r.status), })); @@ -344,7 +346,11 @@ export default function WarehouseManagementPage() { const openWarehouseEditModal = (row: any) => { setWarehouseEditMode(true); - setWarehouseForm({ ...row }); + setWarehouseForm({ + ...row, + warehouse_type: row._warehouse_type_code ?? row.warehouse_type ?? "", + status: row._status_code ?? row.status ?? "", + }); setWarehouseModalOpen(true); }; @@ -374,10 +380,10 @@ export default function WarehouseManagementPage() { warehouse_code: finalWarehouseCode, warehouse_name: warehouseForm.warehouse_name?.trim(), warehouse_type: warehouseForm.warehouse_type || "", - manager: warehouseForm.manager || "", - address: warehouseForm.address || "", + manager_name: warehouseForm.manager_name || "", + contact: warehouseForm.contact || "", status: warehouseForm.status || "", - description: warehouseForm.description || "", + memo: warehouseForm.memo || "", }; // 신규 등록 시 창고코드 중복 체크 @@ -729,7 +735,7 @@ export default function WarehouseManagementPage() { 창고코드: r.warehouse_code, 창고명: r.warehouse_name, 유형: r.warehouse_type, - 관리자: r.manager, + 관리자: r.manager_name, 상태: r.status, })), "창고정보" @@ -1041,9 +1047,9 @@ export default function WarehouseManagementPage() {
- setWarehouseForm((prev) => ({ ...prev, manager: e.target.value })) + setWarehouseForm((prev) => ({ ...prev, manager_name: e.target.value })) } placeholder="관리자를 입력해주세요" /> @@ -1069,24 +1075,24 @@ export default function WarehouseManagementPage() {
- {/* 주소 (전체 너비) */} + {/* 연락처 (전체 너비) */}
- + - setWarehouseForm((prev) => ({ ...prev, address: e.target.value })) + setWarehouseForm((prev) => ({ ...prev, contact: e.target.value })) } - placeholder="주소를 입력해주세요" + placeholder="연락처를 입력해주세요" />
{/* 비고 (전체 너비) */}
- setWarehouseForm((prev) => ({ ...prev, description: e.target.value })) + setWarehouseForm((prev) => ({ ...prev, memo: e.target.value })) } placeholder="비고를 입력해주세요" /> diff --git a/frontend/app/(main)/COMPANY_30/production/plan-management/page.tsx b/frontend/app/(main)/COMPANY_30/production/plan-management/page.tsx index c1ee134d..6eae857e 100644 --- a/frontend/app/(main)/COMPANY_30/production/plan-management/page.tsx +++ b/frontend/app/(main)/COMPANY_30/production/plan-management/page.tsx @@ -185,9 +185,6 @@ export default function ProductionPlanManagementPage() { const [modalQuantity, setModalQuantity] = useState(0); const [modalStartDate, setModalStartDate] = useState(""); const [modalEndDate, setModalEndDate] = useState(""); - const [modalManager, setModalManager] = useState(""); - const [modalWorkOrderNo, setModalWorkOrderNo] = useState(""); - const [modalRemarks, setModalRemarks] = useState(""); const [modalEquipmentId, setModalEquipmentId] = useState(""); // 미리보기 데이터 @@ -200,7 +197,10 @@ export default function ProductionPlanManagementPage() { const [selectedPlanIds, setSelectedPlanIds] = useState>(new Set()); // useConfirmDialog - const { confirm, ConfirmDialogComponent } = useConfirmDialog(); + const { confirm, ConfirmDialogComponent, isConfirmOpenRef } = useConfirmDialog(); + + // 수량 지정 분할 입력값 + const [customSplitQty, setCustomSplitQty] = useState(""); // ========== 데이터 로드 ========== @@ -694,10 +694,8 @@ export default function ProductionPlanManagementPage() { setModalQuantity(Number(plan.plan_qty)); setModalStartDate(plan.start_date?.split("T")[0] || ""); setModalEndDate(plan.end_date?.split("T")[0] || ""); - setModalManager((plan as any).manager_name || ""); - setModalWorkOrderNo((plan as any).work_order_no || ""); - setModalRemarks(plan.remarks || ""); setModalEquipmentId((plan as any).equipment_code || (plan.equipment_id ? String(plan.equipment_id) : "")); + setCustomSplitQty(""); setScheduleModalOpen(true); }, []); @@ -709,9 +707,6 @@ export default function ProductionPlanManagementPage() { plan_qty: modalQuantity, start_date: modalStartDate, end_date: modalEndDate, - manager_name: modalManager, - work_order_no: modalWorkOrderNo, - remarks: modalRemarks, equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null, equipment_name: modalEquipmentId && modalEquipmentId !== "none" ? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null @@ -721,13 +716,14 @@ export default function ProductionPlanManagementPage() { toast.success("생산계획이 수정되었습니다"); setScheduleModalOpen(false); fetchPlans(); + fetchOrderSummary(); } } catch (err: any) { - toast.error("수정 실패: " + (err.message || "")); + toast.error("수정 실패: " + (err?.response?.data?.message || err.message || "")); } finally { setSaving(false); } - }, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalManager, modalWorkOrderNo, modalRemarks, modalEquipmentId, fetchPlans]); + }, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId, equipmentList, fetchPlans, fetchOrderSummary]); const handleDeletePlan = useCallback(async () => { if (!selectedPlan) return; @@ -741,24 +737,158 @@ export default function ProductionPlanManagementPage() { toast.success("삭제되었습니다"); setScheduleModalOpen(false); fetchPlans(); + fetchOrderSummary(); } catch (err: any) { - toast.error("삭제 실패: " + (err.message || "")); + toast.error("삭제 실패: " + (err?.response?.data?.message || err.message || "")); } - }, [selectedPlan, fetchPlans, confirm]); + }, [selectedPlan, fetchPlans, fetchOrderSummary, confirm]); + + // 에러 메시지 추출 헬퍼 + const extractErrMsg = (err: any): string => { + return err?.response?.data?.message || err?.message || ""; + }; + + // modalQuantity/일정/설비가 DB의 selectedPlan 값과 다른지 확인 (dirty 체크) + const isModalDirty = useCallback((): boolean => { + if (!selectedPlan) return false; + const planQty = Number(selectedPlan.plan_qty) || 0; + const planStart = selectedPlan.start_date?.split("T")[0] || ""; + const planEnd = selectedPlan.end_date?.split("T")[0] || ""; + const planEq = (selectedPlan as any).equipment_code || (selectedPlan.equipment_id ? String(selectedPlan.equipment_id) : ""); + return ( + planQty !== Number(modalQuantity) || + planStart !== modalStartDate || + planEnd !== modalEndDate || + planEq !== modalEquipmentId + ); + }, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId]); + + // dirty 상태면 자동 저장 후 selectedPlan 을 최신 값으로 갱신 + const ensureSavedBeforeSplit = useCallback(async (): Promise => { + if (!selectedPlan) return false; + if (!isModalDirty()) return true; + try { + const res = await updatePlan(selectedPlan.id, { + plan_qty: modalQuantity, + start_date: modalStartDate, + end_date: modalEndDate, + equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null, + equipment_name: modalEquipmentId && modalEquipmentId !== "none" + ? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null + : null, + } as any); + if (!res.success) { + toast.error("저장 실패로 분할이 중단되었습니다"); + return false; + } + // selectedPlan 을 최신 값으로 동기화 (이후 로직에서 plan_qty 를 참조) + setSelectedPlan((prev) => prev ? ({ + ...prev, + plan_qty: modalQuantity, + start_date: modalStartDate, + end_date: modalEndDate, + equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null, + } as any) : prev); + return true; + } catch (err: any) { + toast.error("저장 실패로 분할이 중단되었습니다: " + extractErrMsg(err)); + return false; + } + }, [selectedPlan, isModalDirty, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId, equipmentList]); + + // 균등 분할 (2/3/4분할 버튼) + const handleSplitSchedule = useCallback(async (splitCount: number) => { + if (!selectedPlan || splitCount < 2) return; + // 모달 입력값 기준 (이후 자동 저장되므로 modalQuantity 가 진실) + const originalQty = Number(modalQuantity) || 0; + if (originalQty < splitCount) { + toast.error(`${splitCount}분할하려면 수량이 ${splitCount} 이상이어야 합니다`); + return; + } + if (selectedPlan.status && selectedPlan.status !== "planned") { + toast.error("계획 상태인 건만 분할할 수 있습니다"); + return; + } + const ok = await confirm(`이 계획을 ${splitCount}개로 균등 분할하시겠습니까?`, { + description: `수량 ${originalQty}이(가) ${splitCount}개로 나뉩니다.`, + confirmText: "분할", + }); + if (!ok) return; + + // dirty 면 자동 저장 + const saved = await ensureSavedBeforeSplit(); + if (!saved) return; + + const eachQty = Math.floor(originalQty / splitCount); + if (eachQty <= 0) { + toast.error("분할 수량이 부족합니다"); + return; + } + + let successCount = 0; + try { + // N-1회 호출: 매번 eachQty만큼 원본에서 떼어내 새 plan 생성 + for (let i = 0; i < splitCount - 1; i++) { + const res = await splitSchedule(selectedPlan.id, eachQty); + if (!res.success) throw new Error("분할 응답 실패"); + successCount++; + } + toast.success(`계획이 ${splitCount}개로 분할되었습니다`); + setScheduleModalOpen(false); + fetchPlans(); + fetchOrderSummary(); + } catch (err: any) { + const msg = extractErrMsg(err); + if (successCount > 0) { + toast.error(`분할 일부 실패 (${successCount + 1}개 생성됨): ${msg}`); + } else { + toast.error("분할 실패: " + msg); + } + fetchPlans(); + fetchOrderSummary(); + } + }, [selectedPlan, modalQuantity, fetchPlans, fetchOrderSummary, confirm, ensureSavedBeforeSplit]); + + // 수량 지정 분할 (원본에서 입력 수량만큼 떼어내기) + const handleCustomSplit = useCallback(async () => { + if (!selectedPlan) return; + const splitQty = Number(customSplitQty); + const originalQty = Number(modalQuantity) || 0; + if (!splitQty || splitQty < 1) { + toast.error("떼어낼 수량을 1 이상으로 입력하세요"); + return; + } + if (splitQty >= originalQty) { + toast.error("떼어낼 수량은 원본 수량보다 작아야 합니다"); + return; + } + if (selectedPlan.status && selectedPlan.status !== "planned") { + toast.error("계획 상태인 건만 분할할 수 있습니다"); + return; + } + const ok = await confirm(`이 계획에서 ${splitQty}만큼 떼어내시겠습니까?`, { + description: `원본 ${originalQty} → 원본 ${originalQty - splitQty} + 신규 ${splitQty}`, + confirmText: "분할", + }); + if (!ok) return; + + const saved = await ensureSavedBeforeSplit(); + if (!saved) return; - const handleSplitSchedule = useCallback(async (splitQty: number) => { - if (!selectedPlan || splitQty <= 0) return; try { const res = await splitSchedule(selectedPlan.id, splitQty); - if (res.success) { - toast.success("계획이 분할되었습니다"); - setScheduleModalOpen(false); - fetchPlans(); - } + if (!res.success) throw new Error("분할 응답 실패"); + toast.success(`${splitQty} 수량이 분리되었습니다`); + setCustomSplitQty(""); + setScheduleModalOpen(false); + fetchPlans(); + fetchOrderSummary(); } catch (err: any) { - toast.error("분할 실패: " + (err.message || "")); + toast.error("분할 실패: " + extractErrMsg(err)); + fetchPlans(); + fetchOrderSummary(); } - }, [selectedPlan, fetchPlans]); + }, [selectedPlan, modalQuantity, customSplitQty, fetchPlans, fetchOrderSummary, confirm, ensureSavedBeforeSplit]); // 병합 핸들러 const handleMergeSchedules = useCallback(async () => { @@ -780,11 +910,12 @@ export default function ProductionPlanManagementPage() { toast.success("계획이 병합되었습니다"); setSelectedPlanIds(new Set()); fetchPlans(); + fetchOrderSummary(); } } catch (err: any) { - toast.error("병합 실패: " + (err.message || "")); + toast.error("병합 실패: " + (err?.response?.data?.message || err.message || "")); } - }, [selectedPlanIds, rightTab, fetchPlans, confirm]); + }, [selectedPlanIds, rightTab, fetchPlans, fetchOrderSummary, confirm]); // 타임라인 이벤트 드래그 이동 const handleEventMove = useCallback(async (eventId: string | number, newStart: string, newEnd: string) => { @@ -796,11 +927,12 @@ export default function ProductionPlanManagementPage() { if (res.success) { toast.success("일정이 변경되었습니다"); fetchPlans(); + fetchOrderSummary(); } } catch (err: any) { toast.error("일정 변경 실패: " + (err.message || "")); } - }, [fetchPlans]); + }, [fetchPlans, fetchOrderSummary]); // 타임라인 이벤트 리사이즈 const handleEventResize = useCallback(async (eventId: string | number, newStart: string, newEnd: string) => { @@ -812,11 +944,12 @@ export default function ProductionPlanManagementPage() { if (res.success) { toast.success("기간이 변경되었습니다"); fetchPlans(); + fetchOrderSummary(); } } catch (err: any) { toast.error("기간 변경 실패: " + (err.message || "")); } - }, [fetchPlans]); + }, [fetchPlans, fetchOrderSummary]); // 불러오기 처리 const handleImportOrderItems = useCallback(async () => { @@ -1463,8 +1596,26 @@ export default function ProductionPlanManagementPage() { {/* ========== 모달들 ========== */} {/* 스케줄 상세/편집 모달 */} - - + { + // confirm 다이얼로그가 열려 있는 동안 발생하는 닫힘 이벤트(포커스 이탈 등)는 무시 + if (!v && isConfirmOpenRef.current) return; + setScheduleModalOpen(v); + }} + > + { + if (isConfirmOpenRef.current) e.preventDefault(); + }} + onInteractOutside={(e) => { + if (isConfirmOpenRef.current) e.preventDefault(); + }} + onFocusOutside={(e) => { + if (isConfirmOpenRef.current) e.preventDefault(); + }} + > @@ -1554,37 +1705,67 @@ export default function ProductionPlanManagementPage() { 계획 분할

+
+ {[2, 3, 4].map((n) => { + const canSplit = + modalQuantity >= n && + (selectedPlan?.status === "planned" || !selectedPlan?.status); + return ( + + ); + })} +
+
+

+ 하나의 생산계획을 선택한 개수만큼 균등 분할합니다. (수량 부족 또는 완료 상태는 불가) +

+ {/* 수량 지정 분할 */} +
+ + { + const v = e.target.value; + if (v === "") setCustomSplitQty(""); + else setCustomSplitQty(Math.max(0, Math.floor(Number(v) || 0))); + }} + className="h-7 w-28 text-xs" + placeholder="떼어낼 수량" + min={1} + max={Math.max(0, modalQuantity - 1)} + step={1} + /> + + / {modalQuantity} +
-

하나의 생산계획을 여러 개로 분할합니다.

- - -
-

추가 정보

-
-
- - setModalManager(e.target.value)} className="h-9 text-xs" placeholder="담당자명" /> -
-
- - setModalWorkOrderNo(e.target.value)} className="h-9 text-xs" placeholder="자동생성" /> -
-
- - setModalRemarks(e.target.value)} className="h-9 text-xs" placeholder="비고사항 입력" /> -
-
+

+ 입력한 수량만큼 떼어내 새 계획을 생성합니다. (1 이상, 원본 수량 미만) +

)} diff --git a/frontend/app/(main)/COMPANY_30/quality/item-inspection/page.tsx b/frontend/app/(main)/COMPANY_30/quality/item-inspection/page.tsx index d32103fd..9555117e 100644 --- a/frontend/app/(main)/COMPANY_30/quality/item-inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_30/quality/item-inspection/page.tsx @@ -977,12 +977,13 @@ export default function ItemInspectionInfoPage() { 판단기준 합격기준 필수 + 단위
{selectedTabRows.length === 0 ? ( - 등록된 검사항목이 없어요 + 등록된 검사항목이 없어요 ) : selectedTabRows.map((row: any) => ( @@ -1015,6 +1016,14 @@ export default function ItemInspectionInfoPage() { 필수 ) : "-"} + + {(() => { + const insp = inspOptions.find(o => o.code === row.inspection_standard_id); + const unitCode = insp?.unit || ""; + const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode; + return unitLabel || "-"; + })()} + ))} @@ -1179,12 +1188,13 @@ export default function ItemInspectionInfoPage() { 판단기준 합격기준 (판단기준별) 필수 + 단위 {(!inspectionRows[key] || inspectionRows[key].length === 0) ? ( - 항목추가 버튼으로 검사항목을 추가하세요 + 항목추가 버튼으로 검사항목을 추가하세요 ) : inspectionRows[key].map((row) => ( @@ -1250,6 +1260,7 @@ export default function ItemInspectionInfoPage() { )} updateInspRow(key, row.id, "is_required", !!v)} /> + {row.unit || "-"} diff --git a/frontend/app/(main)/COMPANY_7/logistics/warehouse/page.tsx b/frontend/app/(main)/COMPANY_7/logistics/warehouse/page.tsx index bfb7fbfd..1d409dd2 100644 --- a/frontend/app/(main)/COMPANY_7/logistics/warehouse/page.tsx +++ b/frontend/app/(main)/COMPANY_7/logistics/warehouse/page.tsx @@ -74,7 +74,7 @@ const WAREHOUSE_COLUMNS = [ { key: "warehouse_code", label: "창고코드" }, { key: "warehouse_name", label: "창고명" }, { key: "warehouse_type", label: "유형" }, - { key: "manager", label: "관리자" }, + { key: "manager_name", label: "관리자" }, { key: "status", label: "상태" }, ]; const LOCATION_TABLE = "warehouse_location"; @@ -247,6 +247,8 @@ export default function WarehouseManagementPage() { const raw = res.data?.data?.data || res.data?.data?.rows || []; const data = raw.map((r: any) => ({ ...r, + _warehouse_type_code: r.warehouse_type, + _status_code: r.status, warehouse_type: resolveCategory(categoryOptions, "warehouse_type", r.warehouse_type), status: resolveCategory(categoryOptions, "status", r.status), })); @@ -353,7 +355,11 @@ export default function WarehouseManagementPage() { const openWarehouseEditModal = (row: any) => { setWarehouseEditMode(true); - setWarehouseForm({ ...row }); + setWarehouseForm({ + ...row, + warehouse_type: row._warehouse_type_code ?? row.warehouse_type ?? "", + status: row._status_code ?? row.status ?? "", + }); setWarehouseModalOpen(true); }; @@ -383,10 +389,10 @@ export default function WarehouseManagementPage() { warehouse_code: finalWarehouseCode, warehouse_name: warehouseForm.warehouse_name?.trim(), warehouse_type: warehouseForm.warehouse_type || "", - manager: warehouseForm.manager || "", - address: warehouseForm.address || "", + manager_name: warehouseForm.manager_name || "", + contact: warehouseForm.contact || "", status: warehouseForm.status || "", - description: warehouseForm.description || "", + memo: warehouseForm.memo || "", }; // 신규 등록 시 창고코드 중복 체크 @@ -738,7 +744,7 @@ export default function WarehouseManagementPage() { 창고코드: r.warehouse_code, 창고명: r.warehouse_name, 유형: r.warehouse_type, - 관리자: r.manager, + 관리자: r.manager_name, 상태: r.status, })), "창고정보" @@ -1050,9 +1056,9 @@ export default function WarehouseManagementPage() {
- setWarehouseForm((prev) => ({ ...prev, manager: e.target.value })) + setWarehouseForm((prev) => ({ ...prev, manager_name: e.target.value })) } placeholder="관리자를 입력해주세요" /> @@ -1078,24 +1084,24 @@ export default function WarehouseManagementPage() {
- {/* 주소 (전체 너비) */} + {/* 연락처 (전체 너비) */}
- + - setWarehouseForm((prev) => ({ ...prev, address: e.target.value })) + setWarehouseForm((prev) => ({ ...prev, contact: e.target.value })) } - placeholder="주소를 입력해주세요" + placeholder="연락처를 입력해주세요" />
{/* 비고 (전체 너비) */}
- setWarehouseForm((prev) => ({ ...prev, description: e.target.value })) + setWarehouseForm((prev) => ({ ...prev, memo: e.target.value })) } placeholder="비고를 입력해주세요" /> diff --git a/frontend/app/(main)/COMPANY_7/production/plan-management/page.tsx b/frontend/app/(main)/COMPANY_7/production/plan-management/page.tsx index c1ee134d..6eae857e 100644 --- a/frontend/app/(main)/COMPANY_7/production/plan-management/page.tsx +++ b/frontend/app/(main)/COMPANY_7/production/plan-management/page.tsx @@ -185,9 +185,6 @@ export default function ProductionPlanManagementPage() { const [modalQuantity, setModalQuantity] = useState(0); const [modalStartDate, setModalStartDate] = useState(""); const [modalEndDate, setModalEndDate] = useState(""); - const [modalManager, setModalManager] = useState(""); - const [modalWorkOrderNo, setModalWorkOrderNo] = useState(""); - const [modalRemarks, setModalRemarks] = useState(""); const [modalEquipmentId, setModalEquipmentId] = useState(""); // 미리보기 데이터 @@ -200,7 +197,10 @@ export default function ProductionPlanManagementPage() { const [selectedPlanIds, setSelectedPlanIds] = useState>(new Set()); // useConfirmDialog - const { confirm, ConfirmDialogComponent } = useConfirmDialog(); + const { confirm, ConfirmDialogComponent, isConfirmOpenRef } = useConfirmDialog(); + + // 수량 지정 분할 입력값 + const [customSplitQty, setCustomSplitQty] = useState(""); // ========== 데이터 로드 ========== @@ -694,10 +694,8 @@ export default function ProductionPlanManagementPage() { setModalQuantity(Number(plan.plan_qty)); setModalStartDate(plan.start_date?.split("T")[0] || ""); setModalEndDate(plan.end_date?.split("T")[0] || ""); - setModalManager((plan as any).manager_name || ""); - setModalWorkOrderNo((plan as any).work_order_no || ""); - setModalRemarks(plan.remarks || ""); setModalEquipmentId((plan as any).equipment_code || (plan.equipment_id ? String(plan.equipment_id) : "")); + setCustomSplitQty(""); setScheduleModalOpen(true); }, []); @@ -709,9 +707,6 @@ export default function ProductionPlanManagementPage() { plan_qty: modalQuantity, start_date: modalStartDate, end_date: modalEndDate, - manager_name: modalManager, - work_order_no: modalWorkOrderNo, - remarks: modalRemarks, equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null, equipment_name: modalEquipmentId && modalEquipmentId !== "none" ? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null @@ -721,13 +716,14 @@ export default function ProductionPlanManagementPage() { toast.success("생산계획이 수정되었습니다"); setScheduleModalOpen(false); fetchPlans(); + fetchOrderSummary(); } } catch (err: any) { - toast.error("수정 실패: " + (err.message || "")); + toast.error("수정 실패: " + (err?.response?.data?.message || err.message || "")); } finally { setSaving(false); } - }, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalManager, modalWorkOrderNo, modalRemarks, modalEquipmentId, fetchPlans]); + }, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId, equipmentList, fetchPlans, fetchOrderSummary]); const handleDeletePlan = useCallback(async () => { if (!selectedPlan) return; @@ -741,24 +737,158 @@ export default function ProductionPlanManagementPage() { toast.success("삭제되었습니다"); setScheduleModalOpen(false); fetchPlans(); + fetchOrderSummary(); } catch (err: any) { - toast.error("삭제 실패: " + (err.message || "")); + toast.error("삭제 실패: " + (err?.response?.data?.message || err.message || "")); } - }, [selectedPlan, fetchPlans, confirm]); + }, [selectedPlan, fetchPlans, fetchOrderSummary, confirm]); + + // 에러 메시지 추출 헬퍼 + const extractErrMsg = (err: any): string => { + return err?.response?.data?.message || err?.message || ""; + }; + + // modalQuantity/일정/설비가 DB의 selectedPlan 값과 다른지 확인 (dirty 체크) + const isModalDirty = useCallback((): boolean => { + if (!selectedPlan) return false; + const planQty = Number(selectedPlan.plan_qty) || 0; + const planStart = selectedPlan.start_date?.split("T")[0] || ""; + const planEnd = selectedPlan.end_date?.split("T")[0] || ""; + const planEq = (selectedPlan as any).equipment_code || (selectedPlan.equipment_id ? String(selectedPlan.equipment_id) : ""); + return ( + planQty !== Number(modalQuantity) || + planStart !== modalStartDate || + planEnd !== modalEndDate || + planEq !== modalEquipmentId + ); + }, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId]); + + // dirty 상태면 자동 저장 후 selectedPlan 을 최신 값으로 갱신 + const ensureSavedBeforeSplit = useCallback(async (): Promise => { + if (!selectedPlan) return false; + if (!isModalDirty()) return true; + try { + const res = await updatePlan(selectedPlan.id, { + plan_qty: modalQuantity, + start_date: modalStartDate, + end_date: modalEndDate, + equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null, + equipment_name: modalEquipmentId && modalEquipmentId !== "none" + ? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null + : null, + } as any); + if (!res.success) { + toast.error("저장 실패로 분할이 중단되었습니다"); + return false; + } + // selectedPlan 을 최신 값으로 동기화 (이후 로직에서 plan_qty 를 참조) + setSelectedPlan((prev) => prev ? ({ + ...prev, + plan_qty: modalQuantity, + start_date: modalStartDate, + end_date: modalEndDate, + equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null, + } as any) : prev); + return true; + } catch (err: any) { + toast.error("저장 실패로 분할이 중단되었습니다: " + extractErrMsg(err)); + return false; + } + }, [selectedPlan, isModalDirty, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId, equipmentList]); + + // 균등 분할 (2/3/4분할 버튼) + const handleSplitSchedule = useCallback(async (splitCount: number) => { + if (!selectedPlan || splitCount < 2) return; + // 모달 입력값 기준 (이후 자동 저장되므로 modalQuantity 가 진실) + const originalQty = Number(modalQuantity) || 0; + if (originalQty < splitCount) { + toast.error(`${splitCount}분할하려면 수량이 ${splitCount} 이상이어야 합니다`); + return; + } + if (selectedPlan.status && selectedPlan.status !== "planned") { + toast.error("계획 상태인 건만 분할할 수 있습니다"); + return; + } + const ok = await confirm(`이 계획을 ${splitCount}개로 균등 분할하시겠습니까?`, { + description: `수량 ${originalQty}이(가) ${splitCount}개로 나뉩니다.`, + confirmText: "분할", + }); + if (!ok) return; + + // dirty 면 자동 저장 + const saved = await ensureSavedBeforeSplit(); + if (!saved) return; + + const eachQty = Math.floor(originalQty / splitCount); + if (eachQty <= 0) { + toast.error("분할 수량이 부족합니다"); + return; + } + + let successCount = 0; + try { + // N-1회 호출: 매번 eachQty만큼 원본에서 떼어내 새 plan 생성 + for (let i = 0; i < splitCount - 1; i++) { + const res = await splitSchedule(selectedPlan.id, eachQty); + if (!res.success) throw new Error("분할 응답 실패"); + successCount++; + } + toast.success(`계획이 ${splitCount}개로 분할되었습니다`); + setScheduleModalOpen(false); + fetchPlans(); + fetchOrderSummary(); + } catch (err: any) { + const msg = extractErrMsg(err); + if (successCount > 0) { + toast.error(`분할 일부 실패 (${successCount + 1}개 생성됨): ${msg}`); + } else { + toast.error("분할 실패: " + msg); + } + fetchPlans(); + fetchOrderSummary(); + } + }, [selectedPlan, modalQuantity, fetchPlans, fetchOrderSummary, confirm, ensureSavedBeforeSplit]); + + // 수량 지정 분할 (원본에서 입력 수량만큼 떼어내기) + const handleCustomSplit = useCallback(async () => { + if (!selectedPlan) return; + const splitQty = Number(customSplitQty); + const originalQty = Number(modalQuantity) || 0; + if (!splitQty || splitQty < 1) { + toast.error("떼어낼 수량을 1 이상으로 입력하세요"); + return; + } + if (splitQty >= originalQty) { + toast.error("떼어낼 수량은 원본 수량보다 작아야 합니다"); + return; + } + if (selectedPlan.status && selectedPlan.status !== "planned") { + toast.error("계획 상태인 건만 분할할 수 있습니다"); + return; + } + const ok = await confirm(`이 계획에서 ${splitQty}만큼 떼어내시겠습니까?`, { + description: `원본 ${originalQty} → 원본 ${originalQty - splitQty} + 신규 ${splitQty}`, + confirmText: "분할", + }); + if (!ok) return; + + const saved = await ensureSavedBeforeSplit(); + if (!saved) return; - const handleSplitSchedule = useCallback(async (splitQty: number) => { - if (!selectedPlan || splitQty <= 0) return; try { const res = await splitSchedule(selectedPlan.id, splitQty); - if (res.success) { - toast.success("계획이 분할되었습니다"); - setScheduleModalOpen(false); - fetchPlans(); - } + if (!res.success) throw new Error("분할 응답 실패"); + toast.success(`${splitQty} 수량이 분리되었습니다`); + setCustomSplitQty(""); + setScheduleModalOpen(false); + fetchPlans(); + fetchOrderSummary(); } catch (err: any) { - toast.error("분할 실패: " + (err.message || "")); + toast.error("분할 실패: " + extractErrMsg(err)); + fetchPlans(); + fetchOrderSummary(); } - }, [selectedPlan, fetchPlans]); + }, [selectedPlan, modalQuantity, customSplitQty, fetchPlans, fetchOrderSummary, confirm, ensureSavedBeforeSplit]); // 병합 핸들러 const handleMergeSchedules = useCallback(async () => { @@ -780,11 +910,12 @@ export default function ProductionPlanManagementPage() { toast.success("계획이 병합되었습니다"); setSelectedPlanIds(new Set()); fetchPlans(); + fetchOrderSummary(); } } catch (err: any) { - toast.error("병합 실패: " + (err.message || "")); + toast.error("병합 실패: " + (err?.response?.data?.message || err.message || "")); } - }, [selectedPlanIds, rightTab, fetchPlans, confirm]); + }, [selectedPlanIds, rightTab, fetchPlans, fetchOrderSummary, confirm]); // 타임라인 이벤트 드래그 이동 const handleEventMove = useCallback(async (eventId: string | number, newStart: string, newEnd: string) => { @@ -796,11 +927,12 @@ export default function ProductionPlanManagementPage() { if (res.success) { toast.success("일정이 변경되었습니다"); fetchPlans(); + fetchOrderSummary(); } } catch (err: any) { toast.error("일정 변경 실패: " + (err.message || "")); } - }, [fetchPlans]); + }, [fetchPlans, fetchOrderSummary]); // 타임라인 이벤트 리사이즈 const handleEventResize = useCallback(async (eventId: string | number, newStart: string, newEnd: string) => { @@ -812,11 +944,12 @@ export default function ProductionPlanManagementPage() { if (res.success) { toast.success("기간이 변경되었습니다"); fetchPlans(); + fetchOrderSummary(); } } catch (err: any) { toast.error("기간 변경 실패: " + (err.message || "")); } - }, [fetchPlans]); + }, [fetchPlans, fetchOrderSummary]); // 불러오기 처리 const handleImportOrderItems = useCallback(async () => { @@ -1463,8 +1596,26 @@ export default function ProductionPlanManagementPage() { {/* ========== 모달들 ========== */} {/* 스케줄 상세/편집 모달 */} - - + { + // confirm 다이얼로그가 열려 있는 동안 발생하는 닫힘 이벤트(포커스 이탈 등)는 무시 + if (!v && isConfirmOpenRef.current) return; + setScheduleModalOpen(v); + }} + > + { + if (isConfirmOpenRef.current) e.preventDefault(); + }} + onInteractOutside={(e) => { + if (isConfirmOpenRef.current) e.preventDefault(); + }} + onFocusOutside={(e) => { + if (isConfirmOpenRef.current) e.preventDefault(); + }} + > @@ -1554,37 +1705,67 @@ export default function ProductionPlanManagementPage() { 계획 분할

+
+ {[2, 3, 4].map((n) => { + const canSplit = + modalQuantity >= n && + (selectedPlan?.status === "planned" || !selectedPlan?.status); + return ( + + ); + })} +
+
+

+ 하나의 생산계획을 선택한 개수만큼 균등 분할합니다. (수량 부족 또는 완료 상태는 불가) +

+ {/* 수량 지정 분할 */} +
+ + { + const v = e.target.value; + if (v === "") setCustomSplitQty(""); + else setCustomSplitQty(Math.max(0, Math.floor(Number(v) || 0))); + }} + className="h-7 w-28 text-xs" + placeholder="떼어낼 수량" + min={1} + max={Math.max(0, modalQuantity - 1)} + step={1} + /> + + / {modalQuantity} +
-

하나의 생산계획을 여러 개로 분할합니다.

- - -
-

추가 정보

-
-
- - setModalManager(e.target.value)} className="h-9 text-xs" placeholder="담당자명" /> -
-
- - setModalWorkOrderNo(e.target.value)} className="h-9 text-xs" placeholder="자동생성" /> -
-
- - setModalRemarks(e.target.value)} className="h-9 text-xs" placeholder="비고사항 입력" /> -
-
+

+ 입력한 수량만큼 떼어내 새 계획을 생성합니다. (1 이상, 원본 수량 미만) +

)} diff --git a/frontend/app/(main)/COMPANY_7/production/process-info/ItemRoutingTab.tsx b/frontend/app/(main)/COMPANY_7/production/process-info/ItemRoutingTab.tsx index 4eefd66c..89f060d8 100644 --- a/frontend/app/(main)/COMPANY_7/production/process-info/ItemRoutingTab.tsx +++ b/frontend/app/(main)/COMPANY_7/production/process-info/ItemRoutingTab.tsx @@ -12,6 +12,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; import { @@ -91,7 +92,7 @@ export function ItemRoutingTab() { const [formFixedOrder, setFormFixedOrder] = useState("Y"); const [formWorkType, setFormWorkType] = useState("내부"); const [formStandardTime, setFormStandardTime] = useState(""); - const [formOutsource, setFormOutsource] = useState(""); + const [formOutsources, setFormOutsources] = useState([]); const [subcontractorOptions, setSubcontractorOptions] = useState<{ code: string; name: string }[]>([]); const [detailSubmitting, setDetailSubmitting] = useState(false); @@ -281,7 +282,7 @@ export function ItemRoutingTab() { setFormFixedOrder("Y"); setFormWorkType("내부"); setFormStandardTime(""); - setFormOutsource(""); + setFormOutsources([]); setDetailDialogOpen(true); }; @@ -308,7 +309,10 @@ export function ItemRoutingTab() { setFormFixedOrder(row.is_fixed_order === "N" ? "N" : "Y"); setFormWorkType(row.work_type || "내부"); setFormStandardTime(row.standard_time || ""); - setFormOutsource(row.outsource_supplier || ""); + const loaded = Array.isArray(row.outsource_supplier_list) && row.outsource_supplier_list.length > 0 + ? row.outsource_supplier_list + : (row.outsource_supplier ? [row.outsource_supplier] : []); + setFormOutsources(loaded); setDetailDialogOpen(true); }; @@ -329,7 +333,8 @@ export function ItemRoutingTab() { return; } const proc = processes.find((p) => p.process_code === formProcessCode); - const outsource = showOutsourceField ? formOutsource.trim() : ""; + const outsourceList = showOutsourceField ? formOutsources.filter((s) => s && s.trim() !== "") : []; + const outsourcePrimary = outsourceList[0] || ""; setDetailSubmitting(true); try { @@ -344,7 +349,8 @@ export function ItemRoutingTab() { is_fixed_order: formFixedOrder, work_type: formWorkType, standard_time: st || "0", - outsource_supplier: outsource, + outsource_supplier: outsourcePrimary, + outsource_supplier_list: outsourceList, }; setDetails((prev) => sortDetailsBySeq([...prev, newRow])); toast.success("공정이 추가되었어요. 저장을 눌러 반영해주세요"); @@ -362,7 +368,8 @@ export function ItemRoutingTab() { is_fixed_order: formFixedOrder, work_type: formWorkType, standard_time: st || "0", - outsource_supplier: outsource, + outsource_supplier: outsourcePrimary, + outsource_supplier_list: outsourceList, } : d, ), @@ -399,6 +406,7 @@ export function ItemRoutingTab() { work_type: d.work_type || "내부", standard_time: String(d.standard_time ?? "0"), outsource_supplier: d.outsource_supplier || "", + outsource_supplier_list: d.outsource_supplier_list || (d.outsource_supplier ? [d.outsource_supplier] : []), })); setSaving(true); @@ -480,11 +488,19 @@ export function ItemRoutingTab() { const detailsGridData = useMemo( () => - details.map((d) => ({ - ...d, - process_display: d.process_name || d.process_code, - outsource_display: subcontractorOptions.find((s) => s.code === d.outsource_supplier)?.name || d.outsource_supplier || "—", - })), + details.map((d) => { + const codes = Array.isArray(d.outsource_supplier_list) && d.outsource_supplier_list.length > 0 + ? d.outsource_supplier_list + : (d.outsource_supplier ? [d.outsource_supplier] : []); + const names = codes + .map((c) => subcontractorOptions.find((s) => s.code === c)?.name || c) + .filter(Boolean); + return { + ...d, + process_display: d.process_name || d.process_code, + outsource_display: names.length === 0 ? "—" : names.join(", "), + }; + }), [details, subcontractorOptions], ); @@ -909,15 +925,46 @@ export function ItemRoutingTab() { {showOutsourceField && (
- - + + + + + + +
+ {subcontractorOptions.length === 0 ? ( +
등록된 외주업체가 없어요
+ ) : subcontractorOptions.map((s) => { + const checked = formOutsources.includes(s.code); + return ( + + ); + })} +
+
+
)} diff --git a/frontend/app/(main)/COMPANY_7/purchase/purchase-item/page.tsx b/frontend/app/(main)/COMPANY_7/purchase/purchase-item/page.tsx index 42db2edf..77f32fd8 100644 --- a/frontend/app/(main)/COMPANY_7/purchase/purchase-item/page.tsx +++ b/frontend/app/(main)/COMPANY_7/purchase/purchase-item/page.tsx @@ -154,6 +154,7 @@ const FORM_FIELDS = [ { key: "user_type01", label: "대분류", type: "category" }, { key: "user_type02", label: "중분류", type: "category" }, { key: "lead_time", label: "생산 리드타임(일)", type: "text", placeholder: "숫자 입력 (예: 7)" }, + { key: "expiry", label: "유효기간", type: "expiry" }, { key: "image", label: "품목 이미지", type: "image" }, { key: "meno", label: "메모", type: "textarea" }, ] as const; @@ -170,6 +171,21 @@ const formatNum = (val: any): string => { return isNaN(n) ? String(val) : n.toLocaleString(); }; +// 유효기간 요약 문자열 (NULL/0은 해당 단위 생략) +const formatExpirySummary = (y: any, m: any, d: any): string => { + const toInt = (v: any) => { + if (v === null || v === undefined || v === "") return 0; + const n = Number(v); + return isNaN(n) ? 0 : Math.floor(n); + }; + const years = toInt(y), months = toInt(m), days = toInt(d); + const parts: string[] = []; + if (years) parts.push(`${years}년`); + if (months) parts.push(`${months}개월`); + if (days) parts.push(`${days}일`); + return parts.join(" "); +}; + const ITEM_GRID_COLUMNS = [ { key: "item_number", label: "품번" }, { key: "item_name", label: "품명" }, @@ -177,6 +193,7 @@ const ITEM_GRID_COLUMNS = [ { key: "inventory_unit", label: "단위" }, { key: "standard_price", label: "기준단가/구매단가" }, { key: "currency_code", label: "통화" }, + { key: "expiry_summary", label: "유효기간" }, { key: "status", label: "상태" }, ]; @@ -339,6 +356,7 @@ export default function PurchaseItemPage() { for (const col of CATEGORY_COLUMNS) { if (converted[col]) converted[col] = resolve(col, converted[col]); } + converted.expiry_summary = formatExpirySummary(r.expiry_years, r.expiry_months, r.expiry_days); return converted; }); setItems(data); @@ -550,7 +568,7 @@ export default function PurchaseItemPage() { setSaving(true); try { if (isEditMode && editId) { - const { id, created_date, updated_date, writer, company_code, ...updateFields } = formData; + const { id, created_date, updated_date, writer, company_code, expiry_summary, ...updateFields } = formData; await apiClient.put(`/table-management/tables/${ITEM_TABLE}/edit`, { originalData: { id: editId }, updatedData: updateFields, @@ -583,7 +601,7 @@ export default function PurchaseItemPage() { } } - const { id, created_date, updated_date, ...insertFields } = formData; + const { id, created_date, updated_date, expiry_summary, ...insertFields } = formData; await apiClient.post(`/table-management/tables/${ITEM_TABLE}/add`, { id: crypto.randomUUID(), ...insertFields, @@ -1166,6 +1184,7 @@ export default function PurchaseItemPage() { inventory_unit: { width: "w-[60px]" }, standard_price: { width: "w-[90px]", align: "right", formatNumber: true }, currency_code: { width: "w-[50px]" }, + expiry_summary: { width: "w-[110px]" }, status: { width: "w-[60px]" }, }; const itemColumns: EDataTableColumn[] = ts.visibleColumns.map((col): EDataTableColumn => ({ @@ -1596,6 +1615,33 @@ export default function PurchaseItemPage() { placeholder={field.label} rows={3} /> + ) : field.type === "expiry" ? ( +
+ {[ + { key: "expiry_years", unit: "년" }, + { key: "expiry_months", unit: "개월" }, + { key: "expiry_days", unit: "일" }, + ].map(({ key, unit }) => ( +
+ { + const v = e.target.value; + setFormData((prev) => ({ + ...prev, + [key]: v === "" ? null : Math.max(0, Math.floor(Number(v))), + })); + }} + placeholder="0" + className="h-9 text-right" + /> + {unit} +
+ ))} +
) : ["selling_price", "standard_price"].includes(field.key) ? ( 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 [loading, setLoading] = useState(false); @@ -402,7 +433,13 @@ export default function ItemInspectionInfoPage() { // 선택된 탭의 검사항목 행 const selectedTabRows = useMemo(() => { if (!selectedGroup || !selectedTypeTab) return []; - return selectedGroup.rows.filter((r: any) => r.inspection_type === selectedTypeTab && r.inspection_standard_id); + const filtered = selectedGroup.rows.filter((r: any) => r.inspection_type === selectedTypeTab && r.inspection_standard_id); + return [...filtered].sort((a: any, b: any) => { + const av = parseInt(String(a.sort_order || "9999"), 10); + const bv = parseInt(String(b.sort_order || "9999"), 10); + if (av === bv) return String(a.id).localeCompare(String(b.id)); + return av - bv; + }); }, [selectedGroup, selectedTypeTab]); // 검사기준 ID → 라벨 @@ -436,6 +473,13 @@ export default function ItemInspectionInfoPage() { autoFilter: true, }); const allRows = res.data?.data?.data || res.data?.data?.rows || []; + // sort_order 기준 오름차순 정렬 (varchar이므로 숫자 파싱 후 비교) + allRows.sort((a: any, b: any) => { + const av = parseInt(String(a.sort_order || "9999"), 10); + const bv = parseInt(String(b.sort_order || "9999"), 10); + if (av === bv) return String(a.id).localeCompare(String(b.id)); + return av - bv; + }); const rowMap: Record = {}; const typeFlags: Record = {}; @@ -462,7 +506,8 @@ export default function ItemInspectionInfoPage() { inspection_standard_id: r.inspection_standard_id || "", inspection_detail: r.inspection_item_name || r.inspection_standard || "", inspection_method: mLabel, - apply_process: "", + apply_process: r.apply_process || "", + classification: r.classification || "", acceptance_criteria: r.pass_criteria || "", is_required: r.is_required === "true" || r.is_required === true, judgment_criteria: jcLabel, @@ -480,9 +525,18 @@ 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: "", acceptance_criteria: "", is_required: false }], + [typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", classification: "", acceptance_criteria: "", is_required: false }], })); }; + 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 removeInspRow = (typeKey: string, rowId: string) => { setInspectionRows(prev => ({ ...prev, [typeKey]: (prev[typeKey] || []).filter(r => r.id !== rowId) })); }; @@ -542,18 +596,23 @@ export default function ItemInspectionInfoPage() { } const enabledTypes = INSPECTION_TYPES.filter(t => !!form[t.key]); const rows: any[] = []; + let globalOrder = 0; for (const t of enabledTypes) { const typeRows = inspectionRows[t.key] || []; if (typeRows.length === 0) { - rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, is_active: form.is_active || "사용", manager_id: form.manager_id || "", memo: form.remarks || "" }); + globalOrder += 1; + rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, is_active: form.is_active || "사용", manager_id: form.manager_id || "", memo: form.remarks || "", sort_order: String(globalOrder).padStart(4, "0") }); } else { for (const r of typeRows) { + globalOrder += 1; 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 || "", + 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 || "", + sort_order: String(globalOrder).padStart(4, "0"), }); } } @@ -974,15 +1033,17 @@ export default function ItemInspectionInfoPage() { 검사기준 검사방법 적용공정 + 구분 판단기준 합격기준 필수 + 단위
{selectedTabRows.length === 0 ? ( - 등록된 검사항목이 없어요 + 등록된 검사항목이 없어요 ) : selectedTabRows.map((row: any) => ( @@ -1001,6 +1062,7 @@ export default function ItemInspectionInfoPage() { const proc = processOptions.find(p => p.code === code); return proc?.name || code; })()} + {row.classification || "-"} {(() => { const insp = inspOptions.find(o => o.code === row.inspection_standard_id); @@ -1015,6 +1077,14 @@ export default function ItemInspectionInfoPage() { 필수 ) : "-"} + + {(() => { + const insp = inspOptions.find(o => o.code === row.inspection_standard_id); + const unitCode = insp?.unit || ""; + const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode; + return unitLabel || "-"; + })()} + ))} @@ -1172,21 +1242,38 @@ 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) => ( + + {(dragHandle) => (<> + {dragHandle} updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" /> )} + + updateInspRow(key, row.id, "classification", e.target.value)} placeholder="구분 입력" /> + {row.judgment_criteria ? {row.judgment_criteria} : -} @@ -1250,11 +1340,16 @@ export default function ItemInspectionInfoPage() { )} updateInspRow(key, row.id, "is_required", !!v)} /> + {row.unit || "-"} - - ))} + )} + + ))} + + + )}
diff --git a/frontend/app/(main)/COMPANY_7/sales/sales-item/page.tsx b/frontend/app/(main)/COMPANY_7/sales/sales-item/page.tsx index c713cab6..4905a814 100644 --- a/frontend/app/(main)/COMPANY_7/sales/sales-item/page.tsx +++ b/frontend/app/(main)/COMPANY_7/sales/sales-item/page.tsx @@ -145,6 +145,21 @@ const formatNum = (val: any): string => { return isNaN(n) ? String(val) : n.toLocaleString(); }; +// 유효기간 요약 문자열 (NULL/0은 해당 단위 생략) +const formatExpirySummary = (y: any, m: any, d: any): string => { + const toInt = (v: any) => { + if (v === null || v === undefined || v === "") return 0; + const n = Number(v); + return isNaN(n) ? 0 : Math.floor(n); + }; + const years = toInt(y), months = toInt(m), days = toInt(d); + const parts: string[] = []; + if (years) parts.push(`${years}년`); + if (months) parts.push(`${months}개월`); + if (days) parts.push(`${days}일`); + return parts.join(" "); +}; + const ITEM_GRID_COLUMNS = [ { key: "item_number", label: "품번" }, { key: "item_name", label: "품명" }, @@ -153,6 +168,7 @@ const ITEM_GRID_COLUMNS = [ { key: "standard_price", label: "기준단가" }, { key: "selling_price", label: "판매가격" }, { key: "currency_code", label: "통화" }, + { key: "expiry_summary", label: "유효기간" }, { key: "status", label: "상태" }, ]; @@ -175,6 +191,7 @@ const FORM_FIELDS = [ { key: "user_type01", label: "대분류", type: "category" }, { key: "user_type02", label: "중분류", type: "category" }, { key: "lead_time", label: "생산 리드타임(일)", type: "text", placeholder: "숫자 입력 (예: 7)" }, + { key: "expiry", label: "유효기간", type: "expiry" }, { key: "image", label: "품목 이미지", type: "image" }, { key: "meno", label: "메모", type: "textarea" }, ]; @@ -340,6 +357,7 @@ export default function SalesItemPage() { for (const col of CATS) { if (converted[col]) converted[col] = resolve(col, converted[col]); } + converted.expiry_summary = formatExpirySummary(r.expiry_years, r.expiry_months, r.expiry_days); return converted; }); setItems(data); @@ -1044,7 +1062,7 @@ export default function SalesItemPage() { setSaving(true); try { if (isEditMode && editId) { - const { id, created_date, updated_date, writer, company_code, ...updateFields } = editItemForm; + const { id, created_date, updated_date, writer, company_code, expiry_summary, ...updateFields } = editItemForm; await apiClient.put(`/table-management/tables/${ITEM_TABLE}/edit`, { originalData: { id: editId }, updatedData: updateFields, @@ -1077,7 +1095,7 @@ export default function SalesItemPage() { } } - const { id, created_date, updated_date, ...insertFields } = editItemForm; + const { id, created_date, updated_date, expiry_summary, ...insertFields } = editItemForm; await apiClient.post(`/table-management/tables/${ITEM_TABLE}/add`, { id: crypto.randomUUID(), ...insertFields, @@ -1175,6 +1193,7 @@ export default function SalesItemPage() { { key: "standard_price", label: "기준단가", width: "w-[90px]", align: "right", formatNumber: true }, { key: "selling_price", label: "판매가격", width: "w-[90px]", align: "right", formatNumber: true }, { key: "currency_code", label: "통화", width: "w-[50px]" }, + { key: "expiry_summary", label: "유효기간", width: "w-[110px]" }, { key: "status", label: "상태", width: "w-[60px]" }, ]; @@ -1598,6 +1617,33 @@ export default function SalesItemPage() { placeholder={field.label} rows={3} /> + ) : field.type === "expiry" ? ( +
+ {[ + { key: "expiry_years", unit: "년" }, + { key: "expiry_months", unit: "개월" }, + { key: "expiry_days", unit: "일" }, + ].map(({ key, unit }) => ( +
+ { + const v = e.target.value; + setEditItemForm((prev) => ({ + ...prev, + [key]: v === "" ? null : Math.max(0, Math.floor(Number(v))), + })); + }} + placeholder="0" + className="h-9 text-right" + /> + {unit} +
+ ))} +
) : ["selling_price", "standard_price"].includes(field.key) ? ( ({ ...r, + _warehouse_type_code: r.warehouse_type, + _status_code: r.status, warehouse_type: resolveCategory(categoryOptions, "warehouse_type", r.warehouse_type), status: resolveCategory(categoryOptions, "status", r.status), })); @@ -344,7 +346,11 @@ export default function WarehouseManagementPage() { const openWarehouseEditModal = (row: any) => { setWarehouseEditMode(true); - setWarehouseForm({ ...row }); + setWarehouseForm({ + ...row, + warehouse_type: row._warehouse_type_code ?? row.warehouse_type ?? "", + status: row._status_code ?? row.status ?? "", + }); setWarehouseModalOpen(true); }; @@ -374,10 +380,10 @@ export default function WarehouseManagementPage() { warehouse_code: finalWarehouseCode, warehouse_name: warehouseForm.warehouse_name?.trim(), warehouse_type: warehouseForm.warehouse_type || "", - manager: warehouseForm.manager || "", - address: warehouseForm.address || "", + manager_name: warehouseForm.manager_name || "", + contact: warehouseForm.contact || "", status: warehouseForm.status || "", - description: warehouseForm.description || "", + memo: warehouseForm.memo || "", }; // 신규 등록 시 창고코드 중복 체크 @@ -729,7 +735,7 @@ export default function WarehouseManagementPage() { 창고코드: r.warehouse_code, 창고명: r.warehouse_name, 유형: r.warehouse_type, - 관리자: r.manager, + 관리자: r.manager_name, 상태: r.status, })), "창고정보" @@ -1041,9 +1047,9 @@ export default function WarehouseManagementPage() {
- setWarehouseForm((prev) => ({ ...prev, manager: e.target.value })) + setWarehouseForm((prev) => ({ ...prev, manager_name: e.target.value })) } placeholder="관리자를 입력해주세요" /> @@ -1069,24 +1075,24 @@ export default function WarehouseManagementPage() {
- {/* 주소 (전체 너비) */} + {/* 연락처 (전체 너비) */}
- + - setWarehouseForm((prev) => ({ ...prev, address: e.target.value })) + setWarehouseForm((prev) => ({ ...prev, contact: e.target.value })) } - placeholder="주소를 입력해주세요" + placeholder="연락처를 입력해주세요" />
{/* 비고 (전체 너비) */}
- setWarehouseForm((prev) => ({ ...prev, description: e.target.value })) + setWarehouseForm((prev) => ({ ...prev, memo: e.target.value })) } placeholder="비고를 입력해주세요" /> diff --git a/frontend/app/(main)/COMPANY_8/production/plan-management/page.tsx b/frontend/app/(main)/COMPANY_8/production/plan-management/page.tsx index c1ee134d..6eae857e 100644 --- a/frontend/app/(main)/COMPANY_8/production/plan-management/page.tsx +++ b/frontend/app/(main)/COMPANY_8/production/plan-management/page.tsx @@ -185,9 +185,6 @@ export default function ProductionPlanManagementPage() { const [modalQuantity, setModalQuantity] = useState(0); const [modalStartDate, setModalStartDate] = useState(""); const [modalEndDate, setModalEndDate] = useState(""); - const [modalManager, setModalManager] = useState(""); - const [modalWorkOrderNo, setModalWorkOrderNo] = useState(""); - const [modalRemarks, setModalRemarks] = useState(""); const [modalEquipmentId, setModalEquipmentId] = useState(""); // 미리보기 데이터 @@ -200,7 +197,10 @@ export default function ProductionPlanManagementPage() { const [selectedPlanIds, setSelectedPlanIds] = useState>(new Set()); // useConfirmDialog - const { confirm, ConfirmDialogComponent } = useConfirmDialog(); + const { confirm, ConfirmDialogComponent, isConfirmOpenRef } = useConfirmDialog(); + + // 수량 지정 분할 입력값 + const [customSplitQty, setCustomSplitQty] = useState(""); // ========== 데이터 로드 ========== @@ -694,10 +694,8 @@ export default function ProductionPlanManagementPage() { setModalQuantity(Number(plan.plan_qty)); setModalStartDate(plan.start_date?.split("T")[0] || ""); setModalEndDate(plan.end_date?.split("T")[0] || ""); - setModalManager((plan as any).manager_name || ""); - setModalWorkOrderNo((plan as any).work_order_no || ""); - setModalRemarks(plan.remarks || ""); setModalEquipmentId((plan as any).equipment_code || (plan.equipment_id ? String(plan.equipment_id) : "")); + setCustomSplitQty(""); setScheduleModalOpen(true); }, []); @@ -709,9 +707,6 @@ export default function ProductionPlanManagementPage() { plan_qty: modalQuantity, start_date: modalStartDate, end_date: modalEndDate, - manager_name: modalManager, - work_order_no: modalWorkOrderNo, - remarks: modalRemarks, equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null, equipment_name: modalEquipmentId && modalEquipmentId !== "none" ? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null @@ -721,13 +716,14 @@ export default function ProductionPlanManagementPage() { toast.success("생산계획이 수정되었습니다"); setScheduleModalOpen(false); fetchPlans(); + fetchOrderSummary(); } } catch (err: any) { - toast.error("수정 실패: " + (err.message || "")); + toast.error("수정 실패: " + (err?.response?.data?.message || err.message || "")); } finally { setSaving(false); } - }, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalManager, modalWorkOrderNo, modalRemarks, modalEquipmentId, fetchPlans]); + }, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId, equipmentList, fetchPlans, fetchOrderSummary]); const handleDeletePlan = useCallback(async () => { if (!selectedPlan) return; @@ -741,24 +737,158 @@ export default function ProductionPlanManagementPage() { toast.success("삭제되었습니다"); setScheduleModalOpen(false); fetchPlans(); + fetchOrderSummary(); } catch (err: any) { - toast.error("삭제 실패: " + (err.message || "")); + toast.error("삭제 실패: " + (err?.response?.data?.message || err.message || "")); } - }, [selectedPlan, fetchPlans, confirm]); + }, [selectedPlan, fetchPlans, fetchOrderSummary, confirm]); + + // 에러 메시지 추출 헬퍼 + const extractErrMsg = (err: any): string => { + return err?.response?.data?.message || err?.message || ""; + }; + + // modalQuantity/일정/설비가 DB의 selectedPlan 값과 다른지 확인 (dirty 체크) + const isModalDirty = useCallback((): boolean => { + if (!selectedPlan) return false; + const planQty = Number(selectedPlan.plan_qty) || 0; + const planStart = selectedPlan.start_date?.split("T")[0] || ""; + const planEnd = selectedPlan.end_date?.split("T")[0] || ""; + const planEq = (selectedPlan as any).equipment_code || (selectedPlan.equipment_id ? String(selectedPlan.equipment_id) : ""); + return ( + planQty !== Number(modalQuantity) || + planStart !== modalStartDate || + planEnd !== modalEndDate || + planEq !== modalEquipmentId + ); + }, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId]); + + // dirty 상태면 자동 저장 후 selectedPlan 을 최신 값으로 갱신 + const ensureSavedBeforeSplit = useCallback(async (): Promise => { + if (!selectedPlan) return false; + if (!isModalDirty()) return true; + try { + const res = await updatePlan(selectedPlan.id, { + plan_qty: modalQuantity, + start_date: modalStartDate, + end_date: modalEndDate, + equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null, + equipment_name: modalEquipmentId && modalEquipmentId !== "none" + ? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null + : null, + } as any); + if (!res.success) { + toast.error("저장 실패로 분할이 중단되었습니다"); + return false; + } + // selectedPlan 을 최신 값으로 동기화 (이후 로직에서 plan_qty 를 참조) + setSelectedPlan((prev) => prev ? ({ + ...prev, + plan_qty: modalQuantity, + start_date: modalStartDate, + end_date: modalEndDate, + equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null, + } as any) : prev); + return true; + } catch (err: any) { + toast.error("저장 실패로 분할이 중단되었습니다: " + extractErrMsg(err)); + return false; + } + }, [selectedPlan, isModalDirty, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId, equipmentList]); + + // 균등 분할 (2/3/4분할 버튼) + const handleSplitSchedule = useCallback(async (splitCount: number) => { + if (!selectedPlan || splitCount < 2) return; + // 모달 입력값 기준 (이후 자동 저장되므로 modalQuantity 가 진실) + const originalQty = Number(modalQuantity) || 0; + if (originalQty < splitCount) { + toast.error(`${splitCount}분할하려면 수량이 ${splitCount} 이상이어야 합니다`); + return; + } + if (selectedPlan.status && selectedPlan.status !== "planned") { + toast.error("계획 상태인 건만 분할할 수 있습니다"); + return; + } + const ok = await confirm(`이 계획을 ${splitCount}개로 균등 분할하시겠습니까?`, { + description: `수량 ${originalQty}이(가) ${splitCount}개로 나뉩니다.`, + confirmText: "분할", + }); + if (!ok) return; + + // dirty 면 자동 저장 + const saved = await ensureSavedBeforeSplit(); + if (!saved) return; + + const eachQty = Math.floor(originalQty / splitCount); + if (eachQty <= 0) { + toast.error("분할 수량이 부족합니다"); + return; + } + + let successCount = 0; + try { + // N-1회 호출: 매번 eachQty만큼 원본에서 떼어내 새 plan 생성 + for (let i = 0; i < splitCount - 1; i++) { + const res = await splitSchedule(selectedPlan.id, eachQty); + if (!res.success) throw new Error("분할 응답 실패"); + successCount++; + } + toast.success(`계획이 ${splitCount}개로 분할되었습니다`); + setScheduleModalOpen(false); + fetchPlans(); + fetchOrderSummary(); + } catch (err: any) { + const msg = extractErrMsg(err); + if (successCount > 0) { + toast.error(`분할 일부 실패 (${successCount + 1}개 생성됨): ${msg}`); + } else { + toast.error("분할 실패: " + msg); + } + fetchPlans(); + fetchOrderSummary(); + } + }, [selectedPlan, modalQuantity, fetchPlans, fetchOrderSummary, confirm, ensureSavedBeforeSplit]); + + // 수량 지정 분할 (원본에서 입력 수량만큼 떼어내기) + const handleCustomSplit = useCallback(async () => { + if (!selectedPlan) return; + const splitQty = Number(customSplitQty); + const originalQty = Number(modalQuantity) || 0; + if (!splitQty || splitQty < 1) { + toast.error("떼어낼 수량을 1 이상으로 입력하세요"); + return; + } + if (splitQty >= originalQty) { + toast.error("떼어낼 수량은 원본 수량보다 작아야 합니다"); + return; + } + if (selectedPlan.status && selectedPlan.status !== "planned") { + toast.error("계획 상태인 건만 분할할 수 있습니다"); + return; + } + const ok = await confirm(`이 계획에서 ${splitQty}만큼 떼어내시겠습니까?`, { + description: `원본 ${originalQty} → 원본 ${originalQty - splitQty} + 신규 ${splitQty}`, + confirmText: "분할", + }); + if (!ok) return; + + const saved = await ensureSavedBeforeSplit(); + if (!saved) return; - const handleSplitSchedule = useCallback(async (splitQty: number) => { - if (!selectedPlan || splitQty <= 0) return; try { const res = await splitSchedule(selectedPlan.id, splitQty); - if (res.success) { - toast.success("계획이 분할되었습니다"); - setScheduleModalOpen(false); - fetchPlans(); - } + if (!res.success) throw new Error("분할 응답 실패"); + toast.success(`${splitQty} 수량이 분리되었습니다`); + setCustomSplitQty(""); + setScheduleModalOpen(false); + fetchPlans(); + fetchOrderSummary(); } catch (err: any) { - toast.error("분할 실패: " + (err.message || "")); + toast.error("분할 실패: " + extractErrMsg(err)); + fetchPlans(); + fetchOrderSummary(); } - }, [selectedPlan, fetchPlans]); + }, [selectedPlan, modalQuantity, customSplitQty, fetchPlans, fetchOrderSummary, confirm, ensureSavedBeforeSplit]); // 병합 핸들러 const handleMergeSchedules = useCallback(async () => { @@ -780,11 +910,12 @@ export default function ProductionPlanManagementPage() { toast.success("계획이 병합되었습니다"); setSelectedPlanIds(new Set()); fetchPlans(); + fetchOrderSummary(); } } catch (err: any) { - toast.error("병합 실패: " + (err.message || "")); + toast.error("병합 실패: " + (err?.response?.data?.message || err.message || "")); } - }, [selectedPlanIds, rightTab, fetchPlans, confirm]); + }, [selectedPlanIds, rightTab, fetchPlans, fetchOrderSummary, confirm]); // 타임라인 이벤트 드래그 이동 const handleEventMove = useCallback(async (eventId: string | number, newStart: string, newEnd: string) => { @@ -796,11 +927,12 @@ export default function ProductionPlanManagementPage() { if (res.success) { toast.success("일정이 변경되었습니다"); fetchPlans(); + fetchOrderSummary(); } } catch (err: any) { toast.error("일정 변경 실패: " + (err.message || "")); } - }, [fetchPlans]); + }, [fetchPlans, fetchOrderSummary]); // 타임라인 이벤트 리사이즈 const handleEventResize = useCallback(async (eventId: string | number, newStart: string, newEnd: string) => { @@ -812,11 +944,12 @@ export default function ProductionPlanManagementPage() { if (res.success) { toast.success("기간이 변경되었습니다"); fetchPlans(); + fetchOrderSummary(); } } catch (err: any) { toast.error("기간 변경 실패: " + (err.message || "")); } - }, [fetchPlans]); + }, [fetchPlans, fetchOrderSummary]); // 불러오기 처리 const handleImportOrderItems = useCallback(async () => { @@ -1463,8 +1596,26 @@ export default function ProductionPlanManagementPage() { {/* ========== 모달들 ========== */} {/* 스케줄 상세/편집 모달 */} - - + { + // confirm 다이얼로그가 열려 있는 동안 발생하는 닫힘 이벤트(포커스 이탈 등)는 무시 + if (!v && isConfirmOpenRef.current) return; + setScheduleModalOpen(v); + }} + > + { + if (isConfirmOpenRef.current) e.preventDefault(); + }} + onInteractOutside={(e) => { + if (isConfirmOpenRef.current) e.preventDefault(); + }} + onFocusOutside={(e) => { + if (isConfirmOpenRef.current) e.preventDefault(); + }} + > @@ -1554,37 +1705,67 @@ export default function ProductionPlanManagementPage() { 계획 분할

+
+ {[2, 3, 4].map((n) => { + const canSplit = + modalQuantity >= n && + (selectedPlan?.status === "planned" || !selectedPlan?.status); + return ( + + ); + })} +
+
+

+ 하나의 생산계획을 선택한 개수만큼 균등 분할합니다. (수량 부족 또는 완료 상태는 불가) +

+ {/* 수량 지정 분할 */} +
+ + { + const v = e.target.value; + if (v === "") setCustomSplitQty(""); + else setCustomSplitQty(Math.max(0, Math.floor(Number(v) || 0))); + }} + className="h-7 w-28 text-xs" + placeholder="떼어낼 수량" + min={1} + max={Math.max(0, modalQuantity - 1)} + step={1} + /> + + / {modalQuantity} +
-

하나의 생산계획을 여러 개로 분할합니다.

- - -
-

추가 정보

-
-
- - setModalManager(e.target.value)} className="h-9 text-xs" placeholder="담당자명" /> -
-
- - setModalWorkOrderNo(e.target.value)} className="h-9 text-xs" placeholder="자동생성" /> -
-
- - setModalRemarks(e.target.value)} className="h-9 text-xs" placeholder="비고사항 입력" /> -
-
+

+ 입력한 수량만큼 떼어내 새 계획을 생성합니다. (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 15fe6741..15118ffc 100644 --- a/frontend/app/(main)/COMPANY_8/quality/item-inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_8/quality/item-inspection/page.tsx @@ -977,12 +977,13 @@ export default function ItemInspectionInfoPage() { 판단기준 합격기준 필수 + 단위 {selectedTabRows.length === 0 ? ( - 등록된 검사항목이 없어요 + 등록된 검사항목이 없어요 ) : selectedTabRows.map((row: any) => ( @@ -1015,6 +1016,14 @@ export default function ItemInspectionInfoPage() { 필수 ) : "-"} + + {(() => { + const insp = inspOptions.find(o => o.code === row.inspection_standard_id); + const unitCode = insp?.unit || ""; + const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode; + return unitLabel || "-"; + })()} + ))} @@ -1179,12 +1188,13 @@ export default function ItemInspectionInfoPage() { 판단기준 합격기준 (판단기준별) 필수 + 단위 {(!inspectionRows[key] || inspectionRows[key].length === 0) ? ( - 항목추가 버튼으로 검사항목을 추가하세요 + 항목추가 버튼으로 검사항목을 추가하세요 ) : inspectionRows[key].map((row) => ( @@ -1250,6 +1260,7 @@ export default function ItemInspectionInfoPage() { )} updateInspRow(key, row.id, "is_required", !!v)} /> + {row.unit || "-"} diff --git a/frontend/app/(main)/COMPANY_9/logistics/warehouse/page.tsx b/frontend/app/(main)/COMPANY_9/logistics/warehouse/page.tsx index 96b3d47e..c148cbf6 100644 --- a/frontend/app/(main)/COMPANY_9/logistics/warehouse/page.tsx +++ b/frontend/app/(main)/COMPANY_9/logistics/warehouse/page.tsx @@ -74,7 +74,7 @@ const WAREHOUSE_COLUMNS = [ { key: "warehouse_code", label: "창고코드" }, { key: "warehouse_name", label: "창고명" }, { key: "warehouse_type", label: "유형" }, - { key: "manager", label: "관리자" }, + { key: "manager_name", label: "관리자" }, { key: "status", label: "상태" }, ]; const LOCATION_TABLE = "warehouse_location"; @@ -239,6 +239,8 @@ export default function WarehouseManagementPage() { const raw = res.data?.data?.data || res.data?.data?.rows || []; const data = raw.map((r: any) => ({ ...r, + _warehouse_type_code: r.warehouse_type, + _status_code: r.status, warehouse_type: resolveCategory(categoryOptions, "warehouse_type", r.warehouse_type), status: resolveCategory(categoryOptions, "status", r.status), })); @@ -344,7 +346,11 @@ export default function WarehouseManagementPage() { const openWarehouseEditModal = (row: any) => { setWarehouseEditMode(true); - setWarehouseForm({ ...row }); + setWarehouseForm({ + ...row, + warehouse_type: row._warehouse_type_code ?? row.warehouse_type ?? "", + status: row._status_code ?? row.status ?? "", + }); setWarehouseModalOpen(true); }; @@ -374,10 +380,10 @@ export default function WarehouseManagementPage() { warehouse_code: finalWarehouseCode, warehouse_name: warehouseForm.warehouse_name?.trim(), warehouse_type: warehouseForm.warehouse_type || "", - manager: warehouseForm.manager || "", - address: warehouseForm.address || "", + manager_name: warehouseForm.manager_name || "", + contact: warehouseForm.contact || "", status: warehouseForm.status || "", - description: warehouseForm.description || "", + memo: warehouseForm.memo || "", }; // 신규 등록 시 창고코드 중복 체크 @@ -729,7 +735,7 @@ export default function WarehouseManagementPage() { 창고코드: r.warehouse_code, 창고명: r.warehouse_name, 유형: r.warehouse_type, - 관리자: r.manager, + 관리자: r.manager_name, 상태: r.status, })), "창고정보" @@ -1041,9 +1047,9 @@ export default function WarehouseManagementPage() {
- setWarehouseForm((prev) => ({ ...prev, manager: e.target.value })) + setWarehouseForm((prev) => ({ ...prev, manager_name: e.target.value })) } placeholder="관리자를 입력해주세요" /> @@ -1069,24 +1075,24 @@ export default function WarehouseManagementPage() {
- {/* 주소 (전체 너비) */} + {/* 연락처 (전체 너비) */}
- + - setWarehouseForm((prev) => ({ ...prev, address: e.target.value })) + setWarehouseForm((prev) => ({ ...prev, contact: e.target.value })) } - placeholder="주소를 입력해주세요" + placeholder="연락처를 입력해주세요" />
{/* 비고 (전체 너비) */}
- setWarehouseForm((prev) => ({ ...prev, description: e.target.value })) + setWarehouseForm((prev) => ({ ...prev, memo: e.target.value })) } placeholder="비고를 입력해주세요" /> diff --git a/frontend/app/(main)/COMPANY_9/production/plan-management/page.tsx b/frontend/app/(main)/COMPANY_9/production/plan-management/page.tsx index c1ee134d..6eae857e 100644 --- a/frontend/app/(main)/COMPANY_9/production/plan-management/page.tsx +++ b/frontend/app/(main)/COMPANY_9/production/plan-management/page.tsx @@ -185,9 +185,6 @@ export default function ProductionPlanManagementPage() { const [modalQuantity, setModalQuantity] = useState(0); const [modalStartDate, setModalStartDate] = useState(""); const [modalEndDate, setModalEndDate] = useState(""); - const [modalManager, setModalManager] = useState(""); - const [modalWorkOrderNo, setModalWorkOrderNo] = useState(""); - const [modalRemarks, setModalRemarks] = useState(""); const [modalEquipmentId, setModalEquipmentId] = useState(""); // 미리보기 데이터 @@ -200,7 +197,10 @@ export default function ProductionPlanManagementPage() { const [selectedPlanIds, setSelectedPlanIds] = useState>(new Set()); // useConfirmDialog - const { confirm, ConfirmDialogComponent } = useConfirmDialog(); + const { confirm, ConfirmDialogComponent, isConfirmOpenRef } = useConfirmDialog(); + + // 수량 지정 분할 입력값 + const [customSplitQty, setCustomSplitQty] = useState(""); // ========== 데이터 로드 ========== @@ -694,10 +694,8 @@ export default function ProductionPlanManagementPage() { setModalQuantity(Number(plan.plan_qty)); setModalStartDate(plan.start_date?.split("T")[0] || ""); setModalEndDate(plan.end_date?.split("T")[0] || ""); - setModalManager((plan as any).manager_name || ""); - setModalWorkOrderNo((plan as any).work_order_no || ""); - setModalRemarks(plan.remarks || ""); setModalEquipmentId((plan as any).equipment_code || (plan.equipment_id ? String(plan.equipment_id) : "")); + setCustomSplitQty(""); setScheduleModalOpen(true); }, []); @@ -709,9 +707,6 @@ export default function ProductionPlanManagementPage() { plan_qty: modalQuantity, start_date: modalStartDate, end_date: modalEndDate, - manager_name: modalManager, - work_order_no: modalWorkOrderNo, - remarks: modalRemarks, equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null, equipment_name: modalEquipmentId && modalEquipmentId !== "none" ? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null @@ -721,13 +716,14 @@ export default function ProductionPlanManagementPage() { toast.success("생산계획이 수정되었습니다"); setScheduleModalOpen(false); fetchPlans(); + fetchOrderSummary(); } } catch (err: any) { - toast.error("수정 실패: " + (err.message || "")); + toast.error("수정 실패: " + (err?.response?.data?.message || err.message || "")); } finally { setSaving(false); } - }, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalManager, modalWorkOrderNo, modalRemarks, modalEquipmentId, fetchPlans]); + }, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId, equipmentList, fetchPlans, fetchOrderSummary]); const handleDeletePlan = useCallback(async () => { if (!selectedPlan) return; @@ -741,24 +737,158 @@ export default function ProductionPlanManagementPage() { toast.success("삭제되었습니다"); setScheduleModalOpen(false); fetchPlans(); + fetchOrderSummary(); } catch (err: any) { - toast.error("삭제 실패: " + (err.message || "")); + toast.error("삭제 실패: " + (err?.response?.data?.message || err.message || "")); } - }, [selectedPlan, fetchPlans, confirm]); + }, [selectedPlan, fetchPlans, fetchOrderSummary, confirm]); + + // 에러 메시지 추출 헬퍼 + const extractErrMsg = (err: any): string => { + return err?.response?.data?.message || err?.message || ""; + }; + + // modalQuantity/일정/설비가 DB의 selectedPlan 값과 다른지 확인 (dirty 체크) + const isModalDirty = useCallback((): boolean => { + if (!selectedPlan) return false; + const planQty = Number(selectedPlan.plan_qty) || 0; + const planStart = selectedPlan.start_date?.split("T")[0] || ""; + const planEnd = selectedPlan.end_date?.split("T")[0] || ""; + const planEq = (selectedPlan as any).equipment_code || (selectedPlan.equipment_id ? String(selectedPlan.equipment_id) : ""); + return ( + planQty !== Number(modalQuantity) || + planStart !== modalStartDate || + planEnd !== modalEndDate || + planEq !== modalEquipmentId + ); + }, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId]); + + // dirty 상태면 자동 저장 후 selectedPlan 을 최신 값으로 갱신 + const ensureSavedBeforeSplit = useCallback(async (): Promise => { + if (!selectedPlan) return false; + if (!isModalDirty()) return true; + try { + const res = await updatePlan(selectedPlan.id, { + plan_qty: modalQuantity, + start_date: modalStartDate, + end_date: modalEndDate, + equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null, + equipment_name: modalEquipmentId && modalEquipmentId !== "none" + ? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null + : null, + } as any); + if (!res.success) { + toast.error("저장 실패로 분할이 중단되었습니다"); + return false; + } + // selectedPlan 을 최신 값으로 동기화 (이후 로직에서 plan_qty 를 참조) + setSelectedPlan((prev) => prev ? ({ + ...prev, + plan_qty: modalQuantity, + start_date: modalStartDate, + end_date: modalEndDate, + equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null, + } as any) : prev); + return true; + } catch (err: any) { + toast.error("저장 실패로 분할이 중단되었습니다: " + extractErrMsg(err)); + return false; + } + }, [selectedPlan, isModalDirty, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId, equipmentList]); + + // 균등 분할 (2/3/4분할 버튼) + const handleSplitSchedule = useCallback(async (splitCount: number) => { + if (!selectedPlan || splitCount < 2) return; + // 모달 입력값 기준 (이후 자동 저장되므로 modalQuantity 가 진실) + const originalQty = Number(modalQuantity) || 0; + if (originalQty < splitCount) { + toast.error(`${splitCount}분할하려면 수량이 ${splitCount} 이상이어야 합니다`); + return; + } + if (selectedPlan.status && selectedPlan.status !== "planned") { + toast.error("계획 상태인 건만 분할할 수 있습니다"); + return; + } + const ok = await confirm(`이 계획을 ${splitCount}개로 균등 분할하시겠습니까?`, { + description: `수량 ${originalQty}이(가) ${splitCount}개로 나뉩니다.`, + confirmText: "분할", + }); + if (!ok) return; + + // dirty 면 자동 저장 + const saved = await ensureSavedBeforeSplit(); + if (!saved) return; + + const eachQty = Math.floor(originalQty / splitCount); + if (eachQty <= 0) { + toast.error("분할 수량이 부족합니다"); + return; + } + + let successCount = 0; + try { + // N-1회 호출: 매번 eachQty만큼 원본에서 떼어내 새 plan 생성 + for (let i = 0; i < splitCount - 1; i++) { + const res = await splitSchedule(selectedPlan.id, eachQty); + if (!res.success) throw new Error("분할 응답 실패"); + successCount++; + } + toast.success(`계획이 ${splitCount}개로 분할되었습니다`); + setScheduleModalOpen(false); + fetchPlans(); + fetchOrderSummary(); + } catch (err: any) { + const msg = extractErrMsg(err); + if (successCount > 0) { + toast.error(`분할 일부 실패 (${successCount + 1}개 생성됨): ${msg}`); + } else { + toast.error("분할 실패: " + msg); + } + fetchPlans(); + fetchOrderSummary(); + } + }, [selectedPlan, modalQuantity, fetchPlans, fetchOrderSummary, confirm, ensureSavedBeforeSplit]); + + // 수량 지정 분할 (원본에서 입력 수량만큼 떼어내기) + const handleCustomSplit = useCallback(async () => { + if (!selectedPlan) return; + const splitQty = Number(customSplitQty); + const originalQty = Number(modalQuantity) || 0; + if (!splitQty || splitQty < 1) { + toast.error("떼어낼 수량을 1 이상으로 입력하세요"); + return; + } + if (splitQty >= originalQty) { + toast.error("떼어낼 수량은 원본 수량보다 작아야 합니다"); + return; + } + if (selectedPlan.status && selectedPlan.status !== "planned") { + toast.error("계획 상태인 건만 분할할 수 있습니다"); + return; + } + const ok = await confirm(`이 계획에서 ${splitQty}만큼 떼어내시겠습니까?`, { + description: `원본 ${originalQty} → 원본 ${originalQty - splitQty} + 신규 ${splitQty}`, + confirmText: "분할", + }); + if (!ok) return; + + const saved = await ensureSavedBeforeSplit(); + if (!saved) return; - const handleSplitSchedule = useCallback(async (splitQty: number) => { - if (!selectedPlan || splitQty <= 0) return; try { const res = await splitSchedule(selectedPlan.id, splitQty); - if (res.success) { - toast.success("계획이 분할되었습니다"); - setScheduleModalOpen(false); - fetchPlans(); - } + if (!res.success) throw new Error("분할 응답 실패"); + toast.success(`${splitQty} 수량이 분리되었습니다`); + setCustomSplitQty(""); + setScheduleModalOpen(false); + fetchPlans(); + fetchOrderSummary(); } catch (err: any) { - toast.error("분할 실패: " + (err.message || "")); + toast.error("분할 실패: " + extractErrMsg(err)); + fetchPlans(); + fetchOrderSummary(); } - }, [selectedPlan, fetchPlans]); + }, [selectedPlan, modalQuantity, customSplitQty, fetchPlans, fetchOrderSummary, confirm, ensureSavedBeforeSplit]); // 병합 핸들러 const handleMergeSchedules = useCallback(async () => { @@ -780,11 +910,12 @@ export default function ProductionPlanManagementPage() { toast.success("계획이 병합되었습니다"); setSelectedPlanIds(new Set()); fetchPlans(); + fetchOrderSummary(); } } catch (err: any) { - toast.error("병합 실패: " + (err.message || "")); + toast.error("병합 실패: " + (err?.response?.data?.message || err.message || "")); } - }, [selectedPlanIds, rightTab, fetchPlans, confirm]); + }, [selectedPlanIds, rightTab, fetchPlans, fetchOrderSummary, confirm]); // 타임라인 이벤트 드래그 이동 const handleEventMove = useCallback(async (eventId: string | number, newStart: string, newEnd: string) => { @@ -796,11 +927,12 @@ export default function ProductionPlanManagementPage() { if (res.success) { toast.success("일정이 변경되었습니다"); fetchPlans(); + fetchOrderSummary(); } } catch (err: any) { toast.error("일정 변경 실패: " + (err.message || "")); } - }, [fetchPlans]); + }, [fetchPlans, fetchOrderSummary]); // 타임라인 이벤트 리사이즈 const handleEventResize = useCallback(async (eventId: string | number, newStart: string, newEnd: string) => { @@ -812,11 +944,12 @@ export default function ProductionPlanManagementPage() { if (res.success) { toast.success("기간이 변경되었습니다"); fetchPlans(); + fetchOrderSummary(); } } catch (err: any) { toast.error("기간 변경 실패: " + (err.message || "")); } - }, [fetchPlans]); + }, [fetchPlans, fetchOrderSummary]); // 불러오기 처리 const handleImportOrderItems = useCallback(async () => { @@ -1463,8 +1596,26 @@ export default function ProductionPlanManagementPage() { {/* ========== 모달들 ========== */} {/* 스케줄 상세/편집 모달 */} - - + { + // confirm 다이얼로그가 열려 있는 동안 발생하는 닫힘 이벤트(포커스 이탈 등)는 무시 + if (!v && isConfirmOpenRef.current) return; + setScheduleModalOpen(v); + }} + > + { + if (isConfirmOpenRef.current) e.preventDefault(); + }} + onInteractOutside={(e) => { + if (isConfirmOpenRef.current) e.preventDefault(); + }} + onFocusOutside={(e) => { + if (isConfirmOpenRef.current) e.preventDefault(); + }} + > @@ -1554,37 +1705,67 @@ export default function ProductionPlanManagementPage() { 계획 분할

+
+ {[2, 3, 4].map((n) => { + const canSplit = + modalQuantity >= n && + (selectedPlan?.status === "planned" || !selectedPlan?.status); + return ( + + ); + })} +
+
+

+ 하나의 생산계획을 선택한 개수만큼 균등 분할합니다. (수량 부족 또는 완료 상태는 불가) +

+ {/* 수량 지정 분할 */} +
+ + { + const v = e.target.value; + if (v === "") setCustomSplitQty(""); + else setCustomSplitQty(Math.max(0, Math.floor(Number(v) || 0))); + }} + className="h-7 w-28 text-xs" + placeholder="떼어낼 수량" + min={1} + max={Math.max(0, modalQuantity - 1)} + step={1} + /> + + / {modalQuantity} +
-

하나의 생산계획을 여러 개로 분할합니다.

- - -
-

추가 정보

-
-
- - setModalManager(e.target.value)} className="h-9 text-xs" placeholder="담당자명" /> -
-
- - setModalWorkOrderNo(e.target.value)} className="h-9 text-xs" placeholder="자동생성" /> -
-
- - setModalRemarks(e.target.value)} className="h-9 text-xs" placeholder="비고사항 입력" /> -
-
+

+ 입력한 수량만큼 떼어내 새 계획을 생성합니다. (1 이상, 원본 수량 미만) +

)} diff --git a/frontend/app/(main)/COMPANY_9/quality/item-inspection/page.tsx b/frontend/app/(main)/COMPANY_9/quality/item-inspection/page.tsx index d32103fd..9555117e 100644 --- a/frontend/app/(main)/COMPANY_9/quality/item-inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_9/quality/item-inspection/page.tsx @@ -977,12 +977,13 @@ export default function ItemInspectionInfoPage() { 판단기준 합격기준 필수 + 단위
{selectedTabRows.length === 0 ? ( - 등록된 검사항목이 없어요 + 등록된 검사항목이 없어요 ) : selectedTabRows.map((row: any) => ( @@ -1015,6 +1016,14 @@ export default function ItemInspectionInfoPage() { 필수 ) : "-"} + + {(() => { + const insp = inspOptions.find(o => o.code === row.inspection_standard_id); + const unitCode = insp?.unit || ""; + const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode; + return unitLabel || "-"; + })()} + ))} @@ -1179,12 +1188,13 @@ export default function ItemInspectionInfoPage() { 판단기준 합격기준 (판단기준별) 필수 + 단위 {(!inspectionRows[key] || inspectionRows[key].length === 0) ? ( - 항목추가 버튼으로 검사항목을 추가하세요 + 항목추가 버튼으로 검사항목을 추가하세요 ) : inspectionRows[key].map((row) => ( @@ -1250,6 +1260,7 @@ export default function ItemInspectionInfoPage() { )} updateInspRow(key, row.id, "is_required", !!v)} /> + {row.unit || "-"} diff --git a/frontend/components/common/TimelineScheduler.tsx b/frontend/components/common/TimelineScheduler.tsx index a4b8e8b8..fd1a8254 100644 --- a/frontend/components/common/TimelineScheduler.tsx +++ b/frontend/components/common/TimelineScheduler.tsx @@ -223,6 +223,9 @@ export default function TimelineScheduler({ const gridRef = useRef(null); const scrollRef = useRef(null); + // 드래그 이동(move) 직후 자동 발생하는 click 이벤트 1회를 무시하기 위한 플래그. + // 드래그로 일정이 변경된 직후에 모달이 자동 오픈되면서 이전 날짜가 표시되는 버그(TASK:ERP-006) 방지용. + const justDraggedRef = useRef(false); // 줌 레벨 동기화 useEffect(() => { @@ -404,6 +407,12 @@ export default function TimelineScheduler({ const newStart = toDateStr(addDays(origStart, dayOffset)); const newEnd = toDateStr(addDays(origEnd, dayOffset)); onEventMove?.(dragState.eventId, newStart, newEnd); + // 드래그 직후 브라우저가 자동 디스패치하는 click 이벤트 1회를 무시해 + // 모달이 이전 날짜로 자동 오픈되는 버그(TASK:ERP-006) 방지. + justDraggedRef.current = true; + setTimeout(() => { + justDraggedRef.current = false; + }, 0); } else if (dragState.mode === "resize-left") { const newStart = toDateStr(addDays(origStart, dayOffset)); const newEnd = dragState.origEndDate.split("T")[0]; @@ -411,12 +420,20 @@ export default function TimelineScheduler({ if (parseDate(newStart) <= parseDate(newEnd)) { onEventResize?.(dragState.eventId, newStart, newEnd); } + justDraggedRef.current = true; + setTimeout(() => { + justDraggedRef.current = false; + }, 0); } else if (dragState.mode === "resize-right") { const newStart = dragState.origStartDate.split("T")[0]; const newEnd = toDateStr(addDays(origEnd, dayOffset)); if (parseDate(newStart) <= parseDate(newEnd)) { onEventResize?.(dragState.eventId, newStart, newEnd); } + justDraggedRef.current = true; + setTimeout(() => { + justDraggedRef.current = false; + }, 0); } } @@ -770,6 +787,11 @@ export default function TimelineScheduler({ }} title={`${ev.label || ""} | ${ev.startDate.split("T")[0]} ~ ${ev.endDate.split("T")[0]}${progress > 0 ? ` | ${progress}%` : ""}`} onClick={(e) => { + // 드래그 직후 자동 발생하는 click은 무시 (TASK:ERP-006). + if (justDraggedRef.current) { + e.stopPropagation(); + return; + } if (!isDragging) { e.stopPropagation(); onEventClick?.(ev); diff --git a/frontend/lib/api/processInfo.ts b/frontend/lib/api/processInfo.ts index b52272a5..519d3167 100644 --- a/frontend/lib/api/processInfo.ts +++ b/frontend/lib/api/processInfo.ts @@ -58,6 +58,7 @@ export interface RoutingDetail { work_type: string; standard_time: string; outsource_supplier: string; + outsource_supplier_list?: string[]; } interface ApiResponse { diff --git a/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx b/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx index ef5af8f4..eda81258 100644 --- a/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx +++ b/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx @@ -191,7 +191,7 @@ export function BomTreeComponent({ item_number: headerData.item_code || "", quantity: "-", base_qty: headerData.base_qty || "", - unit: headerData.unit || "", + unit: headerData.unit || (headerData as any).item_unit || (headerData as any).item_inventory_unit || (headerData as any).inventory_unit || "", revision: headerData.revision || "", loss_rate: "", process_type: "", @@ -311,7 +311,7 @@ export function BomTreeComponent({ item_name: raw.item_name || "", item_code: raw.item_number || raw.item_code || "", item_type: raw.item_type || raw.division || "", - unit: raw.unit || raw.item_unit || "", + unit: raw.unit || raw.item_unit || raw.item_inventory_unit || raw.inventory_unit || "", } as BomHeaderInfo; } } catch (e) {