From 70b555b5bd2ec1dc79d008d53a0c1f48e97f0922 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Thu, 2 Apr 2026 12:04:37 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20work=5Forder=5Fprocess=20+=20process=5Fw?= =?UTF-8?q?ork=5Fresult=20id=20NOT=20NULL=20=EC=88=98=EC=A0=95=20feat:=20M?= =?UTF-8?q?ES=20=EA=B3=B5=EC=A0=95=EC=8B=A4=ED=96=89=20=EA=B5=AC=EB=B2=84?= =?UTF-8?q?=EC=A0=84=20=EA=B8=B0=EB=B0=98=20=EC=9E=AC=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?(=ED=83=80=EC=9E=84=EB=9D=BC=EC=9D=B8+=EC=B2=B4=ED=81=AC?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8+=EC=8B=A4=EC=A0=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - popProductionController: INSERT 3곳 gen_random_uuid() 추가 - WorkOrderList: 공정 타임라인, 탭 카운트, 그룹핑 - ProcessWork: 사이드바+체크리스트+실적+생산입고 (PopWorkDetail 참고) - 공정 3건 + 체크리스트 3건 생성 성공 (cmux+psql 검증) --- .../controllers/popProductionController.ts | 16 +- .../pop/hardcoded/production/ProcessWork.tsx | 1243 ++++++++++++----- .../hardcoded/production/WorkOrderList.tsx | 471 ++++--- 3 files changed, 1169 insertions(+), 561 deletions(-) diff --git a/backend-node/src/controllers/popProductionController.ts b/backend-node/src/controllers/popProductionController.ts index 03b41266..6cb5764d 100644 --- a/backend-node/src/controllers/popProductionController.ts +++ b/backend-node/src/controllers/popProductionController.ts @@ -29,7 +29,7 @@ async function copyChecklistToSplit( if (routingDetailId) { const result = await client.query( `INSERT INTO process_work_result ( - company_code, work_order_process_id, + id, company_code, work_order_process_id, source_work_item_id, source_detail_id, work_phase, item_title, item_sort_order, detail_content, detail_type, detail_sort_order, is_required, @@ -38,7 +38,7 @@ async function copyChecklistToSplit( status, writer ) SELECT - pwi.company_code, $1, + gen_random_uuid()::text, pwi.company_code, $1, pwi.id, pwd.id, pwi.work_phase, pwi.title, pwi.sort_order::text, pwd.content, pwd.detail_type, pwd.sort_order::text, pwd.is_required, @@ -140,10 +140,10 @@ async function generateWorkProcessesForInstruction( // 2. work_order_process INSERT const wopResult = await client.query( `INSERT INTO work_order_process ( - company_code, wo_id, seq_no, process_code, process_name, + id, company_code, wo_id, seq_no, process_code, process_name, is_required, is_fixed_order, standard_time, plan_qty, status, routing_detail_id, writer - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + ) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id`, [ companyCode, @@ -920,13 +920,13 @@ export const saveResult = async ( const masterId = proc.parent_process_id || work_order_process_id; const reworkInsert = await pool.query( `INSERT INTO work_order_process ( - wo_id, seq_no, process_code, process_name, is_required, is_fixed_order, + id, wo_id, seq_no, process_code, process_name, is_required, is_fixed_order, standard_time, equipment_code, routing_detail_id, status, input_qty, good_qty, defect_qty, concession_qty, total_production_qty, result_status, is_rework, rework_source_id, parent_process_id, company_code, writer ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, + gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, 'acceptable', $10, '0', '0', '0', '0', 'draft', 'Y', $11, $12, $13, $14 @@ -1642,13 +1642,13 @@ export const acceptProcess = async (req: AuthenticatedRequest, res: Response) => // 분할 행 INSERT (원본 행에서 공정 정보 복사) const result = await pool.query( `INSERT INTO work_order_process ( - wo_id, seq_no, process_code, process_name, is_required, is_fixed_order, + id, wo_id, seq_no, process_code, process_name, is_required, is_fixed_order, standard_time, equipment_code, routing_detail_id, status, input_qty, good_qty, defect_qty, total_production_qty, result_status, accepted_by, accepted_at, started_at, parent_process_id, company_code, writer ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, + gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, 'in_progress', $10, '0', '0', '0', 'draft', $11, NOW()::text, NOW()::text, $12, $13, $11 diff --git a/frontend/components/pop/hardcoded/production/ProcessWork.tsx b/frontend/components/pop/hardcoded/production/ProcessWork.tsx index 93523892..b40bc342 100644 --- a/frontend/components/pop/hardcoded/production/ProcessWork.tsx +++ b/frontend/components/pop/hardcoded/production/ProcessWork.tsx @@ -1,8 +1,9 @@ "use client"; -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useCallback, useMemo } from "react"; import { useRouter } from "next/navigation"; import { apiClient } from "@/lib/api/client"; +import { dataApi } from "@/lib/api/data"; import { ProcessTimer, type TimerStatus } from "./ProcessTimer"; import { DefectTypeModal, type DefectEntry, type DefectType } from "./DefectTypeModal"; @@ -49,18 +50,32 @@ interface WorkInstructionInfo { interface ChecklistItem { id: string; + work_order_process_id: string; + source_work_item_id: string; + source_detail_id: string; work_phase: string; item_title: string; + item_sort_order: string; detail_content: string; detail_type: string; - is_required: string; + detail_sort_order: string; + is_required: string | null; result_value: string | null; is_passed: string | null; status: string; inspection_code: string | null; + inspection_method: string | null; unit: string | null; lower_limit: string | null; upper_limit: string | null; + input_type: string | null; + group_started_at: string | null; + group_paused_at: string | null; + group_total_paused_time: string | null; + group_completed_at: string | null; + recorded_by: string | null; + recorded_at: string | null; + started_at: string | null; } interface Warehouse { @@ -75,8 +90,13 @@ interface WarehouseLocation { location_name: string; } +type ActiveSection = "checklist" | "result" | "inventory"; + +const PHASE_ORDER: Record = { PRE: 1, IN: 2, POST: 3 }; +const PHASE_LABELS: Record = { PRE: "작업 전", IN: "작업 중", POST: "작업 후" }; + /* ------------------------------------------------------------------ */ -/* Numpad Keys (for production qty) */ +/* Numpad Keys */ /* ------------------------------------------------------------------ */ const KEYS = [ @@ -211,6 +231,23 @@ function SimpleNumpadModal({ ); } +/* ------------------------------------------------------------------ */ +/* Checklist Group */ +/* ------------------------------------------------------------------ */ + +interface ChecklistGroup { + phase: string; + title: string; + itemId: string; + sortOrder: number; + items: ChecklistItem[]; + completed: number; + total: number; + timerStarted: boolean; + timerCompleted: boolean; + timerPaused: boolean; +} + /* ------------------------------------------------------------------ */ /* Component */ /* ------------------------------------------------------------------ */ @@ -230,6 +267,10 @@ export function ProcessWork({ processId }: ProcessWorkProps) { const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); + // Active section (left sidebar) + const [activeSection, setActiveSection] = useState("checklist"); + const [selectedGroupId, setSelectedGroupId] = useState(null); + // Production input const [productionQty, setProductionQty] = useState(0); const [defectEntries, setDefectEntries] = useState([]); @@ -245,61 +286,51 @@ export function ProcessWork({ processId }: ProcessWorkProps) { const [warehouseLocations, setWarehouseLocations] = useState([]); const [selectedWarehouse, setSelectedWarehouse] = useState(""); const [selectedLocation, setSelectedLocation] = useState(""); - const [showWarehousePanel, setShowWarehousePanel] = useState(false); const [inboundDone, setInboundDone] = useState(false); /* ---- Fetch Data ---- */ const fetchProcess = useCallback(async () => { setLoading(true); try { - // We don't have a direct "get process by id" API - // But we can check available-qty which returns related info - // And we can use the process data from the result of accept/save/etc. - - // For the prototype, let's use available-qty to get basic info, - // and supplement with checklist query - - // 1. Available qty (gives us seq_no, wo_id, etc.) - const aqRes = await apiClient.get("/pop/production/available-qty", { - params: { work_order_process_id: processId }, + // 1. Fetch process data directly from table + const procRes = await dataApi.getTableData("work_order_process", { + size: 1, + filters: { id: processId }, }); - const aqData = aqRes.data?.data; + const procData = (procRes.data?.[0] ?? null) as ProcessData | null; + if (procData) { + setProcess(procData); - // 2. Get process details - we'll create synthetic data from available info - // In production, there should be a dedicated endpoint. For now use what we have. - const processData: ProcessData = { - id: processId, - wo_id: "", - seq_no: "", - process_code: "", - process_name: "", - status: "in_progress", - plan_qty: String(aqData?.instructionQty || 0), - input_qty: "0", - good_qty: "0", - defect_qty: "0", - concession_qty: "0", - total_production_qty: "0", - parent_process_id: null, - result_status: "draft", - result_note: "", - started_at: null, - paused_at: null, - total_paused_time: null, - completed_at: null, - actual_work_time: null, - accepted_at: null, - accepted_by: null, - defect_detail: null, - target_warehouse_id: null, - target_location_code: null, - is_rework: "N", - routing_detail_id: null, - }; + // 2. Fetch work instruction info + if (procData.wo_id) { + try { + const wiRes = await dataApi.getTableData("work_instruction", { + size: 1, + filters: { id: procData.wo_id }, + }); + const wi = wiRes.data?.[0] as any; + if (wi) { + setWiInfo({ + work_instruction_no: wi.work_instruction_no || "", + item_name: wi.item_name || "", + item_code: wi.item_code || "", + qty: parseInt(wi.qty, 10) || 0, + }); + } + } catch { + // Non-critical + } + } + } - setProcess(processData); + // 3. Fetch checklist (process_work_result) + const checkRes = await dataApi.getTableData("process_work_result", { + size: 500, + filters: { work_order_process_id: processId }, + }); + setChecklist((checkRes.data ?? []) as ChecklistItem[]); - // 3. Defect types + // 4. Defect types try { const dtRes = await apiClient.get("/pop/production/defect-types"); setDefectTypes(dtRes.data?.data || []); @@ -307,7 +338,7 @@ export function ProcessWork({ processId }: ProcessWorkProps) { setDefectTypes([]); } - // 4. Is last process + // 5. Is last process try { const lpRes = await apiClient.get(`/pop/production/is-last-process/${processId}`); const lpData = lpRes.data?.data; @@ -321,7 +352,7 @@ export function ProcessWork({ processId }: ProcessWorkProps) { setIsLastProcess(false); } - // 5. Warehouses (for last process) + // 6. Warehouses try { const whRes = await apiClient.get("/pop/production/warehouses"); setWarehouses(whRes.data?.data || []); @@ -339,6 +370,78 @@ export function ProcessWork({ processId }: ProcessWorkProps) { fetchProcess(); }, [fetchProcess]); + /* ---- Checklist Groups ---- */ + const groups = useMemo(() => { + const map = new Map(); + for (const item of checklist) { + const key = item.source_work_item_id; + if (!map.has(key)) { + map.set(key, { + phase: item.work_phase, + title: item.item_title, + itemId: key, + sortOrder: parseInt(item.item_sort_order || "0", 10), + items: [], + completed: 0, + total: 0, + timerStarted: !!item.group_started_at, + timerCompleted: !!item.group_completed_at, + timerPaused: !!item.group_paused_at, + }); + } + const g = map.get(key)!; + g.items.push(item); + g.total++; + if (item.status === "recorded" || item.status === "completed") g.completed++; + // Update timer state (any row in the group has timer data) + if (item.group_started_at) g.timerStarted = true; + if (item.group_completed_at) g.timerCompleted = true; + if (item.group_paused_at) g.timerPaused = true; + } + + return Array.from(map.values()) + .sort((a, b) => (PHASE_ORDER[a.phase] ?? 9) - (PHASE_ORDER[b.phase] ?? 9) || a.sortOrder - b.sortOrder); + }, [checklist]); + + const groupsByPhase = useMemo(() => { + const result: Record = {}; + for (const g of groups) { + if (!result[g.phase]) result[g.phase] = []; + result[g.phase].push(g); + } + return result; + }, [groups]); + + const availablePhases = useMemo(() => { + const phases: string[] = []; + if (groupsByPhase["PRE"]?.length) phases.push("PRE"); + if (groupsByPhase["IN"]?.length) phases.push("IN"); + if (groupsByPhase["POST"]?.length) phases.push("POST"); + return phases; + }, [groupsByPhase]); + + // Auto-select first group + useEffect(() => { + if (groups.length > 0 && !selectedGroupId) { + setSelectedGroupId(groups[0].itemId); + } + }, [groups, selectedGroupId]); + + const selectedGroup = useMemo( + () => groups.find((g) => g.itemId === selectedGroupId) || null, + [groups, selectedGroupId] + ); + + const currentItems = useMemo( + () => + selectedGroup?.items + .sort( + (a, b) => + parseInt(a.detail_sort_order || "0", 10) - parseInt(b.detail_sort_order || "0", 10) + ) || [], + [selectedGroup] + ); + /* ---- Timer handlers ---- */ const timerStatus: TimerStatus = (() => { if (!process) return "idle"; @@ -350,32 +453,120 @@ export function ProcessWork({ processId }: ProcessWorkProps) { const handleTimerAction = async (action: "start" | "pause" | "resume" | "complete") => { try { - const res = await apiClient.post("/pop/production/timer", { + const body: Record = { work_order_process_id: processId, action, - }); + }; + if (action === "complete") { + body.good_qty = process?.good_qty || "0"; + body.defect_qty = process?.defect_qty || "0"; + } + const res = await apiClient.post("/pop/production/timer", body); if (res.data?.success) { - const d = res.data.data; - setProcess((prev) => { - if (!prev) return prev; - return { - ...prev, - started_at: d.started_at || prev.started_at, - paused_at: action === "pause" ? d.paused_at : action === "resume" ? null : prev.paused_at, - total_paused_time: d.total_paused_time || prev.total_paused_time, - completed_at: d.completed_at || prev.completed_at, - actual_work_time: d.actual_work_time || prev.actual_work_time, - status: d.status || prev.status, - good_qty: d.good_qty || prev.good_qty, - defect_qty: d.defect_qty || prev.defect_qty, - }; - }); + // Refetch to get fresh data + await fetchProcess(); } } catch (error: any) { alert(error.response?.data?.message || "타이머 오류"); } }; + /* ---- Group Timer handlers ---- */ + const handleGroupTimerAction = async ( + action: "start" | "pause" | "resume" | "complete", + groupItemId: string + ) => { + try { + await apiClient.post("/pop/production/group-timer", { + work_order_process_id: processId, + source_work_item_id: groupItemId, + action, + }); + await fetchProcess(); + } catch (error: any) { + alert(error.response?.data?.message || "그룹 타이머 오류"); + } + }; + + /* ---- Checklist save ---- */ + const handleChecklistSave = async ( + itemId: string, + resultValue: string, + isPassed: string | null + ) => { + try { + await apiClient.post("/pop/execute-action", { + tasks: [ + { + type: "data-update", + targetTable: "process_work_result", + targetColumn: "result_value", + operationType: "assign", + valueSource: "fixed", + fixedValue: resultValue, + lookupMode: "manual", + manualItemField: "id", + manualPkColumn: "id", + }, + { + type: "data-update", + targetTable: "process_work_result", + targetColumn: "status", + operationType: "assign", + valueSource: "fixed", + fixedValue: "recorded", + lookupMode: "manual", + manualItemField: "id", + manualPkColumn: "id", + }, + ...(isPassed !== null + ? [ + { + type: "data-update", + targetTable: "process_work_result", + targetColumn: "is_passed", + operationType: "assign", + valueSource: "fixed", + fixedValue: isPassed, + lookupMode: "manual", + manualItemField: "id", + manualPkColumn: "id", + }, + ] + : []), + { + type: "data-update", + targetTable: "process_work_result", + targetColumn: "recorded_at", + operationType: "assign", + valueSource: "fixed", + fixedValue: new Date().toISOString(), + lookupMode: "manual", + manualItemField: "id", + manualPkColumn: "id", + }, + ], + data: { items: [{ id: itemId }], fieldValues: {} }, + }); + // Update local state + setChecklist((prev) => + prev.map((c) => + c.id === itemId + ? { + ...c, + result_value: resultValue, + is_passed: isPassed, + status: "recorded", + recorded_at: new Date().toISOString(), + } + : c + ) + ); + } catch { + alert("체크리스트 저장 실패"); + } + }; + /* ---- Save Result ---- */ const handleSaveResult = async () => { if (productionQty <= 0) { @@ -397,24 +588,16 @@ export function ProcessWork({ processId }: ProcessWorkProps) { const d = res.data.data; setProcess((prev) => { if (!prev) return prev; - return { - ...prev, - total_production_qty: d.total_production_qty || prev.total_production_qty, - good_qty: d.good_qty || prev.good_qty, - defect_qty: d.defect_qty || prev.defect_qty, - concession_qty: d.concession_qty || prev.concession_qty, - defect_detail: d.defect_detail || prev.defect_detail, - result_note: d.result_note || prev.result_note, - result_status: d.result_status || prev.result_status, - status: d.status || prev.status, - input_qty: d.input_qty || prev.input_qty, - }; + return { ...prev, ...d }; }); - // Reset input setProductionQty(0); setDefectEntries([]); setResultNote(""); - alert("실적이 저장되었습니다."); + if (d?.status === "completed") { + alert("모든 수량이 완료되어 자동 확정되었습니다."); + } else { + alert("실적이 저장되었습니다."); + } } else { alert(res.data?.message || "저장 실패"); } @@ -434,15 +617,7 @@ export function ProcessWork({ processId }: ProcessWorkProps) { work_order_process_id: processId, }); if (res.data?.success) { - const d = res.data.data; - setProcess((prev) => { - if (!prev) return prev; - return { - ...prev, - status: d.status || "completed", - result_status: d.result_status || "confirmed", - }; - }); + await fetchProcess(); alert("실적이 확정되었습니다."); } else { alert(res.data?.message || "확정 실패"); @@ -463,14 +638,15 @@ export function ProcessWork({ processId }: ProcessWorkProps) { if (!confirm("생산입고를 진행하시겠습니까?")) return; setSaving(true); try { + const wh = warehouses.find((w) => w.id === selectedWarehouse); + const warehouseCode = wh?.warehouse_code || selectedWarehouse; const res = await apiClient.post("/pop/production/inventory-inbound", { work_order_process_id: processId, - warehouse_code: selectedWarehouse, + warehouse_code: warehouseCode, location_code: selectedLocation || undefined, }); if (res.data?.success) { setInboundDone(true); - setShowWarehousePanel(false); alert(`재고 입고 완료: ${res.data.data?.qty || 0}개`); } else { alert(res.data?.message || "입고 실패"); @@ -512,6 +688,7 @@ export function ProcessWork({ processId }: ProcessWorkProps) { const remaining = Math.max(0, inputQty - totalProduced); const isCompleted = process?.status === "completed"; const isConfirmed = process?.result_status === "confirmed"; + const hasChecklist = checklist.length > 0; /* ---- Loading ---- */ if (loading) { @@ -540,12 +717,20 @@ export function ProcessWork({ processId }: ProcessWorkProps) { } return ( -
+
{/* ====== Header Info Card ====== */} -
+
+ {process.seq_no ? `${process.seq_no}공정` : "공정"} {process.is_rework === "Y" && ( 재작업 @@ -615,271 +800,500 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
{/* ====== Timer ====== */} - handleTimerAction("start")} - onPause={() => handleTimerAction("pause")} - onResume={() => handleTimerAction("resume")} - onComplete={() => handleTimerAction("complete")} - disabled={saving} - /> +
+ handleTimerAction("start")} + onPause={() => handleTimerAction("pause")} + onResume={() => handleTimerAction("resume")} + onComplete={() => handleTimerAction("complete")} + disabled={saving} + /> +
- {/* ====== Production Input ====== */} - {!isConfirmed && ( -
-

- - - - 실적 입력 - {remaining > 0 && ( - 잔여: {remaining.toLocaleString()} - )} -

+ {/* ====== Main Content: Sidebar + Content ====== */} + {(hasChecklist || !isConfirmed || (isLastProcess && !inboundDone)) && ( +
+ {/* ---- Sidebar ---- */} +
+
+ {/* Checklist groups by phase */} + {availablePhases.map((phase) => { + const phaseGroups = groupsByPhase[phase] || []; + const phaseDone = phaseGroups.reduce((s, g) => s + g.completed, 0); + const phaseTotal = phaseGroups.reduce((s, g) => s + g.total, 0); + const allDone = phaseDone >= phaseTotal && phaseTotal > 0; - {/* Production Qty */} -
- - - {/* Defect */} - - - {/* Defect entries summary */} - {defectEntries.length > 0 && ( -
- {defectEntries.map((de) => ( - - {de.defect_name}: {de.qty}개 ({de.disposition === "scrap" ? "폐기" : de.disposition === "rework" ? "재작업" : "특채"}) - - ))} -
- )} - - {/* Good qty preview */} - {productionQty > 0 && ( -
- 이번 차수 양품: {goodQtyThisBatch.toLocaleString()} EA -
- )} - - {/* Note */} -