"use client"; import React, { useEffect, useState, useMemo, useCallback } from "react"; import { Loader2, Play, Pause, CheckCircle2, AlertCircle, Timer, Package, } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Checkbox } from "@/components/ui/checkbox"; import { Badge } from "@/components/ui/badge"; import { cn } from "@/lib/utils"; import { dataApi } from "@/lib/api/data"; import { apiClient } from "@/lib/api/client"; import { usePopEvent } from "@/hooks/pop/usePopEvent"; import { useAuth } from "@/hooks/useAuth"; import type { PopWorkDetailConfig } from "../types"; import type { TimelineProcessStep } from "../types"; // ======================================== // 타입 // ======================================== type RowData = Record; interface WorkResultRow { 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_type: string; detail_label: string; detail_sort_order: string; spec_value: string | null; lower_limit: string | null; upper_limit: string | null; input_type: string | null; result_value: string | null; status: string; is_passed: string | null; recorded_by: string | null; recorded_at: string | null; } interface WorkGroup { phase: string; title: string; itemId: string; sortOrder: number; total: number; completed: number; } type WorkPhase = "PRE" | "IN" | "POST"; const PHASE_ORDER: Record = { PRE: 1, IN: 2, POST: 3 }; interface ProcessTimerData { started_at: string | null; paused_at: string | null; total_paused_time: string | null; status: string; good_qty: string | null; defect_qty: string | null; } // ======================================== // Props // ======================================== interface PopWorkDetailComponentProps { config?: PopWorkDetailConfig; screenId?: string; componentId?: string; currentRowSpan?: number; currentColSpan?: number; } // ======================================== // 메인 컴포넌트 // ======================================== export function PopWorkDetailComponent({ config, screenId, componentId, }: PopWorkDetailComponentProps) { const { getSharedData } = usePopEvent(screenId || "default"); const { user } = useAuth(); const cfg: PopWorkDetailConfig = { showTimer: config?.showTimer ?? true, showQuantityInput: config?.showQuantityInput ?? true, phaseLabels: config?.phaseLabels ?? { PRE: "작업 전", IN: "작업 중", POST: "작업 후" }, }; // parentRow에서 현재 공정 정보 추출 const parentRow = getSharedData("parentRow"); const processFlow = parentRow?.__processFlow__ as TimelineProcessStep[] | undefined; const currentProcess = processFlow?.find((p) => p.isCurrent); const workOrderProcessId = currentProcess?.processId ? String(currentProcess.processId) : undefined; const processName = currentProcess?.processName ?? "공정 상세"; // ======================================== // 상태 // ======================================== const [allResults, setAllResults] = useState([]); const [processData, setProcessData] = useState(null); const [loading, setLoading] = useState(true); const [selectedGroupId, setSelectedGroupId] = useState(null); const [tick, setTick] = useState(Date.now()); const [savingIds, setSavingIds] = useState>(new Set()); // 수량 입력 로컬 상태 const [goodQty, setGoodQty] = useState(""); const [defectQty, setDefectQty] = useState(""); // ======================================== // D-FE1: 데이터 로드 // ======================================== const fetchData = useCallback(async () => { if (!workOrderProcessId) { setLoading(false); return; } try { setLoading(true); const [resultRes, processRes] = await Promise.all([ dataApi.getTableData("process_work_result", { size: 500, filters: { work_order_process_id: workOrderProcessId }, }), dataApi.getTableData("work_order_process", { size: 1, filters: { id: workOrderProcessId }, }), ]); setAllResults((resultRes.data ?? []) as unknown as WorkResultRow[]); const proc = (processRes.data?.[0] ?? null) as ProcessTimerData | null; setProcessData(proc); if (proc) { setGoodQty(proc.good_qty ?? ""); setDefectQty(proc.defect_qty ?? ""); } } catch { toast.error("데이터를 불러오는데 실패했습니다."); } finally { setLoading(false); } }, [workOrderProcessId]); useEffect(() => { fetchData(); }, [fetchData]); // ======================================== // D-FE2: 좌측 사이드바 - 작업항목 그룹핑 // ======================================== const groups = useMemo(() => { const map = new Map(); for (const row of allResults) { const key = row.source_work_item_id; if (!map.has(key)) { map.set(key, { phase: row.work_phase, title: row.item_title, itemId: key, sortOrder: parseInt(row.item_sort_order || "0", 10), total: 0, completed: 0, }); } const g = map.get(key)!; g.total++; if (row.status === "completed") g.completed++; } return Array.from(map.values()).sort( (a, b) => (PHASE_ORDER[a.phase] ?? 9) - (PHASE_ORDER[b.phase] ?? 9) || a.sortOrder - b.sortOrder ); }, [allResults]); // phase별로 그룹핑 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]); // 첫 그룹 자동 선택 useEffect(() => { if (groups.length > 0 && !selectedGroupId) { setSelectedGroupId(groups[0].itemId); } }, [groups, selectedGroupId]); // ======================================== // D-FE3: 우측 체크리스트 // ======================================== const currentItems = useMemo( () => allResults .filter((r) => r.source_work_item_id === selectedGroupId) .sort((a, b) => parseInt(a.detail_sort_order || "0", 10) - parseInt(b.detail_sort_order || "0", 10)), [allResults, selectedGroupId] ); const saveResultValue = useCallback( async ( rowId: string, resultValue: string, isPassed: string | null, newStatus: string ) => { setSavingIds((prev) => new Set(prev).add(rowId)); try { await apiClient.post("/pop/execute-action", { tasks: [ { type: "data-update", targetTable: "process_work_result", targetColumn: "result_value", value: resultValue, items: [{ id: rowId }] }, { type: "data-update", targetTable: "process_work_result", targetColumn: "status", value: newStatus, items: [{ id: rowId }] }, ...(isPassed !== null ? [{ type: "data-update", targetTable: "process_work_result", targetColumn: "is_passed", value: isPassed, items: [{ id: rowId }] }] : []), { type: "data-update", targetTable: "process_work_result", targetColumn: "recorded_by", value: user?.userId ?? "", items: [{ id: rowId }] }, { type: "data-update", targetTable: "process_work_result", targetColumn: "recorded_at", value: new Date().toISOString(), items: [{ id: rowId }] }, ], data: { items: [{ id: rowId }], fieldValues: {} }, }); setAllResults((prev) => prev.map((r) => r.id === rowId ? { ...r, result_value: resultValue, status: newStatus, is_passed: isPassed, recorded_by: user?.userId ?? null, recorded_at: new Date().toISOString(), } : r ) ); } catch { toast.error("저장에 실패했습니다."); } finally { setSavingIds((prev) => { const next = new Set(prev); next.delete(rowId); return next; }); } }, [user?.userId] ); // ======================================== // D-FE4: 타이머 // ======================================== useEffect(() => { if (!cfg.showTimer || !processData?.started_at) return; const id = setInterval(() => setTick(Date.now()), 1000); return () => clearInterval(id); }, [cfg.showTimer, processData?.started_at]); const elapsedMs = useMemo(() => { if (!processData?.started_at) return 0; const now = tick; const totalMs = now - new Date(processData.started_at).getTime(); const pausedSec = parseInt(processData.total_paused_time || "0", 10); const currentPauseMs = processData.paused_at ? now - new Date(processData.paused_at).getTime() : 0; return Math.max(0, totalMs - pausedSec * 1000 - currentPauseMs); }, [processData?.started_at, processData?.paused_at, processData?.total_paused_time, tick]); const formattedTime = useMemo(() => { const totalSec = Math.floor(elapsedMs / 1000); const h = String(Math.floor(totalSec / 3600)).padStart(2, "0"); const m = String(Math.floor((totalSec % 3600) / 60)).padStart(2, "0"); const s = String(totalSec % 60).padStart(2, "0"); return `${h}:${m}:${s}`; }, [elapsedMs]); const isPaused = !!processData?.paused_at; const isStarted = !!processData?.started_at; const handleTimerAction = useCallback( async (action: "start" | "pause" | "resume") => { if (!workOrderProcessId) return; try { await apiClient.post("/api/pop/production/timer", { workOrderProcessId, action, }); // 타이머 상태 새로고침 const res = await dataApi.getTableData("work_order_process", { size: 1, filters: { id: workOrderProcessId }, }); const proc = (res.data?.[0] ?? null) as ProcessTimerData | null; if (proc) setProcessData(proc); } catch { toast.error("타이머 제어에 실패했습니다."); } }, [workOrderProcessId] ); // ======================================== // D-FE5: 수량 등록 + 완료 // ======================================== const handleQuantityRegister = useCallback(async () => { if (!workOrderProcessId) return; try { await apiClient.post("/pop/execute-action", { tasks: [ { type: "data-update", targetTable: "work_order_process", targetColumn: "good_qty", value: goodQty || "0", items: [{ id: workOrderProcessId }] }, { type: "data-update", targetTable: "work_order_process", targetColumn: "defect_qty", value: defectQty || "0", items: [{ id: workOrderProcessId }] }, ], data: { items: [{ id: workOrderProcessId }], fieldValues: {} }, }); toast.success("수량이 등록되었습니다."); } catch { toast.error("수량 등록에 실패했습니다."); } }, [workOrderProcessId, goodQty, defectQty]); const handleProcessComplete = useCallback(async () => { if (!workOrderProcessId) return; try { await apiClient.post("/pop/execute-action", { tasks: [ { type: "data-update", targetTable: "work_order_process", targetColumn: "status", value: "completed", items: [{ id: workOrderProcessId }] }, { type: "data-update", targetTable: "work_order_process", targetColumn: "completed_at", value: new Date().toISOString(), items: [{ id: workOrderProcessId }] }, ], data: { items: [{ id: workOrderProcessId }], fieldValues: {} }, }); toast.success("공정이 완료되었습니다."); setProcessData((prev) => prev ? { ...prev, status: "completed" } : prev ); } catch { toast.error("공정 완료 처리에 실패했습니다."); } }, [workOrderProcessId]); // ======================================== // 안전 장치 // ======================================== if (!parentRow) { return (
카드를 선택해주세요
); } if (!workOrderProcessId) { return (
공정 정보를 찾을 수 없습니다
); } if (loading) { return (
); } if (allResults.length === 0) { return (
작업기준이 등록되지 않았습니다
); } const isProcessCompleted = processData?.status === "completed"; // ======================================== // 렌더링 // ======================================== return (
{/* 헤더 */}

