pop-work-detail: 디자인 v2 전면 개편

- 글로시/입체감 버튼 스타일 (GlossyButton 컴포넌트 추가)
- 체크리스트 좌정보/우입력 분할 레이아웃 (여백 최소화)
- 타이머 sticky 고정 + 시작/일시정지/재개 전환 토글
- 풋터 3버튼 제거 → 각 그룹 하단에 작업완료 버튼 배치
- 필수 항목 미체크 시 다음 공정 탭 전환 차단
- 전체 글자 크기 확대 (버튼 18px+, 항목명 15px, 타이머 26px)
- 배경 흰색 유지
This commit is contained in:
SeongHyun Kim 2026-03-26 17:25:57 +09:00
parent 1128a4c278
commit 3249611cfc
1 changed files with 658 additions and 157 deletions

View File

@ -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, AlertTriangle, Zap, RefreshCw,
Plus, Trash2, Save, FileCheck, Construction, Zap, RefreshCw,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@ -139,21 +139,118 @@ const DEFAULT_CFG: PopWorkDetailConfig = {
// ========================================
const DESIGN = {
button: { height: 56, minWidth: 100 },
button: { height: 60, minWidth: 130 },
input: { height: 56 },
stat: { valueSize: 40, labelSize: 14, weight: 700 },
section: { titleSize: 13, gap: 20 },
tab: { height: 48 },
stat: { valueSize: 40, labelSize: 16, weight: 700 },
section: { titleSize: 16, gap: 16 },
tab: { height: 52 },
footer: { height: 64 },
header: { height: 48 },
kpi: { valueSize: 44, labelSize: 13, weight: 800 },
header: { height: 52 },
kpi: { valueSize: 44, labelSize: 15, weight: 800 },
nav: { height: 56 },
infoBar: { labelSize: 12, valueSize: 14 },
defectRow: { height: 44 },
sidebar: { width: 208 },
infoBar: { labelSize: 13, valueSize: 16 },
defectRow: { height: 48 },
sidebar: { width: 220 },
bg: { page: '#F5F5F5', card: '#FFFFFF', header: '#1a1a2e', infoBar: '#1a1a2e' },
} as const;
// ========================================
// Glossy 버튼 스타일 (v3-rev2 참고)
// ========================================
const glossyBase: React.CSSProperties = {
position: 'relative',
borderRadius: 16,
fontWeight: 800,
cursor: 'pointer',
textShadow: '0 1px 3px rgba(0,0,0,0.3)',
transition: 'all 0.15s ease',
border: 'none',
color: 'white',
};
const glossyOverlay: React.CSSProperties = {
content: "''",
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '50%',
borderRadius: '16px 16px 0 0',
background: 'linear-gradient(to bottom, rgba(255,255,255,0.4), rgba(255,255,255,0.05))',
pointerEvents: 'none',
};
const GLOSSY_VARIANTS = {
green: {
background: 'linear-gradient(to bottom, #4ade80, #16a34a)',
boxShadow: '0 4px 12px rgba(34,197,94,0.3), inset 0 1px 0 rgba(255,255,255,0.4)',
},
blue: {
background: 'linear-gradient(to bottom, #60a5fa, #2563eb)',
boxShadow: '0 4px 12px rgba(59,130,246,0.3), inset 0 1px 0 rgba(255,255,255,0.4)',
},
red: {
background: 'linear-gradient(to bottom, #f87171, #dc2626)',
boxShadow: '0 4px 12px rgba(239,68,68,0.3), inset 0 1px 0 rgba(255,255,255,0.4)',
},
teal: {
background: 'linear-gradient(to bottom, #2dd4bf, #0d9488)',
boxShadow: '0 4px 12px rgba(20,184,166,0.3), inset 0 1px 0 rgba(255,255,255,0.4)',
},
purple: {
background: 'linear-gradient(to bottom, #a78bfa, #7c3aed)',
boxShadow: '0 4px 12px rgba(139,92,246,0.3), inset 0 1px 0 rgba(255,255,255,0.4)',
},
yellow: {
background: 'linear-gradient(to bottom, #fbbf24, #d97706)',
boxShadow: '0 4px 12px rgba(245,158,11,0.3), inset 0 1px 0 rgba(255,255,255,0.4)',
},
gray: {
background: 'linear-gradient(to bottom, #9ca3af, #6b7280)',
boxShadow: '0 4px 12px rgba(107,114,128,0.3), inset 0 1px 0 rgba(255,255,255,0.4)',
},
} as const;
type GlossyVariant = keyof typeof GLOSSY_VARIANTS;
function GlossyButton({
variant,
children,
onClick,
disabled,
className,
style,
}: {
variant: GlossyVariant;
children: React.ReactNode;
onClick?: () => void;
disabled?: boolean;
className?: string;
style?: React.CSSProperties;
}) {
const variantStyle = GLOSSY_VARIANTS[variant];
return (
<button
className={cn("flex items-center justify-center gap-2", className)}
style={{
...glossyBase,
...variantStyle,
minHeight: DESIGN.button.height,
minWidth: DESIGN.button.minWidth,
fontSize: 18,
...style,
...(disabled ? { opacity: 0.4, cursor: 'not-allowed' } : {}),
}}
onClick={disabled ? undefined : onClick}
disabled={disabled}
>
<span style={glossyOverlay} />
{children}
</button>
);
}
const COLORS = {
good: 'text-green-600',
defect: 'text-red-600',
@ -235,9 +332,6 @@ export function PopWorkDetailComponent({
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[]>([]);
@ -544,14 +638,18 @@ export function PopWorkDetailComponent({
(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 allRequiredDone = requiredItems.length === 0 || requiredItems.every((r) => r.status === "completed");
const allDone = groupItems.every((r) => r.status === "completed");
if (allRequiredDone || allDone) {
// 필수 항목 미체크 시 다음 공정으로 안 넘어감
if (!allRequiredDone) return;
if (allDone) {
const idx = groups.findIndex((g) => g.itemId === selectedGroupId);
if (idx >= 0 && idx < groups.length - 1) {
const nextGroup = groups[idx + 1];
setSelectedGroupId(nextGroup.itemId);
setActivePhaseTab(nextGroup.phase);
contentRef.current?.scrollTo({ top: 0, behavior: "smooth" });
toast.success(`${groups[idx].title} 완료 → ${nextGroup.title}`);
}
@ -783,17 +881,17 @@ export function PopWorkDetailComponent({
return (
<div className="flex h-full flex-col rounded-2xl overflow-hidden" style={{ backgroundColor: DESIGN.bg.card }}>
{/* ── 모달 헤더: 미니멀 ── */}
{/* ── 모달 헤더: 글자 크게 ── */}
<div
className="flex shrink-0 items-center justify-between border-b border-gray-100 px-6"
style={{ height: `${DESIGN.header.height}px` }}
>
<div className="flex items-center gap-4">
<h2 className="text-lg font-bold tracking-tight text-gray-900"> </h2>
{woNo && <span className="font-mono text-xs text-gray-400">{woNo}</span>}
<h2 className="font-bold tracking-tight text-gray-900" style={{ fontSize: 20 }}> </h2>
{woNo && <span className="font-mono text-gray-400" style={{ fontSize: 14 }}>{woNo}</span>}
</div>
<button
className="flex h-9 w-9 items-center justify-center rounded-lg text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600"
className="flex h-10 w-10 items-center justify-center rounded-xl text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600"
onClick={() => publish("close_modal", {})}
>
<X className="h-5 w-5" />
@ -849,17 +947,19 @@ export function PopWorkDetailComponent({
</div>
<span
className={cn(
"text-xs font-semibold uppercase tracking-wider",
"font-semibold uppercase tracking-wider",
allDone || anyActive ? "text-gray-900" : "text-gray-400"
)}
style={{ fontSize: 13 }}
>
{cfg.phaseLabels[phase] ?? phase}
</span>
<span
className={cn(
"ml-auto text-xs font-medium",
"ml-auto font-medium",
allDone ? "text-green-600" : anyActive ? "text-blue-600" : "text-gray-400"
)}
style={{ fontSize: 13 }}
>
{progress?.done ?? 0}/{progress?.total ?? 0}
</span>
@ -888,11 +988,12 @@ export function PopWorkDetailComponent({
<SidebarStepIcon status={g.stepStatus} isSelected={isSelected} />
<span
className={cn(
"truncate text-sm",
"truncate",
g.stepStatus === "completed" ? "text-gray-500" :
isSelected ? "font-medium text-blue-700" :
g.stepStatus === "active" ? "text-gray-700" : "text-gray-400"
)}
style={{ fontSize: 14 }}
>
{g.title}
</span>
@ -1109,7 +1210,7 @@ export function PopWorkDetailComponent({
defectQty={parseInt(processData?.defect_qty ?? "0", 10) || 0}
/>
{/* 그룹 헤더 + 타이머 */}
{/* 그룹 헤더 + 타이머 (sticky) */}
{selectedGroup && (
<GroupTimerHeader
group={selectedGroup}
@ -1125,9 +1226,9 @@ export function PopWorkDetailComponent({
)}
{/* 체크리스트 콘텐츠 */}
<div className="space-y-8 px-8 py-6">
<div className="px-4 py-3" style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{selectedGroupId && (
<div className="space-y-2.5">
<>
{currentItems.map((item) => (
<ChecklistRowItem
key={item.id}
@ -1137,7 +1238,36 @@ export function PopWorkDetailComponent({
onSave={saveResultValue}
/>
))}
</div>
{/* 각 그룹 하단: 작업완료 버튼 */}
{!isProcessCompleted && selectedGroup && !isGroupCompleted && (
<div className="mt-3 mb-2">
<GroupCompleteButton
group={selectedGroup}
currentItems={currentItems}
isGroupStarted={isGroupStarted}
onComplete={() => handleGroupTimerAction("complete")}
onNavigateNext={() => {
// 필수 미완료 시 넘어가지 않음
const requiredItems = currentItems.filter((r) => r.is_required === "Y");
const allRequiredDone = requiredItems.length === 0 || requiredItems.every((r) => r.status === "completed");
if (!allRequiredDone) {
toast.error("필수 항목을 모두 완료해주세요.");
return;
}
// 다음 그룹으로 이동
const idx = groups.findIndex((g) => g.itemId === selectedGroupId);
if (idx >= 0 && idx < groups.length - 1) {
const nextGroup = groups[idx + 1];
setSelectedGroupId(nextGroup.itemId);
setActivePhaseTab(nextGroup.phase);
contentRef.current?.scrollTo({ top: 0, behavior: "smooth" });
}
}}
/>
</div>
)}
</>
)}
</div>
</div>
@ -1146,86 +1276,7 @@ export function PopWorkDetailComponent({
</div>
</div>
{/* ── 고정 풋터 액션바 ── */}
{!isProcessCompleted && (
<div
className="flex shrink-0 items-center gap-3 border-t border-gray-100 bg-white px-6"
style={{ height: `${DESIGN.footer.height}px` }}
>
{/* 일시정지 */}
<button
className={cn(
"flex flex-1 items-center justify-center gap-2 rounded-xl border-2 text-sm font-semibold transition-colors",
isGroupPaused
? "border-blue-400 text-blue-700 hover:bg-blue-50"
: "border-amber-400 text-amber-700 hover:bg-amber-50",
!isGroupStarted && "opacity-50 cursor-not-allowed"
)}
style={{ height: 48 }}
disabled={!isGroupStarted}
onClick={() => {
if (isGroupPaused) {
handleGroupTimerAction("resume");
} else if (isGroupStarted) {
handleGroupTimerAction("pause");
}
}}
>
<Pause className="h-5 w-5" fill="currentColor" />
{isGroupPaused ? "재개" : "일시정지"}
</button>
{/* 불량등록 */}
<button
className="flex flex-1 items-center justify-center gap-2 rounded-xl border-2 border-red-300 text-sm font-semibold text-red-600 transition-colors hover:bg-red-50"
style={{ height: 48 }}
onClick={() => {
if (hasResultSections) {
handleResultTabClick();
} else {
toast.info("불량은 실적 탭에서 등록합니다.");
}
}}
>
<AlertTriangle className="h-5 w-5" />
</button>
{/* 작업완료 (2단계 확인) */}
{!confirmCompleteOpen ? (
<button
className="flex flex-1 items-center justify-center gap-2 rounded-xl bg-green-600 text-sm font-semibold text-white transition-colors hover:bg-green-700"
style={{ height: 48 }}
onClick={() => setConfirmCompleteOpen(true)}
>
<Check className="h-5 w-5" />
</button>
) : (
<div className="flex flex-1 gap-2">
<button
className="flex flex-1 items-center justify-center rounded-xl border-2 border-gray-300 text-sm font-semibold text-gray-700 transition-colors hover:bg-gray-50"
style={{ height: 48 }}
onClick={() => setConfirmCompleteOpen(false)}
>
</button>
<button
className="flex flex-1 items-center justify-center gap-2 rounded-xl bg-green-600 text-sm font-semibold text-white transition-colors hover:bg-green-700"
style={{ height: 48 }}
onClick={() => {
setConfirmCompleteOpen(false);
handleProcessComplete();
}}
>
<Check className="h-5 w-5" />
</button>
</div>
)}
</div>
)}
{/* 풋터: 공정 완료 상태 표시만 (3버튼 제거 → 각 그룹 하단에 작업완료 배치) */}
{isProcessCompleted && (
<div
className="flex shrink-0 items-center justify-center border-t border-gray-100 bg-white"
@ -2068,8 +2119,8 @@ function InfoBar({ fields, parentRow, processName }: InfoBarProps) {
: parentRow[f.column];
return (
<div key={f.column} className="flex items-center gap-2">
<span className="text-xs" style={{ color: 'rgba(255,255,255,0.4)' }}>{f.label}</span>
<span className="text-sm font-medium text-white">{val != null ? String(val) : "-"}</span>
<span style={{ color: 'rgba(255,255,255,0.4)', 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>
);
})}
@ -2155,60 +2206,49 @@ function GroupTimerHeader({
onTimerAction,
}: GroupTimerHeaderProps) {
return (
<div className="border-b border-gray-100 bg-white">
<div className="border-b border-gray-100 bg-white" style={{ position: 'sticky', top: 0, zIndex: 10 }}>
{/* 그룹 제목 + 진행 카운트 */}
<div className="flex items-center justify-between px-8 pb-1.5 pt-4">
<div className="flex items-center justify-between px-6 pb-1.5 pt-3">
<div className="flex items-center gap-2.5">
<span className="text-sm font-semibold text-gray-900 uppercase tracking-wide">{group.title}</span>
<span className="rounded bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600">
<span className="font-bold text-gray-900 uppercase tracking-wide" style={{ fontSize: 18 }}>{group.title}</span>
<span className="rounded bg-gray-100 px-2.5 py-1 font-semibold text-gray-600" style={{ fontSize: 15 }}>
{group.completed}/{group.total}
</span>
</div>
{isGroupCompleted && (
<span className="rounded bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700"></span>
<span className="rounded bg-green-100 px-3 py-1 text-sm font-semibold text-green-700"></span>
)}
</div>
{/* 그룹 타이머 */}
{/* 그룹 타이머 - 고정 + 큰 글자 + 시작/일시정지/재개 전환 */}
{cfg.showTimer && (
<div className="flex items-center justify-between bg-gray-50/50 px-8 py-2.5">
<div className="flex items-center gap-4">
<div className="flex items-center justify-between px-6 py-2.5" style={{ backgroundColor: '#f0f2f5' }}>
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<Timer className="h-4 w-4 text-gray-400" />
<span className="font-mono text-sm font-medium tabular-nums text-gray-900">{groupTimerFormatted}</span>
<span className="text-xs text-gray-400"></span>
<Timer className="h-5 w-5 text-blue-500" />
<span className="font-mono font-bold tabular-nums text-blue-700" style={{ fontSize: 26 }}>{groupTimerFormatted}</span>
</div>
<div className="flex items-center gap-2">
<span className="font-mono text-xs tabular-nums text-gray-400">{groupElapsedFormatted}</span>
<span className="text-xs text-gray-400"></span>
<div className="flex items-center gap-1.5">
<span className="font-mono tabular-nums text-gray-400" style={{ fontSize: 14 }}> {groupElapsedFormatted}</span>
</div>
</div>
{!isProcessCompleted && !isGroupCompleted && (
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-2">
{/* 시작 → 일시정지 → 재개 전환 토글 */}
{!isGroupStarted && (
<Button size="sm" variant="outline" className="gap-1.5 px-4 text-sm" onClick={() => onTimerAction("start")}>
<Play className="h-4 w-4" />
</Button>
<GlossyButton variant="teal" onClick={() => onTimerAction("start")} style={{ minHeight: 48, minWidth: 120, fontSize: 16 }}>
<Play className="h-5 w-5" />
</GlossyButton>
)}
{isGroupStarted && !isGroupPaused && (
<>
<Button size="sm" variant="outline" className="gap-1.5 px-3 text-sm" onClick={() => onTimerAction("pause")}>
<Pause className="h-4 w-4" />
</Button>
<Button size="sm" variant="default" className="gap-1.5 px-3 text-sm" onClick={() => onTimerAction("complete")}>
<CheckCircle2 className="h-4 w-4" />
</Button>
</>
<GlossyButton variant="yellow" onClick={() => onTimerAction("pause")} style={{ minHeight: 48, minWidth: 120, fontSize: 16 }}>
<Pause className="h-5 w-5" />
</GlossyButton>
)}
{isGroupStarted && isGroupPaused && (
<>
<Button size="sm" variant="outline" className="gap-1.5 px-3 text-sm" onClick={() => onTimerAction("resume")}>
<Play className="h-4 w-4" />
</Button>
<Button size="sm" variant="default" className="gap-1.5 px-3 text-sm" onClick={() => onTimerAction("complete")}>
<CheckCircle2 className="h-4 w-4" />
</Button>
</>
<GlossyButton variant="teal" onClick={() => onTimerAction("resume")} style={{ minHeight: 48, minWidth: 120, fontSize: 16 }}>
<Play className="h-5 w-5" />
</GlossyButton>
)}
</div>
)}
@ -2269,34 +2309,167 @@ function ChecklistRowItem({ item, saving, disabled, onSave }: {
const isCompleted = item.status === "completed";
const isRequired = item.is_required === "Y";
// 좌정보/우입력 분할 레이아웃 (여백 최소화)
const leftBg = isCompleted
? '#d4edda'
: isRequired
? '#fde8e8'
: '#f0f2f5';
const leftBorderColor = isCompleted
? '#b8d8be'
: isRequired
? '#f5c6c6'
: '#dde0e5';
// 범위/기준값 표시 텍스트
const rangeText = buildRangeText(item);
return (
<div
className={cn(
"relative overflow-hidden rounded-lg shadow-sm transition-all",
"overflow-hidden rounded-xl 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 className="flex items-center" style={{ minHeight: 68 }}>
{/* 왼쪽: 정보 (항목명 + 기준값/범위) */}
<div
className="flex shrink-0 flex-col justify-center px-4 py-2"
style={{
width: 200,
background: leftBg,
borderRight: `2px solid ${leftBorderColor}`,
minHeight: 68,
}}
>
<div className="flex items-center gap-1">
<span
className={cn("font-semibold", isCompleted ? "text-green-800" : "text-gray-900")}
style={{ fontSize: 15 }}
>
{item.detail_label || item.detail_content}
</span>
{isRequired && !isCompleted && <span className="text-red-500 font-bold" style={{ fontSize: 15 }}>*</span>}
</div>
{rangeText && (
<div className={cn("mt-0.5", isCompleted ? "text-green-600" : "text-gray-500")} style={{ fontSize: 13 }}>
{rangeText}
</div>
)}
</div>
{/* 오른쪽: 입력 (측정값, 체크박스, OK/NG) */}
<div className="flex flex-1 items-center justify-center gap-4 px-4 py-2">
<ChecklistItemInput item={item} saving={saving} disabled={disabled} onSave={onSave} />
</div>
)}
<div className="pl-3">
<ChecklistItem item={item} saving={saving} disabled={disabled} onSave={onSave} />
</div>
</div>
);
}
/** 기준값/범위 텍스트 생성 */
function buildRangeText(item: WorkResultRow): string {
const parts: string[] = [];
const lower = item.lower_limit;
const upper = item.upper_limit;
const unit = item.unit || '';
if (lower && upper) {
parts.push(`${lower}~${upper}${unit ? ' ' + unit : ''}`);
} else if (item.spec_value) {
parts.push(`기준: ${item.spec_value}`);
}
if (item.is_required === "Y") parts.push("필수");
const dt = item.detail_type ?? "";
if (dt === "check") parts.push("체크");
else if (dt.startsWith("inspect")) {
const inputType = item.input_type ?? "";
if (inputType === "ox") parts.push("O/X 판정");
else if (inputType === "select") parts.push("선택형");
else if (inputType === "text") parts.push("텍스트");
}
return parts.join(" | ");
}
// ========================================
// 그룹 하단 작업완료 버튼
// ========================================
function GroupCompleteButton({
group,
currentItems,
isGroupStarted,
onComplete,
onNavigateNext,
}: {
group: WorkGroup;
currentItems: WorkResultRow[];
isGroupStarted: boolean;
onComplete: () => void;
onNavigateNext: () => void;
}) {
const [confirmOpen, setConfirmOpen] = useState(false);
const requiredItems = currentItems.filter((r) => r.is_required === "Y");
const allRequiredDone = requiredItems.length === 0 || requiredItems.every((r) => r.status === "completed");
const allDone = currentItems.every((r) => r.status === "completed");
const isDisabled = !isGroupStarted || (!allRequiredDone);
if (confirmOpen) {
return (
<div className="flex items-center gap-3">
<GlossyButton
variant="gray"
onClick={() => setConfirmOpen(false)}
style={{ flex: 1, minHeight: 64, fontSize: 18, borderRadius: 16 }}
>
</GlossyButton>
<GlossyButton
variant="green"
onClick={() => {
setConfirmOpen(false);
onComplete();
}}
style={{ flex: 2, minHeight: 64, fontSize: 20, borderRadius: 16 }}
>
<Check className="h-6 w-6" />
</GlossyButton>
</div>
);
}
return (
<GlossyButton
variant={allDone ? "green" : "purple"}
disabled={isDisabled}
onClick={() => {
if (allDone) {
setConfirmOpen(true);
} else {
// 필수만 완료된 경우 → 다음 공정으로
onNavigateNext();
}
}}
style={{ width: '100%', minHeight: 64, fontSize: 20, borderRadius: 16 }}
>
<svg className="h-6 w-6" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
{isDisabled
? `작업완료 (필수 항목 미완료)`
: allDone
? `작업완료`
: `다음 단계로`
}
</GlossyButton>
);
}
// ========================================
// 체크리스트 개별 항목 (라우터)
// ========================================
@ -2349,6 +2522,334 @@ function ChecklistItem({ item, saving, disabled, onSave }: ChecklistItemProps) {
}
}
// ========================================
// 체크리스트 입력부 (우측 영역 전용 - 분할 레이아웃)
// ========================================
function ChecklistItemInput({ item, saving, disabled, onSave }: ChecklistItemProps) {
const isDisabled = disabled || saving;
const dt = item.detail_type ?? "";
if (dt.startsWith("inspect")) {
const normalized = { ...item, detail_type: "inspect" } as WorkResultRow;
if (!normalized.input_type && dt.includes("_")) {
const suffix = dt.split("_").slice(1).join("_");
const typeMap: Record<string, string> = { numeric: "numeric_range", ox: "ox", text: "text", select: "select" };
normalized.input_type = typeMap[suffix] ?? suffix;
}
return <InspectInputRouter item={normalized} disabled={isDisabled} saving={saving} onSave={onSave} />;
}
switch (dt) {
case "check":
return <CheckInputOnly item={item} disabled={isDisabled} saving={saving} onSave={onSave} />;
case "input":
return <InputOnlyItem item={item} disabled={isDisabled} saving={saving} onSave={onSave} />;
case "procedure":
return <ProcedureInputOnly item={item} disabled={isDisabled} saving={saving} onSave={onSave} />;
case "material":
return <MaterialInputOnly item={item} disabled={isDisabled} saving={saving} onSave={onSave} />;
case "result":
return <ResultInputOnly item={item} disabled={isDisabled} saving={saving} onSave={onSave} />;
case "info":
return <span className="text-gray-500" style={{ fontSize: 15 }}>{item.detail_label || item.detail_content}</span>;
default:
return <span className="text-gray-400" style={{ fontSize: 14 }}> </span>;
}
}
// === 입력 전용 inspect 라우터 ===
function InspectInputRouter(props: { item: WorkResultRow; disabled: boolean; saving: boolean; onSave: ChecklistItemProps["onSave"] }) {
const inputType = props.item.input_type ?? "numeric_range";
switch (inputType) {
case "ox":
return <InspectOXInput {...props} />;
case "select":
return <InspectSelectInput {...props} />;
case "text":
return <InspectTextInput {...props} />;
default:
return <InspectNumericInput {...props} />;
}
}
// === 수치 입력 (우측 전용) ===
function InspectNumericInput({ item, disabled, saving, onSave }: { item: WorkResultRow; disabled: boolean; saving: boolean; onSave: ChecklistItemProps["onSave"] }) {
const [inputVal, setInputVal] = useState(item.result_value ?? "");
const lower = parseFloat(item.lower_limit ?? "");
const upper = parseFloat(item.upper_limit ?? "");
const hasRange = !isNaN(lower) && !isNaN(upper);
const handleSave = () => {
if (!inputVal || disabled) return;
const numVal = parseFloat(inputVal);
let passed: string | null = null;
if (hasRange) passed = numVal >= lower && numVal <= upper ? "Y" : "N";
onSave(item.id, inputVal, passed, "completed");
};
return (
<>
<input
type="number"
className="rounded-xl border-2 border-gray-200 text-center font-bold transition-colors focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-100"
style={{ width: 140, height: 52, fontSize: 24, color: item.is_passed === "Y" ? '#16a34a' : item.is_passed === "N" ? '#dc2626' : '#1f2937' }}
value={inputVal}
onChange={(e) => setInputVal(e.target.value)}
onBlur={handleSave}
disabled={disabled}
placeholder="입력"
/>
{item.unit && <span className="text-gray-400" style={{ fontSize: 16 }}>{item.unit}</span>}
{item.is_passed === "Y" && !saving && <span className="rounded-lg bg-green-100 px-3 py-1.5 font-bold text-green-700" style={{ fontSize: 15 }}>PASS</span>}
{item.is_passed === "N" && !saving && <span className="rounded-lg bg-red-100 px-3 py-1.5 font-bold text-red-700" style={{ fontSize: 15 }}>FAIL</span>}
{item.status === "completed" && item.is_passed === null && !saving && <span className="font-bold text-green-600" style={{ fontSize: 15 }}></span>}
{!item.status || (item.status !== "completed" && !saving) ? (
<GlossyButton variant="blue" onClick={handleSave} disabled={disabled || !inputVal} style={{ minHeight: 48, minWidth: 80, fontSize: 16 }}>
</GlossyButton>
) : null}
{saving && <Loader2 className="h-5 w-5 animate-spin text-gray-400" />}
</>
);
}
// === O/X (우측 전용) ===
function InspectOXInput({ item, disabled, saving, onSave }: { item: WorkResultRow; disabled: boolean; saving: boolean; onSave: ChecklistItemProps["onSave"] }) {
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 (
<>
<GlossyButton
variant="green"
onClick={() => handleSelect("OK")}
disabled={disabled}
style={{
minHeight: 56, minWidth: 130, fontSize: 20,
...(item.result_value === "OK" ? { boxShadow: '0 0 0 3px #22c55e, 0 4px 12px rgba(34,197,94,0.3), inset 0 1px 0 rgba(255,255,255,0.4)' } : {}),
}}
>
O
</GlossyButton>
<GlossyButton
variant="red"
onClick={() => handleSelect("NG")}
disabled={disabled}
style={{
minHeight: 56, minWidth: 130, fontSize: 20,
...(item.result_value === "NG" ? { boxShadow: '0 0 0 3px #ef4444, 0 4px 12px rgba(239,68,68,0.3), inset 0 1px 0 rgba(255,255,255,0.4)' } : {}),
}}
>
X
</GlossyButton>
{saving && <Loader2 className="h-5 w-5 animate-spin text-gray-400" />}
</>
);
}
// === 선택형 (우측 전용) ===
function InspectSelectInput({ 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 = Array.isArray(parsed.options) ? parsed.options : [];
passValues = Array.isArray(parsed.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 (
<>
{options.map((opt) => (
<GlossyButton
key={opt}
variant={currentValue === opt ? "blue" : "gray"}
onClick={() => handleSelect(opt)}
disabled={disabled}
style={{ minHeight: 52, minWidth: 100, fontSize: 16 }}
>
{opt}
</GlossyButton>
))}
{saving && <Loader2 className="h-5 w-5 animate-spin text-gray-400" />}
</>
);
}
// === 텍스트 + 수동판정 (우측 전용) ===
function InspectTextInput({ item, disabled, saving, onSave }: { item: WorkResultRow; disabled: boolean; saving: boolean; onSave: ChecklistItemProps["onSave"] }) {
const [inputVal, setInputVal] = useState(item.result_value ?? "");
const [judged, setJudged] = useState<string | null>(item.is_passed);
const handleJudge = (passed: string) => {
if (disabled) return;
setJudged(passed);
onSave(item.id, inputVal || "-", passed, "completed");
};
return (
<>
<input
className="flex-1 rounded-xl border-2 border-gray-200 px-3 transition-colors focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-100"
style={{ height: 48, fontSize: 16 }}
value={inputVal}
onChange={(e) => setInputVal(e.target.value)}
disabled={disabled}
placeholder="내용 입력"
/>
<GlossyButton
variant={judged === "Y" ? "green" : "gray"}
onClick={() => handleJudge("Y")}
disabled={disabled}
style={{ minHeight: 48, minWidth: 80, fontSize: 15 }}
>
</GlossyButton>
<GlossyButton
variant={judged === "N" ? "red" : "gray"}
onClick={() => handleJudge("N")}
disabled={disabled}
style={{ minHeight: 48, minWidth: 80, fontSize: 15 }}
>
</GlossyButton>
{saving && <Loader2 className="h-5 w-5 animate-spin text-gray-400" />}
</>
);
}
// === 체크박스 (우측 전용) ===
function CheckInputOnly({ item, disabled, saving, onSave }: { item: WorkResultRow; disabled: boolean; saving: boolean; onSave: ChecklistItemProps["onSave"] }) {
const checked = item.result_value === "Y";
return (
<>
{item.status === "completed" && <span className="font-bold text-green-600" style={{ fontSize: 16 }}></span>}
<Checkbox
checked={checked}
disabled={disabled}
className="h-8 w-8"
onCheckedChange={(v) => {
const val = v ? "Y" : "N";
onSave(item.id, val, v ? "Y" : "N", v ? "completed" : "pending");
}}
/>
{saving && <Loader2 className="h-5 w-5 animate-spin text-gray-400" />}
</>
);
}
// === 자유입력 (우측 전용) ===
function InputOnlyItem({ item, disabled, saving, onSave }: { item: WorkResultRow; disabled: boolean; saving: boolean; onSave: ChecklistItemProps["onSave"] }) {
const [inputVal, setInputVal] = useState(item.result_value ?? "");
const inputType = item.input_type === "number" ? "number" : "text";
const handleBlur = () => {
if (!inputVal || disabled) return;
onSave(item.id, inputVal, null, "completed");
};
return (
<>
<input
type={inputType}
className="flex-1 rounded-xl border-2 border-gray-200 px-3 text-center font-semibold transition-colors focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-100"
style={{ height: 48, fontSize: 18 }}
value={inputVal}
onChange={(e) => setInputVal(e.target.value)}
onBlur={handleBlur}
disabled={disabled}
placeholder="값 입력"
/>
{saving && <Loader2 className="h-5 w-5 animate-spin text-gray-400" />}
</>
);
}
// === 절차 확인 (우측 전용) ===
function ProcedureInputOnly({ item, disabled, saving, onSave }: { item: WorkResultRow; disabled: boolean; saving: boolean; onSave: ChecklistItemProps["onSave"] }) {
const checked = item.result_value === "Y";
return (
<>
<Checkbox
checked={checked}
disabled={disabled}
className="h-8 w-8"
onCheckedChange={(v) => {
onSave(item.id, v ? "Y" : "N", null, v ? "completed" : "pending");
}}
/>
<span className="text-gray-600" style={{ fontSize: 15 }}></span>
{saving && <Loader2 className="h-5 w-5 animate-spin text-gray-400" />}
{item.status === "completed" && !saving && <span className="font-bold text-green-600" style={{ fontSize: 15 }}></span>}
</>
);
}
// === 자재/LOT (우측 전용) ===
function MaterialInputOnly({ item, disabled, saving, onSave }: { item: WorkResultRow; disabled: boolean; saving: boolean; onSave: ChecklistItemProps["onSave"] }) {
const [inputVal, setInputVal] = useState(item.result_value ?? "");
const handleSubmit = () => {
if (!inputVal || disabled) return;
onSave(item.id, inputVal, null, "completed");
};
return (
<>
<input
className="flex-1 rounded-xl border-2 border-gray-200 px-3 transition-colors focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-100"
style={{ height: 48, fontSize: 16 }}
value={inputVal}
onChange={(e) => setInputVal(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
disabled={disabled}
placeholder="LOT/바코드"
/>
<GlossyButton variant="blue" onClick={handleSubmit} disabled={disabled || !inputVal} style={{ minHeight: 48, minWidth: 80, fontSize: 15 }}>
</GlossyButton>
{saving && <Loader2 className="h-5 w-5 animate-spin text-gray-400" />}
{item.status === "completed" && !saving && <span className="font-bold text-green-600" style={{ fontSize: 15 }}></span>}
</>
);
}
// === 실적 입력 (우측 전용) ===
function ResultInputOnly({ 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 handleSave = () => {
if (disabled) return;
const val = JSON.stringify({ good, defect });
onSave(item.id, val, null, "completed");
};
return (
<>
<div className="flex items-center gap-1.5">
<span className="text-gray-500" style={{ fontSize: 14 }}></span>
<input type="number" className="w-20 rounded-lg border-2 border-gray-200 px-2 text-center font-bold" style={{ height: 44, fontSize: 18 }} value={good} onChange={(e) => setGood(e.target.value)} disabled={disabled} placeholder="0" />
</div>
<div className="flex items-center gap-1.5">
<span className="text-gray-500" style={{ fontSize: 14 }}></span>
<input type="number" className="w-20 rounded-lg border-2 border-red-200 px-2 text-center font-bold text-red-600" style={{ height: 44, fontSize: 18 }} value={defect} onChange={(e) => setDefect(e.target.value)} disabled={disabled} placeholder="0" />
</div>
<GlossyButton variant="blue" onClick={handleSave} disabled={disabled} style={{ minHeight: 44, minWidth: 80, fontSize: 15 }}>
</GlossyButton>
{saving && <Loader2 className="h-5 w-5 animate-spin text-gray-400" />}
</>
);
}
// ========================================
// check: 체크박스
// ========================================