"use client"; import { useRouter } from "next/navigation"; import React, { useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { usePopSettings } from "@/hooks/pop/usePopSettings"; import { apiClient } from "@/lib/api/client"; import { dataApi } from "@/lib/api/data"; import { ConfirmModal } from "../common/ConfirmModal"; import { type DefectEntry, type DefectType, DefectTypeModal, } from "./DefectTypeModal"; import { ProcessTimer, type TimerStatus } from "./ProcessTimer"; /* ================================================================== */ /* Types */ /* ================================================================== */ interface ProcessData { id: string; wo_id: string; seq_no: string; process_code: string; process_name: string; status: string; plan_qty: string; input_qty: string; good_qty: string; defect_qty: string; concession_qty: string; total_production_qty: string; parent_process_id: string | null; result_status: string; result_note: string; started_at: string | null; paused_at: string | null; total_paused_time: string | null; completed_at: string | null; actual_work_time: string | null; accepted_at: string | null; accepted_by: string | null; defect_detail: string | null; target_warehouse_id: string | null; target_location_code: string | null; is_rework: string; routing_detail_id: string | null; batch_id?: string | null; } interface WorkInstructionInfo { work_instruction_no: string; item_name: string; item_code: string; qty: number; } 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; detail_label: string | null; detail_sort_order: string; is_required: string | null; result_value: string | null; is_passed: string | null; status: string; spec_value: string | null; 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 { id: string; warehouse_code: string; warehouse_name: string; } interface WarehouseLocation { id: string; location_code: string; location_name: string; } interface BatchHistoryItem { seq: number; batch_qty: number; batch_good: number; batch_defect: number; accumulated_total: number; changed_at: string; changed_by: string | null; } type ActiveSection = "checklist" | "result" | "inventory" | "material"; const PHASE_ORDER: Record = { PRE: 1, IN: 2, POST: 3 }; const PHASE_LABELS: Record = { PRE: "작업 전", IN: "작업 중", POST: "작업 후", }; /* ================================================================== */ /* ISA-101 Design Tokens */ /* ================================================================== */ const DESIGN = { bg: { page: "#F5F5F5", card: "#FFFFFF", header: "#1a1a2e", infoBar: "#1a1a2e", }, sidebar: { width: 280 }, timer: { fontSize: 48 }, button: { height: 60, touchMin: 48 }, input: { height: 52 }, footer: { height: 64 }, } as const; /* ================================================================== */ /* Numpad Modal */ /* ================================================================== */ const KEYS = [ { label: "7", action: "7" }, { label: "8", action: "8" }, { label: "9", action: "9" }, { label: "\u2190", action: "backspace" }, { label: "4", action: "4" }, { label: "5", action: "5" }, { label: "6", action: "6" }, { label: "C", action: "clear" }, { label: "1", action: "1" }, { label: "2", action: "2" }, { label: "3", action: "3" }, { label: "MAX", action: "max" }, ]; function SimpleNumpadModal({ open, onClose, onConfirm, maxQty, title, subtitle, }: { open: boolean; onClose: () => void; onConfirm: (qty: number) => void; maxQty: number; title: string; subtitle: string; }) { const [qty, setQty] = useState("0"); useEffect(() => { if (open) setQty("0"); }, [open]); const qtyNum = parseInt(qty, 10) || 0; const handleKey = useCallback( (key: string) => { setQty((prev) => { switch (key) { case "backspace": return prev.length <= 1 ? "0" : prev.slice(0, -1); case "clear": return "0"; case "max": return String(maxQty); default: { const next = prev === "0" ? key : prev + key; const num = parseInt(next, 10); if (isNaN(num)) return prev; return next; } } }); }, [maxQty], ); if (!open) return null; return (
{title}

{subtitle}

{KEYS.map((key) => ( ))}
); } /* ================================================================== */ /* Checklist Group */ /* ================================================================== */ interface ChecklistGroup { phase: string; title: string; itemId: string; sortOrder: number; items: ChecklistItem[]; completed: number; total: number; timerStarted: boolean; timerCompleted: boolean; timerPaused: boolean; timerState: { startedAt: string | null; pausedAt: string | null; totalPausedTime: number; completedAt: string | null; }; } /* ================================================================== */ /* Helper: formatTime */ /* ================================================================== */ function formatTime(totalSeconds: number): string { const h = Math.floor(totalSeconds / 3600); const m = Math.floor((totalSeconds % 3600) / 60); const s = totalSeconds % 60; return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`; } function calcGroupWorkSeconds(timer: ChecklistGroup["timerState"]): number { if (!timer.startedAt) return 0; const now = Date.now(); const start = new Date(timer.startedAt).getTime(); const end = timer.completedAt ? new Date(timer.completedAt).getTime() : now; let pausedMs = timer.totalPausedTime * 1000; if (timer.pausedAt && !timer.completedAt) { pausedMs += now - new Date(timer.pausedAt).getTime(); } return Math.max(0, Math.floor((end - start - pausedMs) / 1000)); } /* ================================================================== */ /* Main Component */ /* ================================================================== */ interface ProcessWorkProps { processId: string; } export function ProcessWork({ processId }: ProcessWorkProps) { const router = useRouter(); const contentRef = useRef(null); /* ---- Core State ---- */ const { settings: popSettings } = usePopSettings(); const peSettings = popSettings.screens.processExecution; const [process, setProcess] = useState(null); const [wiInfo, setWiInfo] = useState(null); const [checklist, setChecklist] = useState([]); const [defectTypes, setDefectTypes] = useState([]); const [processList, setProcessList] = useState< Array<{ process_code: string; process_name: string }> >([]); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); /* ---- Navigation State ---- */ const [activeSection, setActiveSection] = useState("checklist"); const [selectedGroupId, setSelectedGroupId] = useState(null); /* ---- Timer tick ---- */ const [tick, setTick] = useState(Date.now()); /* ---- Production Input ---- */ const [productionQty, setProductionQty] = useState(0); const [defectEntries, setDefectEntries] = useState([]); const [resultNote, setResultNote] = useState(""); /* ---- Modals ---- */ const [prodQtyModal, setProdQtyModal] = useState(false); const [defectModal, setDefectModal] = useState(false); const [confirmModalState, setConfirmModalState] = useState<{ open: boolean; message: string; title?: string; confirmText?: string; variant?: "primary" | "danger" | "success"; onConfirm: () => void; }>({ open: false, message: "", onConfirm: () => {} }); const askConfirm = ( message: string, onConfirm: () => void, opts?: { title?: string; confirmText?: string; variant?: "primary" | "danger" | "success"; }, ) => { setConfirmModalState({ open: true, message, title: opts?.title, confirmText: opts?.confirmText, variant: opts?.variant, onConfirm: () => { setConfirmModalState((s) => ({ ...s, open: false })); onConfirm(); }, }); }; /* ---- Last Process / Warehouse ---- */ const [isLastProcess, setIsLastProcess] = useState(false); const [warehouses, setWarehouses] = useState([]); const [warehouseLocations, setWarehouseLocations] = useState< WarehouseLocation[] >([]); const [selectedWarehouse, setSelectedWarehouse] = useState(""); const [selectedLocation, setSelectedLocation] = useState(""); const [packageUnit, setPackageUnit] = useState(""); const [inboundDone, setInboundDone] = useState(false); /* ---- Batch Badge (단일/다중품목) ---- */ const [batchBadge, setBatchBadge] = useState<{ isMulti: boolean; index: number; total: number; itemType: string; } | null>(null); /* ---- Batch History ---- */ const [history, setHistory] = useState([]); const [historyLoading, setHistoryLoading] = useState(false); /* ================================================================ */ /* Data Fetch */ /* ================================================================ */ const fetchProcess = useCallback(async () => { setLoading(true); try { // 1. Fetch process data const procRes = await dataApi.getTableData("work_order_process", { size: 1, filters: { id: processId }, }); const procData = (procRes.data?.[0] ?? null) as ProcessData | null; if (procData) { setProcess(procData); // 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 Record | undefined; if (wi) { let itemName = String(wi.item_name || ""); let itemCode = String(wi.item_code || wi.item_number || ""); // item_name이 비어있고 item_id가 있으면 item_info에서 조회 if (!itemName && wi.item_id) { try { const itemRes = await apiClient.get( `/data/item_info/${wi.item_id}`, ); const item = itemRes.data?.data; if (item) { itemName = String(item.item_name || ""); itemCode = String(item.item_number || item.item_code || ""); } } catch { /* non-critical */ } } // batch_id가 있으면 해당 품목의 이름을 조회 (다중 품목 지원) let batchItemType = ""; if (procData.batch_id) { try { const batchItemRes = await dataApi.getTableData("item_info", { size: 1, filters: { item_number: procData.batch_id }, }); const batchItem = batchItemRes.data?.[0] as Record | undefined; if (batchItem) { itemName = String(batchItem.item_name || procData.batch_id); itemCode = String(batchItem.item_number || procData.batch_id); batchItemType = String(batchItem.type || ""); } else { itemName = procData.batch_id; itemCode = procData.batch_id; } } catch { itemName = procData.batch_id; itemCode = procData.batch_id; } } // item_type이 없으면 WI의 item_number로 조회 if (!batchItemType && wi.item_number) { try { const wiItemRes = await dataApi.getTableData("item_info", { size: 1, filters: { item_number: String(wi.item_number) }, }); const wiItem = wiItemRes.data?.[0] as Record | undefined; if (wiItem) { batchItemType = String(wiItem.type || ""); } } catch { /* non-critical */ } } // batchItemType을 임시 저장 (step 6에서 사용) (procData as unknown as Record)._itemType = batchItemType; setWiInfo({ work_instruction_no: String(wi.work_instruction_no || ""), item_name: itemName, item_code: itemCode, qty: parseInt(String(wi.qty), 10) || 0, }); } } catch { // Non-critical } } } // 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[]); // 4. Defect types try { const dtRes = await apiClient.get("/pop/production/defect-types"); setDefectTypes(dtRes.data?.data || []); } catch { setDefectTypes([]); } // 5. Is last process try { const lpRes = await apiClient.get( `/pop/production/is-last-process/${processId}`, ); const lpData = lpRes.data?.data; setIsLastProcess(lpData?.isLast || false); if (lpData?.targetWarehouseId) { setSelectedWarehouse(lpData.targetWarehouseId); setSelectedLocation(lpData.targetLocationCode || ""); setInboundDone(true); } } catch { setIsLastProcess(false); } // 6. 같은 작업지시의 공정 목록 (재작업 공정 지정용) if (procData?.wo_id) { try { const plRes = await dataApi.getTableData("work_order_process", { size: 100, filters: { wo_id: procData.wo_id }, }); const allSiblings = (plRes.data ?? []) as ProcessData[]; const masters = allSiblings .filter((p) => !p.parent_process_id) .sort((a, b) => parseInt(a.seq_no, 10) - parseInt(b.seq_no, 10)); // 중복 제거 const seen = new Set(); setProcessList( masters.map((p) => ({ process_code: p.process_code, process_name: p.process_name, })).filter((m) => { if (seen.has(m.process_code)) return false; seen.add(m.process_code); return true; }), ); // 다중품목 판단: 마스터 공정의 DISTINCT batch_id const uniqueBatches: string[] = []; for (const p of masters) { const bid = p.batch_id || ""; if (bid && !uniqueBatches.includes(bid)) { uniqueBatches.push(bid); } } const currentBid = procData?.batch_id || ""; const isMultiBatch = uniqueBatches.length > 1; const bIdx = currentBid ? uniqueBatches.indexOf(currentBid) + 1 : 1; const fetchedItemType = String((procData as unknown as Record)?._itemType || ""); setBatchBadge({ isMulti: isMultiBatch, index: Math.max(bIdx, 1), total: Math.max(uniqueBatches.length, 1), itemType: fetchedItemType, }); } catch { setProcessList([]); } } // 7. Warehouses try { const whRes = await apiClient.get("/pop/production/warehouses"); setWarehouses(whRes.data?.data || []); } catch { setWarehouses([]); } } catch (error) { console.error("[ProcessWork] fetch error:", error); } finally { setLoading(false); } }, [processId]); useEffect(() => { fetchProcess(); }, [fetchProcess]); /* ---- Batch History ---- */ const loadHistory = useCallback(async () => { setHistoryLoading(true); try { const res = await apiClient.get("/pop/production/result-history", { params: { work_order_process_id: processId }, }); if (res.data?.success) { setHistory(res.data.data || []); } } catch { /* ignore */ } finally { setHistoryLoading(false); } }, [processId]); useEffect(() => { loadHistory(); }, [loadHistory]); /* ---- Timer tick for group timers ---- */ useEffect(() => { const id = setInterval(() => setTick(Date.now()), 1000); return () => clearInterval(id); }, []); /* ================================================================ */ /* 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, timerState: { startedAt: item.group_started_at ?? null, pausedAt: item.group_paused_at ?? null, totalPausedTime: parseInt(item.group_total_paused_time || "0", 10), completedAt: item.group_completed_at ?? null, }, }); } 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 may have timer data) if (item.group_started_at) { g.timerStarted = true; g.timerState.startedAt = item.group_started_at; } if (item.group_completed_at) { g.timerCompleted = true; g.timerState.completedAt = item.group_completed_at; } if (item.group_paused_at) { g.timerPaused = true; g.timerState.pausedAt = item.group_paused_at; } if (item.group_total_paused_time) { g.timerState.totalPausedTime = parseInt(item.group_total_paused_time, 10) || 0; } } 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"; if (process.status === "completed") return "completed"; if (process.paused_at) return "paused"; if (process.started_at) return "running"; return "idle"; })(); const handleTimerAction = async ( action: "start" | "pause" | "resume" | "complete", ) => { // 낙관적 업데이트: UI 즉시 반영 setProcess((prev) => { if (!prev) return prev; const now = new Date().toISOString(); if (action === "start") return { ...prev, status: "in_progress", started_at: now, paused_at: null, }; if (action === "pause") return { ...prev, paused_at: now }; if (action === "resume") { const pausedSec = prev.paused_at ? Math.floor((Date.now() - new Date(prev.paused_at).getTime()) / 1000) : 0; return { ...prev, paused_at: null, total_paused_time: String( (parseInt(prev.total_paused_time || "0", 10) || 0) + pausedSec, ), }; } if (action === "complete") return { ...prev, status: "completed", completed_at: now, paused_at: null, }; return prev; }); // API 백그라운드 호출 try { 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"; } await apiClient.post("/pop/production/timer", body); // 서버 데이터로 동기화 (조용히) fetchProcess(); } catch (error: unknown) { const err = error as { response?: { data?: { message?: string } } }; alert(err.response?.data?.message || "타이머 오류"); fetchProcess(); // 실패 시 서버 상태로 복원 } }; /* ---- Group Timer (낙관적 업데이트) ---- */ const handleGroupTimerAction = ( action: "start" | "pause" | "resume" | "complete", groupItemId: string, ) => { // 로컬 체크리스트 상태 즉시 업데이트 const now = new Date().toISOString(); setChecklist((prev) => prev.map((item) => { if (item.source_work_item_id !== groupItemId) return item; if (action === "start") return { ...item, group_started_at: now, group_paused_at: null }; if (action === "pause") return { ...item, group_paused_at: now }; if (action === "resume") { const pausedSec = item.group_paused_at ? Math.floor( (Date.now() - new Date(item.group_paused_at).getTime()) / 1000, ) : 0; const prev_total = parseInt(item.group_total_paused_time || "0", 10) || 0; return { ...item, group_paused_at: null, group_total_paused_time: String(prev_total + pausedSec), }; } if (action === "complete") return { ...item, group_completed_at: now, group_paused_at: null }; return item; }), ); // API 백그라운드 (결과 무시, 실패 시만 동기화) apiClient .post("/pop/production/group-timer", { work_order_process_id: processId, source_work_item_id: groupItemId, action, }) .catch(() => { fetchProcess(); }); }; /* ---- Group timer display ---- */ const selectedGroupWorkSec = useMemo(() => { if (!selectedGroup) return 0; return calcGroupWorkSeconds(selectedGroup.timerState); // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedGroup, tick]); /* ================================================================ */ /* Checklist Save */ /* ================================================================ */ const handleChecklistSave = async ( itemId: string, resultValue: string, isPassed: string | null, ) => { try { const now = new Date().toISOString(); 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: now, 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: now, } : c, ), ); } catch { alert("체크리스트 저장 실패"); } }; /* ================================================================ */ /* Save Result (batch-cumulative) */ /* ================================================================ */ const handleSaveResult = async () => { if (productionQty <= 0) { alert("생산수량을 입력해주세요."); return; } setSaving(true); try { const totalDefect = defectEntries.reduce( (s, e) => s + parseInt(e.qty, 10), 0, ); const res = await apiClient.post("/pop/production/save-result", { work_order_process_id: processId, production_qty: String(productionQty), good_qty: String(productionQty - totalDefect), defect_qty: String(totalDefect), defect_detail: defectEntries.length > 0 ? defectEntries : undefined, result_note: resultNote || undefined, }); if (res.data?.success) { const d = res.data.data; setProcess((prev) => { if (!prev) return prev; return { ...prev, ...d }; }); setProductionQty(0); setDefectEntries([]); setResultNote(""); loadHistory(); if (d?.status === "completed") { alert("모든 수량이 완료되어 자동 확정되었습니다."); } else { alert("실적이 저장되었습니다."); } } else { alert(res.data?.message || "저장 실패"); } } catch (error: unknown) { const err = error as { response?: { data?: { message?: string } } }; alert(err.response?.data?.message || "실적 저장 중 오류"); } finally { setSaving(false); } }; /* ---- Confirm Result ---- */ const handleConfirmResult = () => { askConfirm( "실적을 확정하시겠습니까?\n확정 후에는 추가 등록이 불가합니다.", async () => { setSaving(true); try { const res = await apiClient.post("/pop/production/confirm-result", { work_order_process_id: processId, }); if (res.data?.success) { await fetchProcess(); alert("실적이 확정되었습니다."); } else { alert(res.data?.message || "확정 실패"); } } catch (error: unknown) { const err = error as { response?: { data?: { message?: string } } }; alert(err.response?.data?.message || "확정 중 오류"); } finally { setSaving(false); } }, { title: "실적 확정", confirmText: "확정", variant: "success" }, ); }; /* ================================================================ */ /* Inventory Inbound */ /* ================================================================ */ const fetchLocations = useCallback(async (warehouseId: string) => { if (!warehouseId) { setWarehouseLocations([]); return; } try { const res = await apiClient.get( `/pop/production/warehouse-locations/${warehouseId}`, ); setWarehouseLocations(res.data?.data || []); } catch { setWarehouseLocations([]); } }, []); const handleInbound = () => { if (!selectedWarehouse) { alert("창고를 선택해주세요."); return; } askConfirm( "생산입고를 진행하시겠습니까?", async () => { 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: warehouseCode, location_code: selectedLocation || undefined, }, ); if (res.data?.success) { setInboundDone(true); alert(`재고 입고 완료: ${res.data.data?.qty || 0}개`); } else { alert(res.data?.message || "입고 실패"); } } catch (error: unknown) { const err = error as { response?: { data?: { message?: string }; status?: number }; }; const msg = err.response?.data?.message; if (err.response?.status === 409) { setInboundDone(true); alert(msg || "이미 입고 완료"); } else { alert(msg || "입고 중 오류"); } } finally { setSaving(false); } }, { title: "생산 입고", confirmText: "입고", variant: "primary" }, ); }; /* ================================================================ */ /* Computed Values */ /* ================================================================ */ const totalDefectQty = defectEntries.reduce( (s, e) => s + parseInt(e.qty, 10), 0, ); const goodQtyThisBatch = productionQty - totalDefectQty; const inputQty = parseInt(process?.input_qty || "0", 10); const totalProduced = parseInt(process?.total_production_qty || "0", 10); const accumulatedGood = parseInt(process?.good_qty || "0", 10); const accumulatedDefect = parseInt(process?.defect_qty || "0", 10); const remaining = Math.max(0, inputQty - totalProduced); const isCompleted = process?.status === "completed"; const isConfirmed = process?.result_status === "confirmed"; const hasChecklist = checklist.length > 0; /* ================================================================ */ /* Loading / Error */ /* ================================================================ */ if (loading) { return (
공정 정보 로딩중...
); } if (!process) { return (

공정을 찾을 수 없습니다

); } /* ================================================================ */ /* Render */ /* ================================================================ */ return (
{/* ============================================================ */} {/* Info Bar (Dark Header) */} {/* ============================================================ */}
{wiInfo && (
작업지시 {wiInfo.work_instruction_no}
)} {wiInfo && (
품목 {wiInfo.item_name}
)} {batchBadge && (
{batchBadge.isMulti ? ( 다중 {batchBadge.index}/{batchBadge.total}{batchBadge.itemType ? ` · ${batchBadge.itemType}` : ""} ) : ( 단일{batchBadge.itemType ? ` · ${batchBadge.itemType}` : ""} )}
)}
공정 {process.seq_no ? `${process.seq_no}. ` : ""} {process.process_name || "공정"}
지시 {parseInt(process.plan_qty || "0", 10).toLocaleString()}
접수 {inputQty.toLocaleString()}
{/* Status badge */} {isCompleted ? "완료" : process.status === "in_progress" ? "진행중" : process.status} {process.is_rework === "Y" && ( 재작업 )}
{/* ============================================================ */} {/* Main Layout: Sidebar(left) + Timer+Content(right) */} {/* ============================================================ */} {(hasChecklist || !isConfirmed || (isLastProcess && !inboundDone)) && (
{/* ========================================================= */} {/* Left Sidebar (always visible) */} {/* ========================================================= */}
{/* 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; return (
{PHASE_LABELS[phase] || phase} {phaseDone}/{phaseTotal}
{phaseGroups.map((g) => { const isSelected = selectedGroupId === g.itemId && activeSection === "checklist"; const isDone = g.completed >= g.total && g.total > 0; return ( ); })}
); })} {/* Result section link */} {!isConfirmed && (
{/* 자재 투입 (설정으로 제어) */} {peSettings.materialInput && ( )}
)} {/* Inventory section link */} {isLastProcess && (
)}
{/* ========================================================= */} {/* Mobile Tabs (hidden — sidebar always visible) */} {/* ========================================================= */}
{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 isActive = activeSection === "checklist" && selectedGroup && selectedGroup.phase === phase; return ( ); })} {!isConfirmed && ( )} {isLastProcess && ( )}
{/* ========================================================= */} {/* Right Column: Timer + Content */} {/* ========================================================= */}
{/* Unified Timer Bar (group-level) */}
{(() => { const g = selectedGroup; const groupElapsed = g?.timerState.startedAt ? Math.max( 0, Math.floor( ((g.timerState.completedAt ? new Date(g.timerState.completedAt).getTime() : Date.now()) - new Date(g.timerState.startedAt).getTime()) / 1000, ), ) : 0; const groupWork = selectedGroupWorkSec; const isIdle = !g?.timerStarted; const isPaused = g?.timerPaused; const isDone = g?.timerCompleted; const isRunning = g?.timerStarted && !isPaused && !isDone; const btnClass = "h-12 min-w-[80px] rounded-xl text-base font-bold text-white active:scale-95 transition-all px-4"; return (
{formatTime(groupElapsed)} {formatTime(groupWork)} {g?.title || "그룹 선택"} {isPaused && ( 일시정지 )} {isDone && ( ✓ 완료 )}
{isIdle && g && ( )} {isRunning && g && ( <> )} {isPaused && g && ( <> )}
); })()}
{/* Content Area (scrollable) */}
{/* Checklist Content */} {activeSection === "checklist" && selectedGroup && (
{/* Group header with timer */}

{PHASE_LABELS[selectedGroup.phase] || selectedGroup.phase}

{selectedGroup.title}

= selectedGroup.total && selectedGroup.total > 0 ? "bg-green-100 text-green-700" : selectedGroup.timerStarted ? "bg-blue-100 text-blue-700" : "bg-gray-100 text-gray-500" }`} > {selectedGroup.completed}/{selectedGroup.total}
{/* 그룹 타이머는 상단 통합 타이머로 이동 */} {/* Mobile group navigation (sidebar not visible) */}
{(groupsByPhase[selectedGroup.phase] || []).map((g) => { const isSelected = g.itemId === selectedGroupId; const isDone = g.completed >= g.total && g.total > 0; return ( ); })}
{/* Checklist items */}
{currentItems.map((item) => ( ))} {currentItems.length === 0 && (

체크리스트 항목이 없습니다

)}
)} {/* ====== Material Input Content (설정) ====== */} {activeSection === "material" && peSettings.materialInput && ( )} {/* ====== Result Content ====== */} {activeSection === "result" && !isConfirmed && (

이번 차수 실적 입력 {remaining > 0 && ( 잔여: {remaining.toLocaleString()} )}

{/* Production Qty */} {/* Good Qty (자동 계산) */}
양품 {goodQtyThisBatch > 0 ? `${goodQtyThisBatch.toLocaleString()} EA` : "0 EA"}
{/* Defect */} {/* Defect entries summary */} {defectEntries.length > 0 && (
{defectEntries.map((de) => ( {de.defect_name}: {de.qty}개 ( {de.disposition === "scrap" ? "폐기" : de.disposition === "rework" ? "재작업" : "특채"} ) ))}
)} {/* 누적 현황 */} {totalProduced > 0 && (
누적: {totalProduced}/{inputQty} ( {Math.round((totalProduced / inputQty) * 100)}%)
)} {/* Note */}