pop-work-detail: 디자인 v2 전면 개편
- 글로시/입체감 버튼 스타일 (GlossyButton 컴포넌트 추가) - 체크리스트 좌정보/우입력 분할 레이아웃 (여백 최소화) - 타이머 sticky 고정 + 시작/일시정지/재개 전환 토글 - 풋터 3버튼 제거 → 각 그룹 하단에 작업완료 버튼 배치 - 필수 항목 미체크 시 다음 공정 탭 전환 차단 - 전체 글자 크기 확대 (버튼 18px+, 항목명 15px, 타이머 26px) - 배경 흰색 유지
This commit is contained in:
parent
1128a4c278
commit
3249611cfc
|
|
@ -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: 체크박스
|
||||
// ========================================
|
||||
|
|
|
|||
Loading…
Reference in New Issue