From bb6e17ec28c0f5c281d413c41ff4e6ede2f4e7e9 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Wed, 25 Mar 2026 11:30:54 +0900 Subject: [PATCH] =?UTF-8?q?refactor(pop-work-detail):=20=EC=82=AC=EC=9D=B4?= =?UTF-8?q?=EB=93=9C=EB=B0=94=E2=86=92=ED=83=AD=20=EC=A0=84=ED=99=98,=20KP?= =?UTF-8?q?I=20=EC=B9=B4=EB=93=9C,=20=ED=92=8B=ED=84=B0=20=EC=95=A1?= =?UTF-8?q?=EC=85=98=EB=B0=94=20=EB=8F=84=EC=9E=85=20=EB=A6=AC=EC=84=9C?= =?UTF-8?q?=EC=B9=98=20=EB=B0=98=EC=98=81=20UX=20=EA=B5=AC=EC=A1=B0=20?= =?UTF-8?q?=EB=8C=80=ED=8F=AD=20=EB=B3=80=EA=B2=BD:=20-=20=EC=A2=8C?= =?UTF-8?q?=EC=B8=A1=20=EC=82=AC=EC=9D=B4=EB=93=9C=EB=B0=94(220px)=20?= =?UTF-8?q?=E2=86=92=20=EC=83=81=EB=8B=A8=20=ED=83=AD=20=EB=B0=94(48px)?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=84=ED=99=98=20(=EC=9E=91=EC=97=85=20?= =?UTF-8?q?=EC=A0=84|=EC=9E=91=EC=97=85=20=EC=A4=91|=EC=9E=91=EC=97=85=20?= =?UTF-8?q?=ED=9B=84|=EC=8B=A4=EC=A0=81)=20-=20=ED=83=AD=EB=A7=88=EB=8B=A4?= =?UTF-8?q?=20=EC=A7=84=ED=96=89=EB=A5=A0=20=ED=91=9C=EC=8B=9C=20(?= =?UTF-8?q?=EC=98=88:=20"=EC=9E=91=EC=97=85=20=EC=A0=84=202/5")=20-=20?= =?UTF-8?q?=EA=B3=A0=EC=A0=95=20=ED=97=A4=EB=8D=94:=20=EC=A7=84=ED=95=9C?= =?UTF-8?q?=20=EB=B0=B0=EA=B2=BD(#263238)=20+=20=ED=9D=B0=EC=83=89=20?= =?UTF-8?q?=ED=85=8D=EC=8A=A4=ED=8A=B8=EB=A1=9C=20=EC=9E=91=EC=97=85=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=ED=91=9C=EC=8B=9C=20-=20KPI=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20=EC=83=81=EC=8B=9C=20=ED=91=9C=EC=8B=9C:=20?= =?UTF-8?q?=EC=A0=91=EC=88=98=EB=9F=89/=EC=9E=91=EC=97=85=EC=99=84?= =?UTF-8?q?=EB=A3=8C/=EC=9E=94=EC=97=AC/=EB=B6=88=EB=9F=89=20(40px=20bold,?= =?UTF-8?q?=20=EC=83=89=EC=83=81=20=EA=B5=AC=EB=B6=84)=20-=20=EA=B3=A0?= =?UTF-8?q?=EC=A0=95=20=ED=92=8B=ED=84=B0=20=EC=95=A1=EC=85=98=EB=B0=94:?= =?UTF-8?q?=20=EC=9D=BC=EC=8B=9C=EC=A0=95=EC=A7=80(=ED=99=A9)/=EB=B6=88?= =?UTF-8?q?=EB=9F=89=EB=93=B1=EB=A1=9D(=EC=A0=81)/=EC=9E=91=EC=97=85?= =?UTF-8?q?=EC=99=84=EB=A3=8C(=EB=85=B9)=203=EB=B2=84=ED=8A=BC=20-=20?= =?UTF-8?q?=EC=9E=91=EC=97=85=EC=99=84=EB=A3=8C=202=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8=20(=ED=81=B4=EB=A6=AD=20=E2=86=92=20"?= =?UTF-8?q?=EC=A0=95=EB=A7=90=20=EC=99=84=EB=A3=8C=3F"=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8)=20-=20=EB=B0=B0=EA=B2=BD=EC=83=89=20#F5F5F5=20(?= =?UTF-8?q?=EA=B3=B5=EC=9E=A5=20=EC=A1=B0=EB=AA=85=20=EB=88=88=EB=B6=80?= =?UTF-8?q?=EC=8B=AC=20=EB=B0=A9=EC=A7=80)=20-=20=EC=B2=B4=ED=81=AC?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=ED=96=89:=20=EC=A2=8C=EC=B8=A1?= =?UTF-8?q?=20=EC=83=81=ED=83=9C=20=EB=B0=94=20(=EC=99=84=EB=A3=8C=3D?= =?UTF-8?q?=EB=85=B9,=20=ED=95=84=EC=88=98=3D=EC=A0=81,=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=3D=ED=9A=8C)=20-=20ChecklistRowItem=20=EB=9E=98?= =?UTF-8?q?=ED=8D=BC=EB=A1=9C=20=ED=96=89=20=EC=A0=84=EC=B2=B4=20=ED=84=B0?= =?UTF-8?q?=EC=B9=98=20=EC=98=81=EC=97=AD=20+=20=EC=8B=9C=EA=B0=81=20?= =?UTF-8?q?=ED=94=BC=EB=93=9C=EB=B0=B1=20-=20DESIGN=20=ED=86=A0=ED=81=B0?= =?UTF-8?q?=20=ED=99=95=EC=9E=A5:=20tab,=20footer,=20header,=20kpi,=20bg?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20-=20COLORS=20=ED=99=95=EC=9E=A5:=20kpiI?= =?UTF-8?q?nput,=20kpiComplete,=20kpiRemaining,=20kpiDefect=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20-=20step=20=EB=AA=A8=EB=93=9C=EC=99=80=20list=20?= =?UTF-8?q?=EB=AA=A8=EB=93=9C=20=EB=AA=A8=EB=91=90=20=ED=83=AD=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EC=95=88=EC=97=90=EC=84=9C=20=EC=A0=95=EC=83=81=20?= =?UTF-8?q?=EB=8F=99=EC=9E=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PopWorkDetailComponent.tsx | 546 ++++++++++++------ 1 file changed, 355 insertions(+), 191 deletions(-) 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 dfc5151e..2d199537 100644 --- a/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailComponent.tsx @@ -4,7 +4,7 @@ import React, { useEffect, useState, useMemo, useCallback, useRef } from "react" import { Loader2, Play, Pause, CheckCircle2, AlertCircle, Timer, Package, ChevronLeft, ChevronRight, Check, X, CircleDot, ClipboardList, - Plus, Trash2, Save, FileCheck, Construction, + Plus, Trash2, Save, FileCheck, Construction, AlertTriangle, } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -140,12 +140,16 @@ const DEFAULT_CFG: PopWorkDetailConfig = { const DESIGN = { button: { height: 56, minWidth: 100 }, input: { height: 56 }, - stat: { valueSize: 36, labelSize: 14, weight: 700 }, + stat: { valueSize: 40, labelSize: 14, weight: 700 }, section: { titleSize: 16, gap: 20 }, - sidebar: { width: 220, itemPadding: '16px 20px' }, + tab: { height: 48 }, + footer: { height: 64 }, + header: { height: 56 }, + kpi: { valueSize: 40, labelSize: 14, weight: 700 }, nav: { height: 56 }, infoBar: { labelSize: 14, valueSize: 16 }, defectRow: { height: 56 }, + bg: { page: '#F5F5F5', card: '#FFFFFF', header: '#263238' }, } as const; const COLORS = { @@ -154,6 +158,10 @@ const COLORS = { complete: 'text-blue-600', warning: 'text-amber-600', info: 'text-violet-600', + kpiInput: 'text-gray-900', + kpiComplete: 'text-blue-600', + kpiRemaining: 'text-amber-600', + kpiDefect: 'text-red-600', } as const; // ======================================== @@ -220,10 +228,14 @@ export function PopWorkDetailComponent({ const [currentItemIdx, setCurrentItemIdx] = useState(0); const [showQuantityPanel, setShowQuantityPanel] = useState(false); - // 실적 입력 탭 상태 + // 탭 상태: 상단 탭 (작업 전 | 작업 중 | 작업 후 | 실적) + const [activePhaseTab, setActivePhaseTab] = useState(null); const [resultTabActive, setResultTabActive] = useState(false); const hasResultSections = !!(cfg.resultSections && cfg.resultSections.some((s) => s.enabled)); + // 2단계 확인 (작업완료 버튼) + const [confirmCompleteOpen, setConfirmCompleteOpen] = useState(false); + // 불량 유형 목록 (부모에서 1회 로드, ResultPanel에 전달) const [cachedDefectTypes, setCachedDefectTypes] = useState([]); @@ -333,13 +345,37 @@ export function PopWorkDetailComponent({ return result; }, [groups]); + // 사용 가능한 phase 목록 (탭으로 표시) + const availablePhases = useMemo(() => { + const phases: WorkPhase[] = []; + if (groupsByPhase["PRE"]?.length) phases.push("PRE"); + if (groupsByPhase["IN"]?.length) phases.push("IN"); + if (groupsByPhase["POST"]?.length) phases.push("POST"); + return phases; + }, [groupsByPhase]); + + // 탭별 완료 카운트 (탭 라벨에 표시) + const phaseProgress = useMemo(() => { + const result: Record = {}; + for (const phase of availablePhases) { + const phaseGrps = groupsByPhase[phase] || []; + const totalItems = phaseGrps.reduce((s, g) => s + g.total, 0); + const doneItems = phaseGrps.reduce((s, g) => s + g.completed, 0); + result[phase] = { done: doneItems, total: totalItems }; + } + return result; + }, [availablePhases, groupsByPhase]); + useEffect(() => { if (groups.length > 0 && !selectedGroupId) { setSelectedGroupId(groups[0].itemId); + if (!activePhaseTab) { + setActivePhaseTab(groups[0].phase); + } } else if (groups.length === 0 && hasResultSections && !resultTabActive) { setResultTabActive(true); } - }, [groups, selectedGroupId, hasResultSections, resultTabActive]); + }, [groups, selectedGroupId, hasResultSections, resultTabActive, activePhaseTab]); // 현재 선택 인덱스 const selectedIndex = useMemo( @@ -713,13 +749,37 @@ export function PopWorkDetailComponent({ } const selectedGroup = groups.find((g) => g.itemId === selectedGroupId); + // 현재 탭의 그룹 목록 + const currentTabGroups = useMemo( + () => (activePhaseTab && !resultTabActive ? groupsByPhase[activePhaseTab] ?? [] : []), + [activePhaseTab, resultTabActive, groupsByPhase] + ); + + // 탭 전환 시 해당 phase의 첫 번째 그룹 자동 선택 + const handlePhaseTabChange = useCallback((phase: string) => { + setActivePhaseTab(phase); + setResultTabActive(false); + const phaseGrps = groupsByPhase[phase]; + if (phaseGrps && phaseGrps.length > 0) { + setSelectedGroupId(phaseGrps[0].itemId); + } + contentRef.current?.scrollTo({ top: 0, behavior: "smooth" }); + }, [groupsByPhase]); + + const handleResultTabClick = useCallback(() => { + setResultTabActive(true); + setActivePhaseTab(null); + setSelectedGroupId(null); + contentRef.current?.scrollTo({ top: 0, behavior: "smooth" }); + }, []); + // ======================================== // 렌더링 // ======================================== return ( -
- {/* 작업지시 정보 바 */} +
+ {/* ── 고정 헤더: 작업 정보 ── */} {cfg.infoBar.enabled && ( 0 ? cfg.infoBar.fields : DEFAULT_INFO_FIELDS} @@ -728,110 +788,77 @@ export function PopWorkDetailComponent({ /> )} - {/* 전체 공정 진행 요약은 제거 - 타이머는 그룹 헤더로 이동 */} + {/* ── KPI 카드 (항상 표시) ── */} + - {/* 본문: 좌측 사이드바 + 우측 콘텐츠 */} -
- {/* 좌측 사이드바 */} -
- {(["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) => ( - - ))} -
- ); - })} + {/* ── 탭 바 ── */} +
+ {availablePhases.map((phase) => { + const progress = phaseProgress[phase]; + const isActive = !resultTabActive && activePhaseTab === phase; + return ( + + ); + })} + {hasResultSections && ( + + )} +
- {/* 실적 입력 탭 (resultSections가 설정된 경우만) */} - {cfg.resultSections && cfg.resultSections.some((s) => s.enabled) && ( - <> -
- - - )} -
+ {/* ── 콘텐츠 영역 (스크롤) ── */} +
+ {/* 실적 입력 패널 (hidden으로 상태 유지, unmount 방지) */} + {hasResultSections && ( +
+ { + setProcessData((prev) => prev ? { ...prev, ...updated } : prev); + publish("process_completed", { workOrderProcessId, status: updated?.status }); + }} + /> +
+ )} - {/* 우측 콘텐츠 */} -
- {/* 실적 입력 패널 (hidden으로 상태 유지, unmount 방지) */} - {hasResultSections && ( -
- { - setProcessData((prev) => prev ? { ...prev, ...updated } : prev); - publish("process_completed", { workOrderProcessId, status: updated?.status }); - }} - /> -
- )} - - {/* 체크리스트 영역 */} -
+ {/* 체크리스트 영역 */} +
{cfg.displayMode === "step" ? ( /* ======== 스텝 모드 ======== */ <> @@ -842,7 +869,7 @@ export function PopWorkDetailComponent({

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

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

실적 수량 등록

@@ -862,16 +889,6 @@ export function PopWorkDetailComponent({
)} - {!isProcessCompleted && cfg.navigation.showCompleteButton && ( - - )} - {isProcessCompleted && ( 공정이 완료되었습니다 @@ -895,7 +912,7 @@ export function PopWorkDetailComponent({ groupElapsedFormatted={groupElapsedFormatted} onTimerAction={handleGroupTimerAction} /> -
+
{currentItemIdx + 1} / {currentItems.length}
@@ -934,7 +951,7 @@ export function PopWorkDetailComponent({
{/* 스텝 네비게이션 */} -
+
+ ))} +
+ )} + {/* 그룹 헤더 + 타이머 */} {selectedGroup && ( {currentItems.map((item) => ( - )}
- - {/* 하단 네비게이션 + 수량/완료 */} -
- {cfg.showQuantityInput && !hasResultSections && ( -
- -
- 양품 - 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 ? ( - - ) : ( -
- )} -
- )} -
)} -
+ + {/* ── 고정 풋터 액션바 ── */} + {!isProcessCompleted && ( +
+ {/* 일시정지 */} + + + {/* 불량등록 */} + + + {/* 작업완료 (2단계 확인) */} + {!confirmCompleteOpen ? ( + + ) : ( +
+ + +
+ )} +
+ )} + + {isProcessCompleted && ( +
+ + + 공정이 완료되었습니다 + +
+ )}
); } @@ -1630,15 +1702,24 @@ interface InfoBarProps { 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) : "-"} + {f.label} + {val != null ? String(val) : "-"}
); })} @@ -1646,6 +1727,48 @@ function InfoBar({ fields, parentRow, processName }: InfoBarProps) { ); } +// ======================================== +// KPI 카드 (항상 표시) +// ======================================== + +interface KpiCardsProps { + inputQty: number; + completedQty: number; + defectQty: number; +} + +function KpiCards({ inputQty, completedQty, defectQty }: KpiCardsProps) { + const remaining = Math.max(0, inputQty - completedQty); + const cards = [ + { label: "접수량", value: inputQty, color: COLORS.kpiInput }, + { label: "작업완료", value: completedQty, color: COLORS.kpiComplete }, + { label: "잔여", value: remaining, color: COLORS.kpiRemaining }, + { label: "불량", value: defectQty, color: COLORS.kpiDefect }, + ]; + + return ( +
+ {cards.map((c) => ( +
+ + {c.value} + + + {c.label} + +
+ ))} +
+ ); +} + // ======================================== // 그룹별 타이머 헤더 // ======================================== @@ -1674,7 +1797,7 @@ function GroupTimerHeader({ onTimerAction, }: GroupTimerHeaderProps) { return ( -
+
{/* 그룹 제목 + 진행 카운트 */}
@@ -1752,6 +1875,47 @@ function StepStatusIcon({ status }: { status: "pending" | "active" | "completed" } } +// ======================================== +// 체크리스트 행 래퍼 (행 전체 터치 영역 + 상태 표시) +// ======================================== + +function ChecklistRowItem({ item, saving, disabled, onSave }: { + item: WorkResultRow; + saving: boolean; + disabled: boolean; + onSave: (rowId: string, resultValue: string, isPassed: string | null, newStatus: string) => void; +}) { + const isCompleted = item.status === "completed"; + const isRequired = item.is_required === "Y"; + + return ( +
+ {/* 좌측 상태 바 */} +
+ {/* 필수 표시 */} + {isRequired && !isCompleted && ( +
+ * +
+ )} +
+ +
+
+ ); +} + // ======================================== // 체크리스트 개별 항목 (라우터) // ========================================