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 && ( +
+ * +
+ )} +
+ +
+
+ ); +} + // ======================================== // 체크리스트 개별 항목 (라우터) // ========================================