refactor(pop-work-detail): 사이드바→탭 전환, KPI 카드, 풋터 액션바 도입
리서치 반영 UX 구조 대폭 변경: - 좌측 사이드바(220px) → 상단 탭 바(48px)로 전환 (작업 전|작업 중|작업 후|실적) - 탭마다 진행률 표시 (예: "작업 전 2/5") - 고정 헤더: 진한 배경(#263238) + 흰색 텍스트로 작업 정보 표시 - KPI 카드 상시 표시: 접수량/작업완료/잔여/불량 (40px bold, 색상 구분) - 고정 풋터 액션바: 일시정지(황)/불량등록(적)/작업완료(녹) 3버튼 - 작업완료 2단계 확인 (클릭 → "정말 완료?" 확인) - 배경색 #F5F5F5 (공장 조명 눈부심 방지) - 체크리스트 행: 좌측 상태 바 (완료=녹, 필수=적, 기본=회) - ChecklistRowItem 래퍼로 행 전체 터치 영역 + 시각 피드백 - DESIGN 토큰 확장: tab, footer, header, kpi, bg 추가 - COLORS 확장: kpiInput, kpiComplete, kpiRemaining, kpiDefect 추가 - step 모드와 list 모드 모두 탭 구조 안에서 정상 동작
This commit is contained in:
parent
525237d42d
commit
bb6e17ec28
|
|
@ -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<string | null>(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<DefectTypeOption[]>([]);
|
||||
|
||||
|
|
@ -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<string, { done: number; total: number }> = {};
|
||||
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 (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 작업지시 정보 바 */}
|
||||
<div className="flex h-full flex-col" style={{ backgroundColor: DESIGN.bg.page }}>
|
||||
{/* ── 고정 헤더: 작업 정보 ── */}
|
||||
{cfg.infoBar.enabled && (
|
||||
<InfoBar
|
||||
fields={cfg.infoBar.fields.length > 0 ? cfg.infoBar.fields : DEFAULT_INFO_FIELDS}
|
||||
|
|
@ -728,110 +788,77 @@ export function PopWorkDetailComponent({
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* 전체 공정 진행 요약은 제거 - 타이머는 그룹 헤더로 이동 */}
|
||||
{/* ── KPI 카드 (항상 표시) ── */}
|
||||
<KpiCards
|
||||
inputQty={parseInt(processData?.input_qty ?? "0", 10) || 0}
|
||||
completedQty={parseInt(processData?.total_production_qty ?? "0", 10) || 0}
|
||||
defectQty={parseInt(processData?.defect_qty ?? "0", 10) || 0}
|
||||
/>
|
||||
|
||||
{/* 본문: 좌측 사이드바 + 우측 콘텐츠 */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* 좌측 사이드바 */}
|
||||
<div className="shrink-0 overflow-y-auto border-r bg-muted/30" style={{ width: `${DESIGN.sidebar.width}px` }}>
|
||||
{(["PRE", "IN", "POST"] as WorkPhase[]).map((phase) => {
|
||||
const phaseGroups = groupsByPhase[phase];
|
||||
if (!phaseGroups || phaseGroups.length === 0) return null;
|
||||
return (
|
||||
<div key={phase}>
|
||||
<div className="px-4 pb-1 pt-3 text-xs font-semibold uppercase text-muted-foreground">
|
||||
{cfg.phaseLabels[phase] ?? phase}
|
||||
</div>
|
||||
{phaseGroups.map((g) => (
|
||||
<button
|
||||
key={g.itemId}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2.5 text-left transition-colors",
|
||||
selectedGroupId === g.itemId
|
||||
? "bg-primary/10 text-primary border-l-2 border-primary"
|
||||
: "hover:bg-muted/60"
|
||||
)}
|
||||
style={{ padding: DESIGN.sidebar.itemPadding }}
|
||||
onClick={() => {
|
||||
setSelectedGroupId(g.itemId);
|
||||
setResultTabActive(false);
|
||||
contentRef.current?.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}}
|
||||
>
|
||||
<StepStatusIcon status={g.stepStatus} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="block truncate font-medium leading-tight" style={{ fontSize: `${DESIGN.section.titleSize}px` }}>
|
||||
{g.title}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span>{g.completed}/{g.total}</span>
|
||||
{g.timer.startedAt && !g.timer.completedAt && (
|
||||
<span className="text-primary">진행중</span>
|
||||
)}
|
||||
{g.timer.completedAt && (
|
||||
<span className={COLORS.good}>완료</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* ── 탭 바 ── */}
|
||||
<div
|
||||
className="flex shrink-0 border-b"
|
||||
style={{ height: `${DESIGN.tab.height}px`, backgroundColor: DESIGN.bg.card }}
|
||||
>
|
||||
{availablePhases.map((phase) => {
|
||||
const progress = phaseProgress[phase];
|
||||
const isActive = !resultTabActive && activePhaseTab === phase;
|
||||
return (
|
||||
<button
|
||||
key={phase}
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-center gap-1.5 text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "border-b-2 border-primary text-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => handlePhaseTabChange(phase)}
|
||||
>
|
||||
{cfg.phaseLabels[phase] ?? phase}
|
||||
{progress && (
|
||||
<span className={cn("text-xs", isActive ? "text-primary" : "text-muted-foreground")}>
|
||||
{progress.done}/{progress.total}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{hasResultSections && (
|
||||
<button
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-center gap-1.5 text-sm font-medium transition-colors",
|
||||
resultTabActive
|
||||
? "border-b-2 border-primary text-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={handleResultTabClick}
|
||||
>
|
||||
실적
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 실적 입력 탭 (resultSections가 설정된 경우만) */}
|
||||
{cfg.resultSections && cfg.resultSections.some((s) => s.enabled) && (
|
||||
<>
|
||||
<div className="mx-3 my-2 border-t" />
|
||||
<button
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2.5 text-left transition-colors",
|
||||
resultTabActive
|
||||
? "bg-primary/10 text-primary border-l-2 border-primary"
|
||||
: "hover:bg-muted/60"
|
||||
)}
|
||||
style={{ padding: DESIGN.sidebar.itemPadding }}
|
||||
onClick={() => {
|
||||
setResultTabActive(true);
|
||||
setSelectedGroupId(null);
|
||||
contentRef.current?.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}}
|
||||
>
|
||||
<ClipboardList className="h-4 w-4 shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="block truncate font-medium leading-tight" style={{ fontSize: `${DESIGN.section.titleSize}px` }}>
|
||||
실적 입력
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{processData?.result_status === "confirmed" ? "확정됨" : "미확정"}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* ── 콘텐츠 영역 (스크롤) ── */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* 실적 입력 패널 (hidden으로 상태 유지, unmount 방지) */}
|
||||
{hasResultSections && (
|
||||
<div className={cn("flex flex-1 flex-col overflow-hidden", !resultTabActive && "hidden")}>
|
||||
<ResultPanel
|
||||
workOrderProcessId={workOrderProcessId!}
|
||||
processData={processData}
|
||||
sections={cfg.resultSections ?? []}
|
||||
isProcessCompleted={isProcessCompleted}
|
||||
defectTypes={cachedDefectTypes}
|
||||
onSaved={(updated) => {
|
||||
setProcessData((prev) => prev ? { ...prev, ...updated } : prev);
|
||||
publish("process_completed", { workOrderProcessId, status: updated?.status });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 우측 콘텐츠 */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* 실적 입력 패널 (hidden으로 상태 유지, unmount 방지) */}
|
||||
{hasResultSections && (
|
||||
<div className={cn("flex flex-1 flex-col overflow-hidden", !resultTabActive && "hidden")}>
|
||||
<ResultPanel
|
||||
workOrderProcessId={workOrderProcessId!}
|
||||
processData={processData}
|
||||
sections={cfg.resultSections ?? []}
|
||||
isProcessCompleted={isProcessCompleted}
|
||||
defectTypes={cachedDefectTypes}
|
||||
onSaved={(updated) => {
|
||||
setProcessData((prev) => prev ? { ...prev, ...updated } : prev);
|
||||
publish("process_completed", { workOrderProcessId, status: updated?.status });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 체크리스트 영역 */}
|
||||
<div className={cn("flex flex-1 flex-col overflow-hidden", resultTabActive && "hidden")}>
|
||||
{/* 체크리스트 영역 */}
|
||||
<div className={cn("flex flex-1 flex-col overflow-hidden", resultTabActive && "hidden")}>
|
||||
{cfg.displayMode === "step" ? (
|
||||
/* ======== 스텝 모드 ======== */
|
||||
<>
|
||||
|
|
@ -842,7 +869,7 @@ export function PopWorkDetailComponent({
|
|||
<p className="text-base font-medium">모든 작업 항목이 완료되었습니다</p>
|
||||
|
||||
{cfg.showQuantityInput && !isProcessCompleted && !hasResultSections && (
|
||||
<div className="w-full max-w-sm space-y-4 rounded-lg border p-5">
|
||||
<div className="w-full max-w-sm space-y-4 rounded-lg border p-5" style={{ backgroundColor: DESIGN.bg.card }}>
|
||||
<p className="font-semibold" style={{ fontSize: `${DESIGN.section.titleSize}px` }}>실적 수량 등록</p>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
|
|
@ -862,16 +889,6 @@ export function PopWorkDetailComponent({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{!isProcessCompleted && cfg.navigation.showCompleteButton && (
|
||||
<Button
|
||||
className="gap-2 px-8"
|
||||
style={{ height: `${DESIGN.button.height}px`, fontSize: `${DESIGN.section.titleSize}px` }}
|
||||
onClick={handleProcessComplete}
|
||||
>
|
||||
<CheckCircle2 className="h-5 w-5" />공정 완료
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isProcessCompleted && (
|
||||
<Badge variant="outline" className="text-base text-green-600 px-3 py-1">
|
||||
공정이 완료되었습니다
|
||||
|
|
@ -895,7 +912,7 @@ export function PopWorkDetailComponent({
|
|||
groupElapsedFormatted={groupElapsedFormatted}
|
||||
onTimerAction={handleGroupTimerAction}
|
||||
/>
|
||||
<div className="px-4 pb-2">
|
||||
<div className="px-4 pb-2" style={{ backgroundColor: DESIGN.bg.card }}>
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{currentItemIdx + 1} / {currentItems.length}</span>
|
||||
</div>
|
||||
|
|
@ -934,7 +951,7 @@ export function PopWorkDetailComponent({
|
|||
</div>
|
||||
|
||||
{/* 스텝 네비게이션 */}
|
||||
<div className="flex items-center justify-between border-t px-4 py-3">
|
||||
<div className="flex items-center justify-between border-t px-4 py-3" style={{ backgroundColor: DESIGN.bg.card }}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
|
|
@ -947,6 +964,7 @@ export function PopWorkDetailComponent({
|
|||
} else if (selectedIndex > 0) {
|
||||
const prevGroup = groups[selectedIndex - 1];
|
||||
setSelectedGroupId(prevGroup.itemId);
|
||||
setActivePhaseTab(prevGroup.phase);
|
||||
const prevItems = allResults.filter(
|
||||
(r) => r.source_work_item_id === prevGroup.itemId
|
||||
);
|
||||
|
|
@ -970,6 +988,8 @@ export function PopWorkDetailComponent({
|
|||
if (currentItemIdx < currentItems.length - 1) {
|
||||
setCurrentItemIdx(currentItemIdx + 1);
|
||||
} else if (selectedIndex < groups.length - 1) {
|
||||
const nextGroup = groups[selectedIndex + 1];
|
||||
setActivePhaseTab(nextGroup.phase);
|
||||
navigateStep(1);
|
||||
} else {
|
||||
setShowQuantityPanel(true);
|
||||
|
|
@ -987,8 +1007,33 @@ export function PopWorkDetailComponent({
|
|||
)}
|
||||
</>
|
||||
) : (
|
||||
/* ======== 리스트 모드 (기존) ======== */
|
||||
/* ======== 리스트 모드 ======== */
|
||||
<>
|
||||
{/* 탭 내 그룹 목록 표시 (그룹이 여러 개인 경우) */}
|
||||
{currentTabGroups.length > 1 && (
|
||||
<div className="flex gap-2 overflow-x-auto border-b px-4 py-2" style={{ backgroundColor: DESIGN.bg.card }}>
|
||||
{currentTabGroups.map((g) => (
|
||||
<button
|
||||
key={g.itemId}
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-1.5 rounded-full px-3 py-1.5 text-sm transition-colors",
|
||||
selectedGroupId === g.itemId
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted hover:bg-muted/80 text-muted-foreground"
|
||||
)}
|
||||
onClick={() => {
|
||||
setSelectedGroupId(g.itemId);
|
||||
contentRef.current?.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}}
|
||||
>
|
||||
<StepStatusIcon status={g.stepStatus} />
|
||||
<span className="truncate max-w-[120px]">{g.title}</span>
|
||||
<span className="text-xs opacity-70">{g.completed}/{g.total}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 그룹 헤더 + 타이머 */}
|
||||
{selectedGroup && (
|
||||
<GroupTimerHeader
|
||||
|
|
@ -1009,7 +1054,7 @@ export function PopWorkDetailComponent({
|
|||
{selectedGroupId && (
|
||||
<div className="space-y-2.5">
|
||||
{currentItems.map((item) => (
|
||||
<ChecklistItem
|
||||
<ChecklistRowItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
saving={savingIds.has(item.id)}
|
||||
|
|
@ -1020,74 +1065,101 @@ export function PopWorkDetailComponent({
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 하단 네비게이션 + 수량/완료 */}
|
||||
<div className="border-t">
|
||||
{cfg.showQuantityInput && !hasResultSections && (
|
||||
<div className="flex items-center gap-3 px-4 py-2.5">
|
||||
<Package className="h-4.5 w-4.5 shrink-0 text-muted-foreground" />
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground" style={{ fontSize: `${DESIGN.section.titleSize}px` }}>양품</span>
|
||||
<Input type="number" className="w-24" style={{ height: `${DESIGN.input.height}px`, fontSize: `${DESIGN.section.titleSize}px` }} value={goodQty} onChange={(e) => setGoodQty(e.target.value)} disabled={isProcessCompleted} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground" style={{ fontSize: `${DESIGN.section.titleSize}px` }}>불량</span>
|
||||
<Input type="number" className="w-24" style={{ height: `${DESIGN.input.height}px`, fontSize: `${DESIGN.section.titleSize}px` }} value={defectQty} onChange={(e) => setDefectQty(e.target.value)} disabled={isProcessCompleted} />
|
||||
</div>
|
||||
<Button size="sm" variant="outline" className="px-4" style={{ height: `${DESIGN.button.height}px`, fontSize: `${DESIGN.section.titleSize}px` }} onClick={handleQuantityRegister} disabled={isProcessCompleted}>
|
||||
등록
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cfg.navigation.showPrevNext && (
|
||||
<div className="flex items-center justify-between px-4 py-2.5">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-1.5 px-5"
|
||||
style={{ height: `${DESIGN.nav.height}px`, fontSize: `${DESIGN.section.titleSize}px` }}
|
||||
disabled={selectedIndex <= 0}
|
||||
onClick={() => navigateStep(-1)}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />이전
|
||||
</Button>
|
||||
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{selectedIndex + 1} / {groups.length}
|
||||
</span>
|
||||
|
||||
{selectedIndex < groups.length - 1 ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-1.5 px-5"
|
||||
style={{ height: `${DESIGN.nav.height}px`, fontSize: `${DESIGN.section.titleSize}px` }}
|
||||
onClick={() => navigateStep(1)}
|
||||
>
|
||||
다음<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
) : cfg.navigation.showCompleteButton && !isProcessCompleted ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="gap-1.5 px-5"
|
||||
style={{ height: `${DESIGN.nav.height}px`, fontSize: `${DESIGN.section.titleSize}px` }}
|
||||
onClick={handleProcessComplete}
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4" />공정 완료
|
||||
</Button>
|
||||
) : (
|
||||
<div className="w-16" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 고정 풋터 액션바 ── */}
|
||||
{!isProcessCompleted && (
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-3 border-t px-4"
|
||||
style={{ height: `${DESIGN.footer.height}px`, backgroundColor: DESIGN.bg.card }}
|
||||
>
|
||||
{/* 일시정지 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1 gap-2 border-amber-400 text-amber-700 hover:bg-amber-50 font-medium"
|
||||
style={{ height: `${DESIGN.footer.height - 16}px`, fontSize: `${DESIGN.section.titleSize}px` }}
|
||||
onClick={() => {
|
||||
if (isGroupPaused) {
|
||||
handleGroupTimerAction("resume");
|
||||
} else if (isGroupStarted) {
|
||||
handleGroupTimerAction("pause");
|
||||
}
|
||||
}}
|
||||
disabled={!isGroupStarted}
|
||||
>
|
||||
<Pause className="h-5 w-5" />
|
||||
{isGroupPaused ? "재개" : "일시정지"}
|
||||
</Button>
|
||||
|
||||
{/* 불량등록 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1 gap-2 border-red-400 text-red-700 hover:bg-red-50 font-medium"
|
||||
style={{ height: `${DESIGN.footer.height - 16}px`, fontSize: `${DESIGN.section.titleSize}px` }}
|
||||
onClick={() => {
|
||||
if (hasResultSections) {
|
||||
handleResultTabClick();
|
||||
} else {
|
||||
toast.info("불량은 실적 탭에서 등록합니다.");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
불량등록
|
||||
</Button>
|
||||
|
||||
{/* 작업완료 (2단계 확인) */}
|
||||
{!confirmCompleteOpen ? (
|
||||
<Button
|
||||
variant="default"
|
||||
className="flex-1 gap-2 bg-green-600 hover:bg-green-700 font-medium"
|
||||
style={{ height: `${DESIGN.footer.height - 16}px`, fontSize: `${DESIGN.section.titleSize}px` }}
|
||||
onClick={() => setConfirmCompleteOpen(true)}
|
||||
>
|
||||
<CheckCircle2 className="h-5 w-5" />
|
||||
작업완료
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex flex-1 gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1 font-medium"
|
||||
style={{ height: `${DESIGN.footer.height - 16}px`, fontSize: `${DESIGN.section.titleSize}px` }}
|
||||
onClick={() => setConfirmCompleteOpen(false)}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
className="flex-1 gap-2 bg-green-600 hover:bg-green-700 font-medium"
|
||||
style={{ height: `${DESIGN.footer.height - 16}px`, fontSize: `${DESIGN.section.titleSize}px` }}
|
||||
onClick={() => {
|
||||
setConfirmCompleteOpen(false);
|
||||
handleProcessComplete();
|
||||
}}
|
||||
>
|
||||
<CheckCircle2 className="h-5 w-5" />
|
||||
정말 완료
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isProcessCompleted && (
|
||||
<div
|
||||
className="flex shrink-0 items-center justify-center border-t"
|
||||
style={{ height: `${DESIGN.footer.height}px`, backgroundColor: DESIGN.bg.card }}
|
||||
>
|
||||
<Badge variant="outline" className="text-base text-green-600 px-4 py-1.5">
|
||||
<CheckCircle2 className="mr-2 h-5 w-5" />
|
||||
공정이 완료되었습니다
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1630,15 +1702,24 @@ interface InfoBarProps {
|
|||
|
||||
function InfoBar({ fields, parentRow, processName }: InfoBarProps) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-5 gap-y-1.5 border-b bg-muted/30 px-4 py-2.5">
|
||||
<div
|
||||
className="flex shrink-0 flex-wrap items-center gap-x-5 gap-y-1.5 px-4"
|
||||
style={{
|
||||
minHeight: `${DESIGN.header.height}px`,
|
||||
backgroundColor: DESIGN.bg.header,
|
||||
color: '#FFFFFF',
|
||||
paddingTop: 8,
|
||||
paddingBottom: 8,
|
||||
}}
|
||||
>
|
||||
{fields.map((f) => {
|
||||
const val = f.column === "__process_name"
|
||||
? processName
|
||||
: parentRow[f.column];
|
||||
return (
|
||||
<div key={f.column} className="flex items-center gap-1.5">
|
||||
<span className="text-muted-foreground" style={{ fontSize: `${DESIGN.infoBar.labelSize}px` }}>{f.label}</span>
|
||||
<span className="font-medium" style={{ fontSize: `${DESIGN.infoBar.valueSize}px` }}>{val != null ? String(val) : "-"}</span>
|
||||
<span className="text-white/60" style={{ fontSize: `${DESIGN.infoBar.labelSize}px` }}>{f.label}</span>
|
||||
<span className="font-medium text-white" style={{ fontSize: `${DESIGN.infoBar.valueSize}px` }}>{val != null ? String(val) : "-"}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
@ -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 (
|
||||
<div className="flex shrink-0 gap-3 px-4 py-3" style={{ backgroundColor: DESIGN.bg.card }}>
|
||||
{cards.map((c) => (
|
||||
<div
|
||||
key={c.label}
|
||||
className="flex flex-1 flex-col items-center rounded-lg border py-2 shadow-sm"
|
||||
style={{ backgroundColor: DESIGN.bg.card }}
|
||||
>
|
||||
<span
|
||||
className={cn("font-bold tabular-nums", c.color)}
|
||||
style={{ fontSize: `${DESIGN.kpi.valueSize}px`, fontWeight: DESIGN.kpi.weight }}
|
||||
>
|
||||
{c.value}
|
||||
</span>
|
||||
<span className="text-muted-foreground" style={{ fontSize: `${DESIGN.kpi.labelSize}px` }}>
|
||||
{c.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 그룹별 타이머 헤더
|
||||
// ========================================
|
||||
|
|
@ -1674,7 +1797,7 @@ function GroupTimerHeader({
|
|||
onTimerAction,
|
||||
}: GroupTimerHeaderProps) {
|
||||
return (
|
||||
<div className="border-b">
|
||||
<div className="border-b" style={{ backgroundColor: DESIGN.bg.card }}>
|
||||
{/* 그룹 제목 + 진행 카운트 */}
|
||||
<div className="flex items-center justify-between px-4 pt-3 pb-1.5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
|
|
@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-lg shadow-sm transition-all",
|
||||
isCompleted && "ring-1 ring-green-200",
|
||||
)}
|
||||
style={{ backgroundColor: DESIGN.bg.card }}
|
||||
>
|
||||
{/* 좌측 상태 바 */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute left-0 top-0 bottom-0 w-1 rounded-l-lg",
|
||||
isCompleted ? "bg-green-500" : isRequired ? "bg-red-400" : "bg-gray-200"
|
||||
)}
|
||||
/>
|
||||
{/* 필수 표시 */}
|
||||
{isRequired && !isCompleted && (
|
||||
<div className="absolute top-2 right-2">
|
||||
<span className="text-red-500 text-xs font-bold">*</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="pl-3">
|
||||
<ChecklistItem item={item} saving={saving} disabled={disabled} onSave={onSave} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 체크리스트 개별 항목 (라우터)
|
||||
// ========================================
|
||||
|
|
|
|||
Loading…
Reference in New Issue