{processName}

{cfg.showTimer && (
{formattedTime} {!isProcessCompleted && ( <> {!isStarted && ( )} {isStarted && !isPaused && ( )} {isStarted && isPaused && ( )} )}
)}
{/* 본문: 좌측 사이드바 + 우측 체크리스트 */}
{/* 좌측 사이드바 */}
{(["PRE", "IN", "POST"] as WorkPhase[]).map((phase) => { const phaseGroups = groupsByPhase[phase]; if (!phaseGroups || phaseGroups.length === 0) return null; return (
{cfg.phaseLabels[phase] ?? phase}
{phaseGroups.map((g) => ( ))}
); })}
{/* 우측 체크리스트 */}
{selectedGroupId && (
{currentItems.map((item) => ( ))}
)}
{/* 하단: 수량 입력 + 완료 */} {cfg.showQuantityInput && (
양품 setGoodQty(e.target.value)} disabled={isProcessCompleted} />
불량 setDefectQty(e.target.value)} disabled={isProcessCompleted} />
{!isProcessCompleted && ( )} {isProcessCompleted && ( 완료됨 )}
)}
); } // ======================================== // 체크리스트 개별 항목 // ======================================== interface ChecklistItemProps { item: WorkResultRow; saving: boolean; disabled: boolean; onSave: ( rowId: string, resultValue: string, isPassed: string | null, newStatus: string ) => void; } function ChecklistItem({ item, saving, disabled, onSave }: ChecklistItemProps) { const isSaving = saving; const isDisabled = disabled || isSaving; switch (item.detail_type) { case "check": return ; case "inspect": return ; case "input": return ; case "procedure": return ; case "material": return ; default: return (
알 수 없는 유형: {item.detail_type}
); } } // ===== check: 체크박스 ===== function CheckItem({ item, disabled, saving, onSave, }: { item: WorkResultRow; disabled: boolean; saving: boolean; onSave: ChecklistItemProps["onSave"]; }) { const checked = item.result_value === "Y"; return (
{ const val = v ? "Y" : "N"; onSave(item.id, val, v ? "Y" : "N", v ? "completed" : "pending"); }} /> {item.detail_label} {saving && } {item.status === "completed" && !saving && ( 완료 )}
); } // ===== inspect: 측정값 입력 (범위 판정) ===== function InspectItem({ item, disabled, saving, onSave, }: { item: WorkResultRow; disabled: boolean; saving: boolean; onSave: ChecklistItemProps["onSave"]; }) { const [inputVal, setInputVal] = useState(item.result_value ?? ""); const lower = parseFloat(item.lower_limit ?? ""); const upper = parseFloat(item.upper_limit ?? ""); const hasRange = !isNaN(lower) && !isNaN(upper); const handleBlur = () => { if (!inputVal || disabled) return; const numVal = parseFloat(inputVal); let passed: string | null = null; if (hasRange) { passed = numVal >= lower && numVal <= upper ? "Y" : "N"; } onSave(item.id, inputVal, passed, "completed"); }; const isPassed = item.is_passed; return (
{item.detail_label} {hasRange && ( 기준: {item.lower_limit} ~ {item.upper_limit} {item.spec_value ? ` (표준: ${item.spec_value})` : ""} )}
setInputVal(e.target.value)} onBlur={handleBlur} disabled={disabled} placeholder="측정값 입력" /> {saving && } {isPassed === "Y" && !saving && ( 합격 )} {isPassed === "N" && !saving && ( 불합격 )}
); } // ===== input: 자유 입력 ===== function InputItem({ item, disabled, saving, onSave, }: { item: WorkResultRow; disabled: boolean; saving: boolean; onSave: ChecklistItemProps["onSave"]; }) { const [inputVal, setInputVal] = useState(item.result_value ?? ""); const inputType = item.input_type === "number" ? "number" : "text"; const handleBlur = () => { if (!inputVal || disabled) return; onSave(item.id, inputVal, null, "completed"); }; return (
{item.detail_label}
setInputVal(e.target.value)} onBlur={handleBlur} disabled={disabled} placeholder="값 입력" /> {saving && }
); } // ===== procedure: 절차 확인 (읽기 전용 + 체크) ===== function ProcedureItem({ item, disabled, saving, onSave, }: { item: WorkResultRow; disabled: boolean; saving: boolean; onSave: ChecklistItemProps["onSave"]; }) { const checked = item.result_value === "Y"; return (
{item.spec_value || item.detail_label}
{ onSave(item.id, v ? "Y" : "N", null, v ? "completed" : "pending"); }} /> 확인 {saving && }
); } // ===== material: 자재/LOT 입력 ===== function MaterialItem({ item, disabled, saving, onSave, }: { item: WorkResultRow; disabled: boolean; saving: boolean; onSave: ChecklistItemProps["onSave"]; }) { const [inputVal, setInputVal] = useState(item.result_value ?? ""); const handleBlur = () => { if (!inputVal || disabled) return; onSave(item.id, inputVal, null, "completed"); }; return (
{item.detail_label}
setInputVal(e.target.value)} onBlur={handleBlur} disabled={disabled} placeholder="LOT 번호 입력" /> {saving && }
); }