From 06c52b422f9a3fcdb96ce7443422989f9b7fdfdb Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Tue, 17 Mar 2026 09:32:59 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20BLOCK=20DETAIL=20Phase=204=20+=20?= =?UTF-8?q?=EC=95=88=EC=A0=95=ED=99=94=20-=20=EA=B7=B8=EB=A3=B9=EB=B3=84?= =?UTF-8?q?=20=ED=83=80=EC=9D=B4=EB=A8=B8,=20=ED=84=B0=EC=B9=98=20?= =?UTF-8?q?=EC=B5=9C=EC=A0=81=ED=99=94=20UI,=20DB=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95=20pop-work-detail=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=EC=97=90=20=EA=B7=B8?= =?UTF-8?q?=EB=A3=B9=EB=B3=84=20=ED=83=80=EC=9D=B4=EB=A8=B8=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=EA=B3=BC=20=ED=84=B0=EC=B9=98=20=EC=B5=9C?= =?UTF-8?q?=EC=A0=81=ED=99=94=20UI=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=98?= =?UTF-8?q?=EA=B3=A0,=20=EC=B2=B4=ED=81=AC=EB=A6=AC=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=EA=B0=80=20DB=EC=97=90=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=EB=90=98=EC=A7=80=20=EC=95=8A=EB=8D=98=20=EB=B2=84=EA=B7=B8?= =?UTF-8?q?=EB=A5=BC=20=EC=88=98=EC=A0=95=ED=95=98=EC=97=AC=20=EC=95=88?= =?UTF-8?q?=EC=A0=95=ED=99=94=EB=A5=BC=20=EC=99=84=EB=A3=8C=ED=95=9C?= =?UTF-8?q?=EB=8B=A4.=20[=EA=B7=B8=EB=A3=B9=EB=B3=84=20=ED=83=80=EC=9D=B4?= =?UTF-8?q?=EB=A8=B8]=20-=20group-timer=20API=20=EC=8B=A0=EA=B7=9C:=20star?= =?UTF-8?q?t/pause/resume/complete=20=EC=95=A1=EC=85=98=20(popProductionCo?= =?UTF-8?q?ntroller)=20-=20process=5Fwork=5Fresult=EC=97=90=20group=5Fstar?= =?UTF-8?q?ted=5Fat/paused=5Fat/total=5Fpaused=5Ftime/completed=5Fat=20?= =?UTF-8?q?=ED=99=9C=EC=9A=A9=20-=20GroupTimerHeader=20UI:=20=EC=88=9C?= =?UTF-8?q?=EC=88=98=20=EC=9E=91=EC=97=85=EC=8B=9C=EA=B0=84=20+=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=BC=EC=8B=9C=EA=B0=84=20=EC=9D=B4=EC=A4=91=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=20-=20=EC=B2=AB=20=EA=B7=B8=EB=A3=B9=20"?= =?UTF-8?q?=EC=8B=9C=EC=9E=91"=20=EC=8B=9C=20work=5Forder=5Fprocess.starte?= =?UTF-8?q?d=5Fat=20=EC=9E=90=EB=8F=99=20=EA=B8=B0=EB=A1=9D=20(=EA=B3=B5?= =?UTF-8?q?=EC=A0=95=20=EC=8B=9C=EC=9E=91=20=EC=9E=90=EB=8F=99=20=EA=B0=90?= =?UTF-8?q?=EC=A7=80)=20-=20=EA=B3=B5=EC=A0=95=20=EC=99=84=EB=A3=8C=20?= =?UTF-8?q?=EC=8B=9C=20actual=5Fwork=5Ftime=EC=9D=84=20=EA=B7=B8=EB=A3=B9?= =?UTF-8?q?=20=ED=83=80=EC=9D=B4=EB=A8=B8=20=ED=95=A9=EC=82=B0=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B0=B1=EC=97=94=EB=93=9C=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EA=B3=84=EC=82=B0=20[=ED=84=B0=EC=B9=98=20=EC=B5=9C=EC=A0=81?= =?UTF-8?q?=ED=99=94=20UI]=20-=2012=EA=B0=9C=20=EC=98=81=EC=97=AD=20?= =?UTF-8?q?=EC=A0=84=EB=A9=B4=20=EC=8A=A4=EC=BC=80=EC=9D=BC=EC=97=85:=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20h-11~h-12,=20=EC=9E=85=EB=A0=A5=20h-11,=20?= =?UTF-8?q?=EC=B2=B4=ED=81=AC=EB=B0=95=EC=8A=A4=20h-6=20w-6=20-=20?= =?UTF-8?q?=EC=82=AC=EC=9D=B4=EB=93=9C=EB=B0=94=20w-[180px],=20InfoBar=20t?= =?UTF-8?q?ext-sm,=20=EC=B5=9C=EC=86=8C=20=ED=84=B0=EC=B9=98=20=EC=98=81?= =?UTF-8?q?=EC=97=AD=2040~44px=20=ED=99=95=EB=B3=B4=20-=20=EC=82=B0?= =?UTF-8?q?=EC=97=85=20=ED=98=84=EC=9E=A5=20=ED=83=9C=EB=B8=94=EB=A6=BF=20?= =?UTF-8?q?=ED=84=B0=EC=B9=98=20=EC=82=AC=EC=9A=A9=20=EC=B5=9C=EC=A0=81?= =?UTF-8?q?=ED=99=94=20[DB=20=EC=A0=80=EC=9E=A5=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95]=20-=20saveResultValue/handleQuantityRegiste?= =?UTF-8?q?r:=20execute-action=20task=20=ED=98=95=EC=8B=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=20=20(fixedValue=20+=20lookupMode:"manual"=20+=20m?= =?UTF-8?q?anualItemField/manualPkColumn:"id")=20-=20=EC=9B=90=EC=9D=B8:?= =?UTF-8?q?=20=EB=B0=B1=EC=97=94=EB=93=9C=EA=B0=80=20=5F=5Fcart=5Frow=5Fke?= =?UTF-8?q?y=EB=A5=BC=20=EC=B0=BE=EB=8A=94=EB=8D=B0=20=ED=94=84=EB=A1=A0?= =?UTF-8?q?=ED=8A=B8=EC=97=90=EC=84=9C=20id=EB=A7=8C=20=EC=A0=84=EC=86=A1?= =?UTF-8?q?=ED=95=98=EC=97=AC=20lookup=20=EC=8B=A4=ED=8C=A8=20[=EB=94=94?= =?UTF-8?q?=EC=9E=90=EC=9D=B4=EB=84=88=20=EC=84=A4=EC=A0=95=20=ED=99=95?= =?UTF-8?q?=EC=9E=A5]=20-=20displayMode:=20list/step=20=EC=A0=84=ED=99=98?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80=20-=20PopWorkDetailC?= =?UTF-8?q?onfig:=20=ED=91=9C=EC=8B=9C=20=EB=AA=A8=EB=93=9C=20Select=20?= =?UTF-8?q?=EB=93=9C=EB=A1=AD=EB=8B=A4=EC=9A=B4=20-=20types.ts:=20PopWorkD?= =?UTF-8?q?etailConfig=20=EC=9D=B8=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=20d?= =?UTF-8?q?isplayMode=20=EC=B6=94=EA=B0=80=20-=20PopCardListV2Component:?= =?UTF-8?q?=20parentRow.=5F=5FprocessFlow=5F=5F=20=EC=A0=84=EB=8B=AC=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/popProductionController.ts | 180 ++- .../src/routes/popProductionRoutes.ts | 2 + .../PopCardListV2Component.tsx | 8 + .../PopWorkDetailComponent.tsx | 1410 +++++++++++++---- .../pop-work-detail/PopWorkDetailConfig.tsx | 164 +- .../pop-components/pop-work-detail/index.tsx | 18 + frontend/lib/registry/pop-components/types.ts | 26 + 7 files changed, 1441 insertions(+), 367 deletions(-) diff --git a/backend-node/src/controllers/popProductionController.ts b/backend-node/src/controllers/popProductionController.ts index d575b07a..59cef801 100644 --- a/backend-node/src/controllers/popProductionController.ts +++ b/backend-node/src/controllers/popProductionController.ts @@ -206,10 +206,11 @@ export const controlTimer = async ( }); } - if (!["start", "pause", "resume"].includes(action)) { + if (!["start", "pause", "resume", "complete"].includes(action)) { return res.status(400).json({ success: false, - message: "action은 start, pause, resume 중 하나여야 합니다.", + message: + "action은 start, pause, resume, complete 중 하나여야 합니다.", }); } @@ -262,6 +263,47 @@ export const controlTimer = async ( [work_order_process_id, companyCode] ); break; + + case "complete": { + const { good_qty, defect_qty } = req.body; + + const groupSumResult = await pool.query( + `SELECT COALESCE(SUM( + CASE WHEN group_started_at IS NOT NULL AND group_completed_at IS NOT NULL THEN + EXTRACT(EPOCH FROM group_completed_at::timestamp - group_started_at::timestamp)::int + - COALESCE(group_total_paused_time::int, 0) + ELSE 0 END + ), 0)::text AS total_work_seconds + FROM process_work_result + WHERE work_order_process_id = $1 AND company_code = $2`, + [work_order_process_id, companyCode] + ); + const calculatedWorkTime = groupSumResult.rows[0]?.total_work_seconds || "0"; + + result = await pool.query( + `UPDATE work_order_process + SET status = 'completed', + completed_at = NOW()::text, + completed_by = $3, + actual_work_time = $4, + good_qty = COALESCE($5, good_qty), + defect_qty = COALESCE($6, defect_qty), + paused_at = NULL, + updated_date = NOW() + WHERE id = $1 AND company_code = $2 + AND status != 'completed' + RETURNING id, status, completed_at, completed_by, actual_work_time, good_qty, defect_qty`, + [ + work_order_process_id, + companyCode, + userId, + calculatedWorkTime, + good_qty || null, + defect_qty || null, + ] + ); + break; + } } if (!result || result.rowCount === 0) { @@ -289,3 +331,137 @@ export const controlTimer = async ( }); } }; + +/** + * 그룹(작업항목)별 타이머 제어 + * 좌측 사이드바의 각 작업 그룹마다 독립적인 시작/정지/재개/완료 타이머 + */ +export const controlGroupTimer = async ( + req: AuthenticatedRequest, + res: Response +) => { + const pool = getPool(); + + try { + const companyCode = req.user!.companyCode; + const { work_order_process_id, source_work_item_id, action } = req.body; + + if (!work_order_process_id || !source_work_item_id || !action) { + return res.status(400).json({ + success: false, + message: + "work_order_process_id, source_work_item_id, action은 필수입니다.", + }); + } + + if (!["start", "pause", "resume", "complete"].includes(action)) { + return res.status(400).json({ + success: false, + message: + "action은 start, pause, resume, complete 중 하나여야 합니다.", + }); + } + + logger.info("[pop/production] group-timer 요청", { + companyCode, + work_order_process_id, + source_work_item_id, + action, + }); + + const whereClause = `work_order_process_id = $1 AND source_work_item_id = $2 AND company_code = $3`; + const baseParams = [work_order_process_id, source_work_item_id, companyCode]; + + let result; + + switch (action) { + case "start": + result = await pool.query( + `UPDATE process_work_result + SET group_started_at = CASE WHEN group_started_at IS NULL THEN NOW()::text ELSE group_started_at END, + updated_date = NOW() + WHERE ${whereClause} + RETURNING id, group_started_at`, + baseParams + ); + await pool.query( + `UPDATE work_order_process + SET started_at = NOW()::text, updated_date = NOW() + WHERE id = $1 AND company_code = $2 AND started_at IS NULL`, + [work_order_process_id, companyCode] + ); + break; + + case "pause": + result = await pool.query( + `UPDATE process_work_result + SET group_paused_at = NOW()::text, + updated_date = NOW() + WHERE ${whereClause} AND group_paused_at IS NULL + RETURNING id, group_paused_at`, + baseParams + ); + break; + + case "resume": + result = await pool.query( + `UPDATE process_work_result + SET group_total_paused_time = ( + COALESCE(group_total_paused_time::int, 0) + + EXTRACT(EPOCH FROM NOW() - group_paused_at::timestamp)::int + )::text, + group_paused_at = NULL, + updated_date = NOW() + WHERE ${whereClause} AND group_paused_at IS NOT NULL + RETURNING id, group_total_paused_time`, + baseParams + ); + break; + + case "complete": { + result = await pool.query( + `UPDATE process_work_result + SET group_completed_at = NOW()::text, + group_total_paused_time = CASE + WHEN group_paused_at IS NOT NULL THEN ( + COALESCE(group_total_paused_time::int, 0) + + EXTRACT(EPOCH FROM NOW() - group_paused_at::timestamp)::int + )::text + ELSE group_total_paused_time + END, + group_paused_at = NULL, + updated_date = NOW() + WHERE ${whereClause} + RETURNING id, group_started_at, group_completed_at, group_total_paused_time`, + baseParams + ); + break; + } + } + + if (!result || result.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "대상 그룹을 찾을 수 없거나 현재 상태에서 수행할 수 없습니다.", + }); + } + + logger.info("[pop/production] group-timer 완료", { + action, + source_work_item_id, + affectedRows: result.rowCount, + }); + + return res.json({ + success: true, + data: result.rows[0], + affectedRows: result.rowCount, + }); + } catch (error: any) { + logger.error("[pop/production] group-timer 오류:", error); + return res.status(500).json({ + success: false, + message: error.message || "그룹 타이머 처리 중 오류가 발생했습니다.", + }); + } +}; diff --git a/backend-node/src/routes/popProductionRoutes.ts b/backend-node/src/routes/popProductionRoutes.ts index f20d470d..c50f061a 100644 --- a/backend-node/src/routes/popProductionRoutes.ts +++ b/backend-node/src/routes/popProductionRoutes.ts @@ -3,6 +3,7 @@ import { authenticateToken } from "../middleware/authMiddleware"; import { createWorkProcesses, controlTimer, + controlGroupTimer, } from "../controllers/popProductionController"; const router = Router(); @@ -11,5 +12,6 @@ router.use(authenticateToken); router.post("/create-work-processes", createWorkProcesses); router.post("/timer", controlTimer); +router.post("/group-timer", controlGroupTimer); export default router; diff --git a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx index 8c3c6447..bc930e95 100644 --- a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx @@ -974,6 +974,14 @@ export function PopCardListV2Component({ publish(`__comp_output__${componentId}__selected_items`, selectedItems); }, [selectedKeys, filteredRows, componentId, isCartListMode, publish]); + // 공정 완료 이벤트 수신 시 목록 갱신 + useEffect(() => { + const unsub = subscribe("process_completed", () => { + fetchDataRef.current(); + }); + return unsub; + }, [subscribe]); + // 카드 영역 스타일 const cardGap = effectiveConfig?.cardGap ?? spec.gap; const cardMinHeight = spec.height; diff --git a/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailComponent.tsx b/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailComponent.tsx index 963d2148..5aa894cf 100644 --- a/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailComponent.tsx @@ -1,8 +1,9 @@ "use client"; -import React, { useEffect, useState, useMemo, useCallback } from "react"; +import React, { useEffect, useState, useMemo, useCallback, useRef } from "react"; import { Loader2, Play, Pause, CheckCircle2, AlertCircle, Timer, Package, + ChevronLeft, ChevronRight, Check, X, CircleDot, } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -32,7 +33,8 @@ interface WorkResultRow { item_title: string; item_sort_order: string; detail_type: string; - detail_label: string; + detail_label: string | null; + detail_content: string; detail_sort_order: string; spec_value: string | null; lower_limit: string | null; @@ -41,8 +43,23 @@ interface WorkResultRow { result_value: string | null; status: string; is_passed: string | null; + is_required: string | null; recorded_by: string | null; recorded_at: string | null; + started_at: string | null; + group_started_at: string | null; + group_paused_at: string | null; + group_total_paused_time: string | null; + group_completed_at: string | null; + inspection_method: string | null; + unit: string | null; +} + +interface GroupTimerState { + startedAt: string | null; + pausedAt: string | null; + totalPausedTime: number; + completedAt: string | null; } interface WorkGroup { @@ -52,6 +69,8 @@ interface WorkGroup { sortOrder: number; total: number; completed: number; + stepStatus: "pending" | "active" | "completed"; + timer: GroupTimerState; } type WorkPhase = "PRE" | "IN" | "POST"; @@ -61,11 +80,31 @@ interface ProcessTimerData { started_at: string | null; paused_at: string | null; total_paused_time: string | null; + completed_at: string | null; + completed_by: string | null; + actual_work_time: string | null; status: string; good_qty: string | null; defect_qty: string | null; } +const DEFAULT_INFO_FIELDS = [ + { label: "작업지시", column: "wo_no" }, + { label: "품목", column: "item_name" }, + { label: "공정", column: "__process_name" }, + { label: "지시수량", column: "qty" }, +]; + +const DEFAULT_CFG: PopWorkDetailConfig = { + showTimer: true, + showQuantityInput: true, + displayMode: "list", + phaseLabels: { PRE: "작업 전", IN: "작업 중", POST: "작업 후" }, + infoBar: { enabled: true, fields: [] }, + stepControl: { requireStartBeforeInput: false, autoAdvance: true }, + navigation: { showPrevNext: true, showCompleteButton: true }, +}; + // ======================================== // Props // ======================================== @@ -85,18 +124,20 @@ interface PopWorkDetailComponentProps { export function PopWorkDetailComponent({ config, screenId, - componentId, }: PopWorkDetailComponentProps) { - const { getSharedData } = usePopEvent(screenId || "default"); + const { getSharedData, publish } = usePopEvent(screenId || "default"); const { user } = useAuth(); const cfg: PopWorkDetailConfig = { - showTimer: config?.showTimer ?? true, - showQuantityInput: config?.showQuantityInput ?? true, - phaseLabels: config?.phaseLabels ?? { PRE: "작업 전", IN: "작업 중", POST: "작업 후" }, + ...DEFAULT_CFG, + ...config, + displayMode: config?.displayMode ?? DEFAULT_CFG.displayMode, + infoBar: { ...DEFAULT_CFG.infoBar, ...config?.infoBar }, + stepControl: { ...DEFAULT_CFG.stepControl, ...config?.stepControl }, + navigation: { ...DEFAULT_CFG.navigation, ...config?.navigation }, + phaseLabels: { ...DEFAULT_CFG.phaseLabels, ...config?.phaseLabels }, }; - // parentRow에서 현재 공정 정보 추출 const parentRow = getSharedData("parentRow"); const processFlow = parentRow?.__processFlow__ as TimelineProcessStep[] | undefined; const currentProcess = processFlow?.find((p) => p.isCurrent); @@ -115,13 +156,17 @@ export function PopWorkDetailComponent({ const [selectedGroupId, setSelectedGroupId] = useState(null); const [tick, setTick] = useState(Date.now()); const [savingIds, setSavingIds] = useState>(new Set()); + const [activeStepIds, setActiveStepIds] = useState>(new Set()); - // 수량 입력 로컬 상태 const [goodQty, setGoodQty] = useState(""); const [defectQty, setDefectQty] = useState(""); + const [currentItemIdx, setCurrentItemIdx] = useState(0); + const [showQuantityPanel, setShowQuantityPanel] = useState(false); + + const contentRef = useRef(null); // ======================================== - // D-FE1: 데이터 로드 + // 데이터 로드 // ======================================== const fetchData = useCallback(async () => { @@ -129,10 +174,8 @@ export function PopWorkDetailComponent({ setLoading(false); return; } - try { setLoading(true); - const [resultRes, processRes] = await Promise.all([ dataApi.getTableData("process_work_result", { size: 500, @@ -143,9 +186,7 @@ export function PopWorkDetailComponent({ filters: { id: workOrderProcessId }, }), ]); - setAllResults((resultRes.data ?? []) as unknown as WorkResultRow[]); - const proc = (processRes.data?.[0] ?? null) as ProcessTimerData | null; setProcessData(proc); if (proc) { @@ -164,7 +205,7 @@ export function PopWorkDetailComponent({ }, [fetchData]); // ======================================== - // D-FE2: 좌측 사이드바 - 작업항목 그룹핑 + // 좌측 사이드바 - 작업항목 그룹핑 // ======================================== const groups = useMemo(() => { @@ -179,20 +220,34 @@ export function PopWorkDetailComponent({ sortOrder: parseInt(row.item_sort_order || "0", 10), total: 0, completed: 0, + stepStatus: "pending", + timer: { + startedAt: row.group_started_at ?? null, + pausedAt: row.group_paused_at ?? null, + totalPausedTime: parseInt(row.group_total_paused_time || "0", 10), + completedAt: row.group_completed_at ?? null, + }, }); } const g = map.get(key)!; g.total++; if (row.status === "completed") g.completed++; } - return Array.from(map.values()).sort( + const arr = Array.from(map.values()).sort( (a, b) => (PHASE_ORDER[a.phase] ?? 9) - (PHASE_ORDER[b.phase] ?? 9) || a.sortOrder - b.sortOrder ); - }, [allResults]); + for (const g of arr) { + if (g.completed >= g.total && g.total > 0) { + g.stepStatus = "completed"; + } else if (activeStepIds.has(g.itemId) || g.timer.startedAt) { + g.stepStatus = "active"; + } + } + return arr; + }, [allResults, activeStepIds]); - // phase별로 그룹핑 const groupsByPhase = useMemo(() => { const result: Record = {}; for (const g of groups) { @@ -202,25 +257,93 @@ export function PopWorkDetailComponent({ return result; }, [groups]); - // 첫 그룹 자동 선택 useEffect(() => { if (groups.length > 0 && !selectedGroupId) { setSelectedGroupId(groups[0].itemId); } }, [groups, selectedGroupId]); + // 현재 선택 인덱스 + const selectedIndex = useMemo( + () => groups.findIndex((g) => g.itemId === selectedGroupId), + [groups, selectedGroupId] + ); + // ======================================== - // D-FE3: 우측 체크리스트 + // 네비게이션 + // ======================================== + + const navigateStep = useCallback( + (delta: number) => { + const nextIdx = selectedIndex + delta; + if (nextIdx >= 0 && nextIdx < groups.length) { + setSelectedGroupId(groups[nextIdx].itemId); + contentRef.current?.scrollTo({ top: 0, behavior: "smooth" }); + } + }, + [selectedIndex, groups] + ); + + // ======================================== + // 우측 체크리스트 // ======================================== 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)), + .sort( + (a, b) => + parseInt(a.detail_sort_order || "0", 10) - + parseInt(b.detail_sort_order || "0", 10) + ), [allResults, selectedGroupId] ); + // 스텝 모드: 전체 항목을 flat 리스트로 정렬 + const flatItems = useMemo(() => { + const sorted = [...allResults].sort( + (a, b) => + (PHASE_ORDER[a.work_phase] ?? 9) - (PHASE_ORDER[b.work_phase] ?? 9) || + parseInt(a.item_sort_order || "0", 10) - parseInt(b.item_sort_order || "0", 10) || + parseInt(a.detail_sort_order || "0", 10) - parseInt(b.detail_sort_order || "0", 10) + ); + return sorted; + }, [allResults]); + + // 스텝 모드: 모든 체크리스트 항목 완료 여부 + const allItemsCompleted = useMemo( + () => flatItems.length > 0 && flatItems.every((r) => r.status === "completed"), + [flatItems] + ); + + // 그룹 선택 변경 시 스텝 인덱스 리셋 + useEffect(() => { + setCurrentItemIdx(0); + }, [selectedGroupId]); + + // 스텝 모드 자동 다음 이동 + const stepAutoAdvance = useCallback(() => { + if (cfg.displayMode !== "step") return; + const nextPendingIdx = currentItems.findIndex( + (r, i) => i > currentItemIdx && r.status !== "completed" + ); + if (nextPendingIdx >= 0) { + setCurrentItemIdx(nextPendingIdx); + } else if (currentItems.every((r) => r.status === "completed")) { + // 현재 그룹 완료 → 다음 그룹 + const nextGroupIdx = groups.findIndex( + (g, i) => i > selectedIndex && g.stepStatus !== "completed" + ); + if (nextGroupIdx >= 0) { + setSelectedGroupId(groups[nextGroupIdx].itemId); + toast.success(`${groups[selectedIndex]?.title} 완료`); + } else { + setShowQuantityPanel(true); + } + } + }, [cfg.displayMode, currentItems, currentItemIdx, groups, selectedIndex, selectedGroupId]); + const saveResultValue = useCallback( async ( rowId: string, @@ -230,16 +353,33 @@ export function PopWorkDetailComponent({ ) => { setSavingIds((prev) => new Set(prev).add(rowId)); try { + const existingRow = allResults.find((r) => r.id === rowId); + const isFirstTouch = existingRow && !existingRow.started_at; + const now = new Date().toISOString(); + + const mkTask = (col: string, val: string) => ({ + type: "data-update" as const, + targetTable: "process_work_result", + targetColumn: col, + operationType: "assign" as const, + valueSource: "fixed" as const, + fixedValue: val, + lookupMode: "manual" as const, + manualItemField: "id", + manualPkColumn: "id", + }); + + const tasks = [ + mkTask("result_value", resultValue), + mkTask("status", newStatus), + ...(isPassed !== null ? [mkTask("is_passed", isPassed)] : []), + mkTask("recorded_by", user?.userId ?? ""), + mkTask("recorded_at", now), + ...(isFirstTouch ? [mkTask("started_at", now)] : []), + ]; + 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 }] }, - ], + tasks, data: { items: [{ id: rowId }], fieldValues: {} }, }); @@ -252,11 +392,22 @@ export function PopWorkDetailComponent({ status: newStatus, is_passed: isPassed, recorded_by: user?.userId ?? null, - recorded_at: new Date().toISOString(), + recorded_at: now, + started_at: r.started_at ?? now, } : r ) ); + + if (cfg.stepControl.autoAdvance && newStatus === "completed") { + setTimeout(() => { + if (cfg.displayMode === "step") { + stepAutoAdvance(); + } else { + checkAutoAdvance(); + } + }, 300); + } } catch { toast.error("저장에 실패했습니다."); } finally { @@ -267,18 +418,57 @@ export function PopWorkDetailComponent({ }); } }, - [user?.userId] + // eslint-disable-next-line react-hooks/exhaustive-deps + [user?.userId, cfg.stepControl.autoAdvance, allResults] ); + const checkAutoAdvance = useCallback(() => { + if (!selectedGroupId) return; + const groupItems = allResults.filter( + (r) => r.source_work_item_id === selectedGroupId + ); + const requiredItems = groupItems.filter((r) => r.is_required === "Y"); + const allRequiredDone = requiredItems.length > 0 && requiredItems.every((r) => r.status === "completed"); + const allDone = groupItems.every((r) => r.status === "completed"); + + if (allRequiredDone || allDone) { + const idx = groups.findIndex((g) => g.itemId === selectedGroupId); + if (idx >= 0 && idx < groups.length - 1) { + const nextGroup = groups[idx + 1]; + setSelectedGroupId(nextGroup.itemId); + contentRef.current?.scrollTo({ top: 0, behavior: "smooth" }); + toast.success(`${groups[idx].title} 완료 → ${nextGroup.title}`); + } + } + }, [selectedGroupId, allResults, groups]); + // ======================================== - // D-FE4: 타이머 + // 단계 시작/활성화 + // ======================================== + + const handleStepStart = useCallback((itemId: string) => { + setActiveStepIds((prev) => new Set(prev).add(itemId)); + }, []); + + const isStepLocked = useMemo(() => { + if (!cfg.stepControl.requireStartBeforeInput) return false; + if (!selectedGroupId) return true; + return !activeStepIds.has(selectedGroupId); + }, [cfg.stepControl.requireStartBeforeInput, selectedGroupId, activeStepIds]); + + // ======================================== + // 프로세스 타이머 (전체 공정용) // ======================================== useEffect(() => { - if (!cfg.showTimer || !processData?.started_at) return; + if (!cfg.showTimer) return; + const hasActiveGroupTimer = groups.some( + (g) => g.timer.startedAt && !g.timer.completedAt + ); + if (!hasActiveGroupTimer && !processData?.started_at) return; const id = setInterval(() => setTick(Date.now()), 1000); return () => clearInterval(id); - }, [cfg.showTimer, processData?.started_at]); + }, [cfg.showTimer, processData?.started_at, groups]); const elapsedMs = useMemo(() => { if (!processData?.started_at) return 0; @@ -291,50 +481,121 @@ export function PopWorkDetailComponent({ 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]); + // formattedTime은 제거 - 그룹별 타이머로 대체됨 + // isPaused, isStarted는 프로세스 레벨 (사용하지 않으나 processData 참조용으로 유지) - const isPaused = !!processData?.paused_at; - const isStarted = !!processData?.started_at; + // ======================================== + // 그룹별 타이머 + // ======================================== - const handleTimerAction = useCallback( - async (action: "start" | "pause" | "resume") => { - if (!workOrderProcessId) return; + const selectedGroupTimer = useMemo(() => { + const g = groups.find((g) => g.itemId === selectedGroupId); + return g?.timer ?? { startedAt: null, pausedAt: null, totalPausedTime: 0, completedAt: null }; + }, [groups, selectedGroupId]); + + // 그룹 타이머: 순수 작업시간 (일시정지 제외) + const groupWorkMs = useMemo(() => { + if (!selectedGroupTimer.startedAt) return 0; + const end = selectedGroupTimer.completedAt + ? new Date(selectedGroupTimer.completedAt).getTime() + : tick; + const start = new Date(selectedGroupTimer.startedAt).getTime(); + const totalMs = end - start; + const pausedMs = selectedGroupTimer.totalPausedTime * 1000; + const currentPauseMs = selectedGroupTimer.pausedAt + ? (selectedGroupTimer.completedAt ? 0 : tick - new Date(selectedGroupTimer.pausedAt).getTime()) + : 0; + return Math.max(0, totalMs - pausedMs - currentPauseMs); + }, [selectedGroupTimer, tick]); + + // 그룹 타이머: 경과 시간 (일시정지 무시, 시작~끝) + const groupElapsedMs = useMemo(() => { + if (!selectedGroupTimer.startedAt) return 0; + const end = selectedGroupTimer.completedAt + ? new Date(selectedGroupTimer.completedAt).getTime() + : tick; + const start = new Date(selectedGroupTimer.startedAt).getTime(); + return Math.max(0, end - start); + }, [selectedGroupTimer, tick]); + + const groupTimerFormatted = useMemo(() => formatMsToTime(groupWorkMs), [groupWorkMs]); + const groupElapsedFormatted = useMemo(() => formatMsToTime(groupElapsedMs), [groupElapsedMs]); + + const isGroupStarted = !!selectedGroupTimer.startedAt; + const isGroupPaused = !!selectedGroupTimer.pausedAt; + const isGroupCompleted = !!selectedGroupTimer.completedAt; + + const handleGroupTimerAction = useCallback( + async (action: "start" | "pause" | "resume" | "complete") => { + if (!workOrderProcessId || !selectedGroupId) return; try { - await apiClient.post("/api/pop/production/timer", { - workOrderProcessId, + await apiClient.post("/pop/production/group-timer", { + work_order_process_id: workOrderProcessId, + source_work_item_id: selectedGroupId, action, }); - // 타이머 상태 새로고침 + await fetchData(); + } catch { + toast.error("그룹 타이머 제어에 실패했습니다."); + } + }, + [workOrderProcessId, selectedGroupId, fetchData] + ); + + const handleTimerAction = useCallback( + async (action: "start" | "pause" | "resume" | "complete") => { + if (!workOrderProcessId) return; + try { + const body: Record = { + work_order_process_id: workOrderProcessId, + action, + }; + if (action === "complete") { + body.good_qty = goodQty || "0"; + body.defect_qty = defectQty || "0"; + } + await apiClient.post("/pop/production/timer", body); 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); + if (proc) { + setProcessData(proc); + if (action === "complete") { + toast.success("공정이 완료되었습니다."); + publish("process_completed", { workOrderProcessId, goodQty, defectQty }); + } + } } catch { toast.error("타이머 제어에 실패했습니다."); } }, - [workOrderProcessId] + [workOrderProcessId, goodQty, defectQty, publish] ); // ======================================== - // D-FE5: 수량 등록 + 완료 + // 수량 등록 + 완료 // ======================================== const handleQuantityRegister = useCallback(async () => { if (!workOrderProcessId) return; try { + const mkWopTask = (col: string, val: string) => ({ + type: "data-update" as const, + targetTable: "work_order_process", + targetColumn: col, + operationType: "assign" as const, + valueSource: "fixed" as const, + fixedValue: val, + lookupMode: "manual" as const, + manualItemField: "id", + manualPkColumn: "id", + }); 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 }] }, + mkWopTask("good_qty", goodQty || "0"), + mkWopTask("defect_qty", defectQty || "0"), ], data: { items: [{ id: workOrderProcessId }], fieldValues: {} }, }); @@ -345,23 +606,8 @@ export function PopWorkDetailComponent({ }, [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]); + await handleTimerAction("complete"); + }, [handleTimerAction]); // ======================================== // 안전 장치 @@ -403,6 +649,7 @@ export function PopWorkDetailComponent({ } const isProcessCompleted = processData?.status === "completed"; + const selectedGroup = groups.find((g) => g.itemId === selectedGroupId); // ======================================== // 렌더링 @@ -410,70 +657,58 @@ export function PopWorkDetailComponent({ return (
- {/* 헤더 */} -
-

{processName}

- {cfg.showTimer && ( -
- - - {formattedTime} - - {!isProcessCompleted && ( - <> - {!isStarted && ( - - )} - {isStarted && !isPaused && ( - - )} - {isStarted && isPaused && ( - - )} - - )} -
- )} -
+ {/* 작업지시 정보 바 */} + {cfg.infoBar.enabled && ( + 0 ? cfg.infoBar.fields : DEFAULT_INFO_FIELDS} + parentRow={parentRow} + processName={processName} + /> + )} - {/* 본문: 좌측 사이드바 + 우측 체크리스트 */} + {/* 전체 공정 진행 요약은 제거 - 타이머는 그룹 헤더로 이동 */} + + {/* 본문: 좌측 사이드바 + 우측 콘텐츠 */}
{/* 좌측 사이드바 */} -
+
{(["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) => ( ))}
@@ -481,73 +716,398 @@ export function PopWorkDetailComponent({ })}
- {/* 우측 체크리스트 */} -
- {selectedGroupId && ( -
- {currentItems.map((item) => ( - + {cfg.displayMode === "step" ? ( + /* ======== 스텝 모드 ======== */ + <> + {showQuantityPanel || allItemsCompleted ? ( + /* 수량 등록 + 공정 완료 화면 */ +
+ +

모든 작업 항목이 완료되었습니다

+ + {cfg.showQuantityInput && !isProcessCompleted && ( +
+

실적 수량 등록

+
+
+ 양품 + setGoodQty(e.target.value)} placeholder="0" /> +
+
+ 불량 + setDefectQty(e.target.value)} placeholder="0" /> +
+ {(parseInt(goodQty, 10) || 0) + (parseInt(defectQty, 10) || 0) > 0 && ( +

+ 합계: {(parseInt(goodQty, 10) || 0) + (parseInt(defectQty, 10) || 0)} +

+ )} +
+
+ )} + + {!isProcessCompleted && cfg.navigation.showCompleteButton && ( + + )} + + {isProcessCompleted && ( + + 공정이 완료되었습니다 + + )} +
+ ) : ( + /* 단계별 항목 표시 */ + <> + {/* 그룹 헤더 + 타이머 + 진행률 */} + {selectedGroup && ( + <> + +
+
+ {currentItemIdx + 1} / {currentItems.length} +
+
+
0 ? ((selectedGroup.completed / selectedGroup.total) * 100) : 0}%`, + }} + /> +
+
+ + )} + + {/* 현재 항목 1개 표시 */} +
+ {currentItems[currentItemIdx] && ( +
+ {currentItems[currentItemIdx].started_at && ( +
+ + {currentItems[currentItemIdx].recorded_at + ? formatDuration(currentItems[currentItemIdx].started_at!, currentItems[currentItemIdx].recorded_at!) + : "진행 중..."} +
+ )} + +
+ )} +
+ + {/* 스텝 네비게이션 */} +
+ + + + {selectedGroup?.title} ({currentItemIdx + 1}/{currentItems.length}) + + + +
+ + )} + + ) : ( + /* ======== 리스트 모드 (기존) ======== */ + <> + {/* 그룹 헤더 + 타이머 */} + {selectedGroup && ( + - ))} -
+ )} + + {/* 체크리스트 콘텐츠 */} +
+ {selectedGroupId && ( +
+ {currentItems.map((item) => ( + + ))} +
+ )} +
+ + {/* 하단 네비게이션 + 수량/완료 */} +
+ {cfg.showQuantityInput && ( +
+ +
+ 양품 + setGoodQty(e.target.value)} disabled={isProcessCompleted} /> +
+
+ 불량 + setDefectQty(e.target.value)} disabled={isProcessCompleted} /> +
+ +
+ )} + + {cfg.navigation.showPrevNext && ( +
+ + + + {selectedIndex + 1} / {groups.length} + + + {selectedIndex < groups.length - 1 ? ( + + ) : cfg.navigation.showCompleteButton && !isProcessCompleted ? ( + + ) : ( +
+ )} +
+ )} +
+ )}
+
+ ); +} - {/* 하단: 수량 입력 + 완료 */} - {cfg.showQuantityInput && ( -
- -
- 양품 - setGoodQty(e.target.value)} - disabled={isProcessCompleted} - /> +// ======================================== +// 유틸리티 +// ======================================== + +function formatSeconds(totalSec: number): string { + 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}`; +} + +function formatMsToTime(ms: number): string { + return formatSeconds(Math.floor(ms / 1000)); +} + +function formatDuration(startISO: string, endISO: string): string { + const diffMs = new Date(endISO).getTime() - new Date(startISO).getTime(); + if (diffMs <= 0) return "0초"; + const totalSec = Math.floor(diffMs / 1000); + if (totalSec < 60) return `${totalSec}초`; + const min = Math.floor(totalSec / 60); + const sec = totalSec % 60; + return sec > 0 ? `${min}분 ${sec}초` : `${min}분`; +} + +// ======================================== +// 작업지시 정보 바 +// ======================================== + +interface InfoBarProps { + fields: Array<{ label: string; column: string }>; + parentRow: RowData; + processName: string; +} + +function InfoBar({ fields, parentRow, processName }: InfoBarProps) { + return ( +
+ {fields.map((f) => { + const val = f.column === "__process_name" + ? processName + : parentRow[f.column]; + return ( +
+ {f.label} + {val != null ? String(val) : "-"}
-
- 불량 - setDefectQty(e.target.value)} - disabled={isProcessCompleted} - /> + ); + })} +
+ ); +} + +// ======================================== +// 그룹별 타이머 헤더 +// ======================================== + +interface GroupTimerHeaderProps { + group: WorkGroup; + cfg: PopWorkDetailConfig; + isProcessCompleted: boolean; + isGroupStarted: boolean; + isGroupPaused: boolean; + isGroupCompleted: boolean; + groupTimerFormatted: string; + groupElapsedFormatted: string; + onTimerAction: (action: "start" | "pause" | "resume" | "complete") => void; +} + +function GroupTimerHeader({ + group, + cfg, + isProcessCompleted, + isGroupStarted, + isGroupPaused, + isGroupCompleted, + groupTimerFormatted, + groupElapsedFormatted, + onTimerAction, +}: GroupTimerHeaderProps) { + return ( +
+ {/* 그룹 제목 + 진행 카운트 */} +
+
+ {group.title} + + {group.completed}/{group.total} + +
+ {isGroupCompleted && ( + 완료 + )} +
+ + {/* 그룹 타이머 */} + {cfg.showTimer && ( +
+
+
+ + {groupTimerFormatted} + 작업 +
+
+ {groupElapsedFormatted} + 경과 +
- -
- {!isProcessCompleted && ( - - )} - {isProcessCompleted && ( - - 완료됨 - + {!isProcessCompleted && !isGroupCompleted && ( +
+ {!isGroupStarted && ( + + )} + {isGroupStarted && !isGroupPaused && ( + <> + + + + )} + {isGroupStarted && isGroupPaused && ( + <> + + + + )} +
)}
)} @@ -556,98 +1116,102 @@ export function PopWorkDetailComponent({ } // ======================================== -// 체크리스트 개별 항목 +// 단계 상태 아이콘 +// ======================================== + +function StepStatusIcon({ status }: { status: "pending" | "active" | "completed" }) { + switch (status) { + case "completed": + return ; + case "active": + return ; + default: + return
; + } +} + +// ======================================== +// 체크리스트 개별 항목 (라우터) // ======================================== interface ChecklistItemProps { item: WorkResultRow; saving: boolean; disabled: boolean; - onSave: ( - rowId: string, - resultValue: string, - isPassed: string | null, - newStatus: string - ) => void; + 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; + const isDisabled = disabled || saving; switch (item.detail_type) { case "check": - return ; + return ; case "inspect": - return ; + return ; case "input": - return ; + return ; case "procedure": - return ; + return ; case "material": - return ; + return ; + case "result": + return ; default: return ( -
+
알 수 없는 유형: {item.detail_type}
); } } -// ===== check: 체크박스 ===== +// ======================================== +// check: 체크박스 +// ======================================== -function CheckItem({ - item, - disabled, - saving, - onSave, -}: { - item: WorkResultRow; - disabled: boolean; - saving: boolean; - onSave: ChecklistItemProps["onSave"]; -}) { +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 && ( - - 완료 - - )} + {item.detail_label || item.detail_content} + {item.is_required === "Y" && 필수} + {saving && } + {item.status === "completed" && !saving && 완료}
); } -// ===== inspect: 측정값 입력 (범위 판정) ===== +// ======================================== +// inspect: 라우터 (input_type으로 분기) +// ======================================== -function InspectItem({ - item, - disabled, - saving, - onSave, -}: { - item: WorkResultRow; - disabled: boolean; - saving: boolean; - onSave: ChecklistItemProps["onSave"]; -}) { +function InspectRouter(props: { item: WorkResultRow; disabled: boolean; saving: boolean; onSave: ChecklistItemProps["onSave"] }) { + const inputType = props.item.input_type ?? "numeric_range"; + switch (inputType) { + case "ox": + return ; + case "select": + return ; + case "text": + return ; + default: + return ; + } +} + +// ===== inspect: 수치(범위) ===== + +function InspectNumeric({ 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 ?? ""); @@ -663,60 +1227,183 @@ function InspectItem({ onSave(item.id, inputVal, passed, "completed"); }; - const isPassed = item.is_passed; - return ( -
-
- {item.detail_label} +
+
+
+ {item.detail_label || item.detail_content} + {item.is_required === "Y" && 필수} +
{hasRange && ( - - 기준: {item.lower_limit} ~ {item.upper_limit} + + {item.lower_limit} ~ {item.upper_limit} {item.unit || ""} {item.spec_value ? ` (표준: ${item.spec_value})` : ""} )}
-
- setInputVal(e.target.value)} - onBlur={handleBlur} - disabled={disabled} - placeholder="측정값 입력" - /> - {saving && } - {isPassed === "Y" && !saving && ( - 합격 - )} - {isPassed === "N" && !saving && ( - 불합격 - )} + {item.inspection_method && ( +
{item.inspection_method}
+ )} +
+ setInputVal(e.target.value)} onBlur={handleBlur} disabled={disabled} placeholder="측정값 입력" /> + {item.unit && {item.unit}} + {saving && } + {item.is_passed === "Y" && !saving && 합격} + {item.is_passed === "N" && !saving && 불합격}
); } -// ===== input: 자유 입력 ===== +// ===== inspect: O/X ===== -function InputItem({ - item, - disabled, - saving, - onSave, -}: { - item: WorkResultRow; - disabled: boolean; - saving: boolean; - onSave: ChecklistItemProps["onSave"]; -}) { +function InspectOX({ item, disabled, saving, onSave }: { item: WorkResultRow; disabled: boolean; saving: boolean; onSave: ChecklistItemProps["onSave"] }) { + const currentValue = item.result_value ?? ""; + const criteriaOK = item.spec_value ?? "OK"; + + const handleSelect = (value: string) => { + if (disabled) return; + const passed = value === criteriaOK ? "Y" : "N"; + onSave(item.id, value, passed, "completed"); + }; + + return ( +
+
+ {item.detail_label || item.detail_content} + {item.is_required === "Y" && 필수} +
+
+ + + {saving && } + {item.is_passed === "Y" && !saving && 합격} + {item.is_passed === "N" && !saving && 불합격} +
+
+ ); +} + +// ===== inspect: 선택형 ===== + +function InspectSelect({ item, disabled, saving, onSave }: { item: WorkResultRow; disabled: boolean; saving: boolean; onSave: ChecklistItemProps["onSave"] }) { + const currentValue = item.result_value ?? ""; + let options: string[] = []; + let passValues: string[] = []; + + try { + const parsed = JSON.parse(item.spec_value ?? "{}"); + options = parsed.options ?? []; + passValues = parsed.passValues ?? []; + } catch { + options = (item.spec_value ?? "").split(",").filter(Boolean); + } + + const handleSelect = (value: string) => { + if (disabled) return; + const passed = passValues.includes(value) ? "Y" : "N"; + onSave(item.id, value, passed, "completed"); + }; + + return ( +
+
+ {item.detail_label || item.detail_content} + {item.is_required === "Y" && 필수} +
+
+ {options.map((opt) => ( + + ))} + {saving && } + {item.is_passed === "Y" && !saving && 합격} + {item.is_passed === "N" && !saving && 불합격} +
+
+ ); +} + +// ===== inspect: 텍스트 입력 + 수동 판정 ===== + +function InspectText({ item, disabled, saving, onSave }: { item: WorkResultRow; disabled: boolean; saving: boolean; onSave: ChecklistItemProps["onSave"] }) { + const [inputVal, setInputVal] = useState(item.result_value ?? ""); + const [judged, setJudged] = useState(item.is_passed); + + const handleJudge = (passed: string) => { + if (disabled) return; + setJudged(passed); + onSave(item.id, inputVal || "-", passed, "completed"); + }; + + const handleBlur = () => { + if (!inputVal || disabled) return; + if (judged) { + onSave(item.id, inputVal, judged, "completed"); + } + }; + + return ( +
+
+ {item.detail_label || item.detail_content} + {item.is_required === "Y" && 필수} +
+
+ setInputVal(e.target.value)} onBlur={handleBlur} disabled={disabled} placeholder="내용 입력" /> + + + {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"; @@ -726,106 +1413,153 @@ function InputItem({ }; return ( -
-
{item.detail_label}
-
- setInputVal(e.target.value)} - onBlur={handleBlur} - disabled={disabled} - placeholder="값 입력" - /> - {saving && } +
+
{item.detail_label || item.detail_content}
+
+ setInputVal(e.target.value)} onBlur={handleBlur} disabled={disabled} placeholder="값 입력" /> + {saving && }
); } -// ===== procedure: 절차 확인 (읽기 전용 + 체크) ===== +// ======================================== +// procedure: 절차 확인 +// ======================================== -function ProcedureItem({ - item, - disabled, - saving, - onSave, -}: { - item: WorkResultRow; - disabled: boolean; - saving: boolean; - onSave: ChecklistItemProps["onSave"]; -}) { +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} +
+
+
+ {item.detail_sort_order || "?"} +
+ {item.detail_content || item.detail_label} + {item.is_required === "Y" && 필수}
-
+
{ onSave(item.id, v ? "Y" : "N", null, v ? "completed" : "pending"); }} /> - 확인 - {saving && } + 확인 + {saving && }
); } -// ===== material: 자재/LOT 입력 ===== +// ======================================== +// material: 자재/LOT 입력 (바코드 스캔 지원) +// ======================================== -function MaterialItem({ - item, - disabled, - saving, - onSave, -}: { - item: WorkResultRow; - disabled: boolean; - saving: boolean; - onSave: ChecklistItemProps["onSave"]; -}) { +function MaterialItem({ item, disabled, saving, onSave }: { item: WorkResultRow; disabled: boolean; saving: boolean; onSave: ChecklistItemProps["onSave"] }) { const [inputVal, setInputVal] = useState(item.result_value ?? ""); + const inputRef = useRef(null); - const handleBlur = () => { + const handleSubmit = () => { if (!inputVal || disabled) return; onSave(item.id, inputVal, null, "completed"); }; + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + handleSubmit(); + } + }; + return ( -
-
{item.detail_label}
-
+
+
+ + {item.detail_label || item.detail_content} + {item.is_required === "Y" && 필수} +
+
setInputVal(e.target.value)} - onBlur={handleBlur} + onKeyDown={handleKeyDown} disabled={disabled} - placeholder="LOT 번호 입력" + placeholder="LOT/바코드 스캔 또는 입력" /> - {saving && } + + {saving && } + {item.status === "completed" && !saving && 투입} +
+
+ ); +} + +// ======================================== +// result: 실적 입력 (양품/불량/불량유형) +// ======================================== + +function ResultInputItem({ item, disabled, saving, onSave }: { item: WorkResultRow; disabled: boolean; saving: boolean; onSave: ChecklistItemProps["onSave"] }) { + let savedData: { good?: string; defect?: string; defectType?: string; note?: string } = {}; + try { + savedData = JSON.parse(item.result_value ?? "{}"); + } catch { + savedData = {}; + } + + const [good, setGood] = useState(savedData.good ?? ""); + const [defect, setDefect] = useState(savedData.defect ?? ""); + const [defectType, setDefectType] = useState(savedData.defectType ?? ""); + const [note, setNote] = useState(savedData.note ?? ""); + + const handleSave = () => { + if (disabled) return; + const val = JSON.stringify({ good, defect, defectType, note }); + onSave(item.id, val, null, "completed"); + }; + + const total = (parseInt(good, 10) || 0) + (parseInt(defect, 10) || 0); + + return ( +
+
{item.detail_label || item.detail_content || "실적 입력"}
+ +
+
+ 양품 + setGood(e.target.value)} disabled={disabled} placeholder="0" /> +
+
+ 불량 + setDefect(e.target.value)} disabled={disabled} placeholder="0" /> +
+ 합계: {total} +
+ + {parseInt(defect, 10) > 0 && ( +
+ 불량유형 + setDefectType(e.target.value)} disabled={disabled} placeholder="스크래치, 치수불량 등" /> +
+ )} + +
+ 비고 + setNote(e.target.value)} disabled={disabled} placeholder="비고 (선택)" /> +
+ +
+ + {saving && } + {item.status === "completed" && !saving && 등록됨}
); diff --git a/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailConfig.tsx b/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailConfig.tsx index 7b75cf78..14d7cdfa 100644 --- a/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailConfig.tsx @@ -1,9 +1,13 @@ "use client"; +import React, { useState } from "react"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { Input } from "@/components/ui/input"; -import type { PopWorkDetailConfig } from "../types"; +import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Plus, Trash2 } from "lucide-react"; +import type { PopWorkDetailConfig, WorkDetailInfoBarField } from "../types"; interface PopWorkDetailConfigPanelProps { config?: PopWorkDetailConfig; @@ -16,6 +20,21 @@ const DEFAULT_PHASE_LABELS: Record = { POST: "작업 후", }; +const DEFAULT_INFO_BAR = { + enabled: true, + fields: [] as WorkDetailInfoBarField[], +}; + +const DEFAULT_STEP_CONTROL = { + requireStartBeforeInput: false, + autoAdvance: true, +}; + +const DEFAULT_NAVIGATION = { + showPrevNext: true, + showCompleteButton: true, +}; + export function PopWorkDetailConfigPanel({ config, onChange, @@ -23,50 +42,141 @@ export function PopWorkDetailConfigPanel({ const cfg: PopWorkDetailConfig = { showTimer: config?.showTimer ?? true, showQuantityInput: config?.showQuantityInput ?? true, + displayMode: config?.displayMode ?? "list", phaseLabels: config?.phaseLabels ?? { ...DEFAULT_PHASE_LABELS }, + infoBar: config?.infoBar ?? { ...DEFAULT_INFO_BAR }, + stepControl: config?.stepControl ?? { ...DEFAULT_STEP_CONTROL }, + navigation: config?.navigation ?? { ...DEFAULT_NAVIGATION }, }; const update = (partial: Partial) => { onChange?.({ ...cfg, ...partial }); }; + const [newFieldLabel, setNewFieldLabel] = useState(""); + const [newFieldColumn, setNewFieldColumn] = useState(""); + + const addInfoBarField = () => { + if (!newFieldLabel || !newFieldColumn) return; + const fields = [...(cfg.infoBar.fields ?? []), { label: newFieldLabel, column: newFieldColumn }]; + update({ infoBar: { ...cfg.infoBar, fields } }); + setNewFieldLabel(""); + setNewFieldColumn(""); + }; + + const removeInfoBarField = (idx: number) => { + const fields = (cfg.infoBar.fields ?? []).filter((_, i) => i !== idx); + update({ infoBar: { ...cfg.infoBar, fields } }); + }; + return ( -
-
- - update({ showTimer: v })} - /> -
+
+ {/* 기본 설정 */} +
+
+ + +
+ update({ showTimer: v })} /> + update({ showQuantityInput: v })} /> +
-
- - update({ showQuantityInput: v })} + {/* 정보 바 */} +
+ update({ infoBar: { ...cfg.infoBar, enabled: v } })} /> -
+ {cfg.infoBar.enabled && ( +
+ {(cfg.infoBar.fields ?? []).map((f, i) => ( +
+ {f.label} + {f.column} + +
+ ))} +
+ setNewFieldLabel(e.target.value)} /> + setNewFieldColumn(e.target.value)} /> + +
+
+ )} + -
- + {/* 단계 제어 */} +
+ update({ stepControl: { ...cfg.stepControl, requireStartBeforeInput: v } })} + /> + update({ stepControl: { ...cfg.stepControl, autoAdvance: v } })} + /> +
+ + {/* 네비게이션 */} +
+ update({ navigation: { ...cfg.navigation, showPrevNext: v } })} + /> + update({ navigation: { ...cfg.navigation, showCompleteButton: v } })} + /> +
+ + {/* 단계 라벨 */} +
{(["PRE", "IN", "POST"] as const).map((phase) => (
- - {phase} - + {phase} - update({ - phaseLabels: { ...cfg.phaseLabels, [phase]: e.target.value }, - }) - } + onChange={(e) => update({ phaseLabels: { ...cfg.phaseLabels, [phase]: e.target.value } })} />
))} -
+ +
+ ); +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+
{title}
+ {children} +
+ ); +} + +function ToggleRow({ label, checked, onChange }: { label: string; checked: boolean; onChange: (v: boolean) => void }) { + return ( +
+ +
); } diff --git a/frontend/lib/registry/pop-components/pop-work-detail/index.tsx b/frontend/lib/registry/pop-components/pop-work-detail/index.tsx index 941db8d4..f453fd7a 100644 --- a/frontend/lib/registry/pop-components/pop-work-detail/index.tsx +++ b/frontend/lib/registry/pop-components/pop-work-detail/index.tsx @@ -9,7 +9,25 @@ import type { PopWorkDetailConfig } from "../types"; const defaultConfig: PopWorkDetailConfig = { showTimer: true, showQuantityInput: true, + displayMode: "list", phaseLabels: { PRE: "작업 전", IN: "작업 중", POST: "작업 후" }, + infoBar: { + enabled: true, + fields: [ + { label: "작업지시", column: "wo_no" }, + { label: "품목", column: "item_name" }, + { label: "공정", column: "__process_name" }, + { label: "지시수량", column: "qty" }, + ], + }, + stepControl: { + requireStartBeforeInput: false, + autoAdvance: true, + }, + navigation: { + showPrevNext: true, + showCompleteButton: true, + }, }; PopComponentRegistry.registerComponent({ diff --git a/frontend/lib/registry/pop-components/types.ts b/frontend/lib/registry/pop-components/types.ts index a32a53cd..94540d27 100644 --- a/frontend/lib/registry/pop-components/types.ts +++ b/frontend/lib/registry/pop-components/types.ts @@ -1006,8 +1006,34 @@ export const VIRTUAL_SUB_SEQ = "__subSeqNo__" as const; // pop-work-detail 전용 타입 // ============================================= +export interface WorkDetailInfoBarField { + label: string; + column: string; +} + +export interface WorkDetailInfoBarConfig { + enabled: boolean; + fields: WorkDetailInfoBarField[]; +} + +export interface WorkDetailStepControl { + requireStartBeforeInput: boolean; + autoAdvance: boolean; +} + +export interface WorkDetailNavigationConfig { + showPrevNext: boolean; + showCompleteButton: boolean; +} + export interface PopWorkDetailConfig { showTimer: boolean; + /** @deprecated result-input 타입으로 대체 */ showQuantityInput: boolean; + /** 표시 모드: list(기존 리스트), step(한 항목씩 진행) */ + displayMode: "list" | "step"; phaseLabels: Record; + infoBar: WorkDetailInfoBarConfig; + stepControl: WorkDetailStepControl; + navigation: WorkDetailNavigationConfig; }