833 lines
26 KiB
TypeScript
833 lines
26 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import React, { useEffect, useState, useMemo, useCallback } from "react";
|
||
|
|
import {
|
||
|
|
Loader2, Play, Pause, CheckCircle2, AlertCircle, Timer, Package,
|
||
|
|
} from "lucide-react";
|
||
|
|
import { toast } from "sonner";
|
||
|
|
import { Button } from "@/components/ui/button";
|
||
|
|
import { Input } from "@/components/ui/input";
|
||
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
||
|
|
import { Badge } from "@/components/ui/badge";
|
||
|
|
import { cn } from "@/lib/utils";
|
||
|
|
import { dataApi } from "@/lib/api/data";
|
||
|
|
import { apiClient } from "@/lib/api/client";
|
||
|
|
import { usePopEvent } from "@/hooks/pop/usePopEvent";
|
||
|
|
import { useAuth } from "@/hooks/useAuth";
|
||
|
|
import type { PopWorkDetailConfig } from "../types";
|
||
|
|
import type { TimelineProcessStep } from "../types";
|
||
|
|
|
||
|
|
// ========================================
|
||
|
|
// 타입
|
||
|
|
// ========================================
|
||
|
|
|
||
|
|
type RowData = Record<string, unknown>;
|
||
|
|
|
||
|
|
interface WorkResultRow {
|
||
|
|
id: string;
|
||
|
|
work_order_process_id: string;
|
||
|
|
source_work_item_id: string;
|
||
|
|
source_detail_id: string;
|
||
|
|
work_phase: string;
|
||
|
|
item_title: string;
|
||
|
|
item_sort_order: string;
|
||
|
|
detail_type: string;
|
||
|
|
detail_label: string;
|
||
|
|
detail_sort_order: string;
|
||
|
|
spec_value: string | null;
|
||
|
|
lower_limit: string | null;
|
||
|
|
upper_limit: string | null;
|
||
|
|
input_type: string | null;
|
||
|
|
result_value: string | null;
|
||
|
|
status: string;
|
||
|
|
is_passed: string | null;
|
||
|
|
recorded_by: string | null;
|
||
|
|
recorded_at: string | null;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface WorkGroup {
|
||
|
|
phase: string;
|
||
|
|
title: string;
|
||
|
|
itemId: string;
|
||
|
|
sortOrder: number;
|
||
|
|
total: number;
|
||
|
|
completed: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
type WorkPhase = "PRE" | "IN" | "POST";
|
||
|
|
const PHASE_ORDER: Record<string, number> = { PRE: 1, IN: 2, POST: 3 };
|
||
|
|
|
||
|
|
interface ProcessTimerData {
|
||
|
|
started_at: string | null;
|
||
|
|
paused_at: string | null;
|
||
|
|
total_paused_time: string | null;
|
||
|
|
status: string;
|
||
|
|
good_qty: string | null;
|
||
|
|
defect_qty: string | null;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ========================================
|
||
|
|
// Props
|
||
|
|
// ========================================
|
||
|
|
|
||
|
|
interface PopWorkDetailComponentProps {
|
||
|
|
config?: PopWorkDetailConfig;
|
||
|
|
screenId?: string;
|
||
|
|
componentId?: string;
|
||
|
|
currentRowSpan?: number;
|
||
|
|
currentColSpan?: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ========================================
|
||
|
|
// 메인 컴포넌트
|
||
|
|
// ========================================
|
||
|
|
|
||
|
|
export function PopWorkDetailComponent({
|
||
|
|
config,
|
||
|
|
screenId,
|
||
|
|
componentId,
|
||
|
|
}: PopWorkDetailComponentProps) {
|
||
|
|
const { getSharedData } = usePopEvent(screenId || "default");
|
||
|
|
const { user } = useAuth();
|
||
|
|
|
||
|
|
const cfg: PopWorkDetailConfig = {
|
||
|
|
showTimer: config?.showTimer ?? true,
|
||
|
|
showQuantityInput: config?.showQuantityInput ?? true,
|
||
|
|
phaseLabels: config?.phaseLabels ?? { PRE: "작업 전", IN: "작업 중", POST: "작업 후" },
|
||
|
|
};
|
||
|
|
|
||
|
|
// parentRow에서 현재 공정 정보 추출
|
||
|
|
const parentRow = getSharedData<RowData>("parentRow");
|
||
|
|
const processFlow = parentRow?.__processFlow__ as TimelineProcessStep[] | undefined;
|
||
|
|
const currentProcess = processFlow?.find((p) => p.isCurrent);
|
||
|
|
const workOrderProcessId = currentProcess?.processId
|
||
|
|
? String(currentProcess.processId)
|
||
|
|
: undefined;
|
||
|
|
const processName = currentProcess?.processName ?? "공정 상세";
|
||
|
|
|
||
|
|
// ========================================
|
||
|
|
// 상태
|
||
|
|
// ========================================
|
||
|
|
|
||
|
|
const [allResults, setAllResults] = useState<WorkResultRow[]>([]);
|
||
|
|
const [processData, setProcessData] = useState<ProcessTimerData | null>(null);
|
||
|
|
const [loading, setLoading] = useState(true);
|
||
|
|
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
||
|
|
const [tick, setTick] = useState(Date.now());
|
||
|
|
const [savingIds, setSavingIds] = useState<Set<string>>(new Set());
|
||
|
|
|
||
|
|
// 수량 입력 로컬 상태
|
||
|
|
const [goodQty, setGoodQty] = useState("");
|
||
|
|
const [defectQty, setDefectQty] = useState("");
|
||
|
|
|
||
|
|
// ========================================
|
||
|
|
// D-FE1: 데이터 로드
|
||
|
|
// ========================================
|
||
|
|
|
||
|
|
const fetchData = useCallback(async () => {
|
||
|
|
if (!workOrderProcessId) {
|
||
|
|
setLoading(false);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
setLoading(true);
|
||
|
|
|
||
|
|
const [resultRes, processRes] = await Promise.all([
|
||
|
|
dataApi.getTableData("process_work_result", {
|
||
|
|
size: 500,
|
||
|
|
filters: { work_order_process_id: workOrderProcessId },
|
||
|
|
}),
|
||
|
|
dataApi.getTableData("work_order_process", {
|
||
|
|
size: 1,
|
||
|
|
filters: { id: workOrderProcessId },
|
||
|
|
}),
|
||
|
|
]);
|
||
|
|
|
||
|
|
setAllResults((resultRes.data ?? []) as unknown as WorkResultRow[]);
|
||
|
|
|
||
|
|
const proc = (processRes.data?.[0] ?? null) as ProcessTimerData | null;
|
||
|
|
setProcessData(proc);
|
||
|
|
if (proc) {
|
||
|
|
setGoodQty(proc.good_qty ?? "");
|
||
|
|
setDefectQty(proc.defect_qty ?? "");
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
toast.error("데이터를 불러오는데 실패했습니다.");
|
||
|
|
} finally {
|
||
|
|
setLoading(false);
|
||
|
|
}
|
||
|
|
}, [workOrderProcessId]);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
fetchData();
|
||
|
|
}, [fetchData]);
|
||
|
|
|
||
|
|
// ========================================
|
||
|
|
// D-FE2: 좌측 사이드바 - 작업항목 그룹핑
|
||
|
|
// ========================================
|
||
|
|
|
||
|
|
const groups = useMemo<WorkGroup[]>(() => {
|
||
|
|
const map = new Map<string, WorkGroup>();
|
||
|
|
for (const row of allResults) {
|
||
|
|
const key = row.source_work_item_id;
|
||
|
|
if (!map.has(key)) {
|
||
|
|
map.set(key, {
|
||
|
|
phase: row.work_phase,
|
||
|
|
title: row.item_title,
|
||
|
|
itemId: key,
|
||
|
|
sortOrder: parseInt(row.item_sort_order || "0", 10),
|
||
|
|
total: 0,
|
||
|
|
completed: 0,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
const g = map.get(key)!;
|
||
|
|
g.total++;
|
||
|
|
if (row.status === "completed") g.completed++;
|
||
|
|
}
|
||
|
|
return Array.from(map.values()).sort(
|
||
|
|
(a, b) =>
|
||
|
|
(PHASE_ORDER[a.phase] ?? 9) - (PHASE_ORDER[b.phase] ?? 9) ||
|
||
|
|
a.sortOrder - b.sortOrder
|
||
|
|
);
|
||
|
|
}, [allResults]);
|
||
|
|
|
||
|
|
// phase별로 그룹핑
|
||
|
|
const groupsByPhase = useMemo(() => {
|
||
|
|
const result: Record<string, WorkGroup[]> = {};
|
||
|
|
for (const g of groups) {
|
||
|
|
if (!result[g.phase]) result[g.phase] = [];
|
||
|
|
result[g.phase].push(g);
|
||
|
|
}
|
||
|
|
return result;
|
||
|
|
}, [groups]);
|
||
|
|
|
||
|
|
// 첫 그룹 자동 선택
|
||
|
|
useEffect(() => {
|
||
|
|
if (groups.length > 0 && !selectedGroupId) {
|
||
|
|
setSelectedGroupId(groups[0].itemId);
|
||
|
|
}
|
||
|
|
}, [groups, selectedGroupId]);
|
||
|
|
|
||
|
|
// ========================================
|
||
|
|
// D-FE3: 우측 체크리스트
|
||
|
|
// ========================================
|
||
|
|
|
||
|
|
const currentItems = useMemo(
|
||
|
|
() =>
|
||
|
|
allResults
|
||
|
|
.filter((r) => r.source_work_item_id === selectedGroupId)
|
||
|
|
.sort((a, b) => parseInt(a.detail_sort_order || "0", 10) - parseInt(b.detail_sort_order || "0", 10)),
|
||
|
|
[allResults, selectedGroupId]
|
||
|
|
);
|
||
|
|
|
||
|
|
const saveResultValue = useCallback(
|
||
|
|
async (
|
||
|
|
rowId: string,
|
||
|
|
resultValue: string,
|
||
|
|
isPassed: string | null,
|
||
|
|
newStatus: string
|
||
|
|
) => {
|
||
|
|
setSavingIds((prev) => new Set(prev).add(rowId));
|
||
|
|
try {
|
||
|
|
await apiClient.post("/pop/execute-action", {
|
||
|
|
tasks: [
|
||
|
|
{ type: "data-update", targetTable: "process_work_result", targetColumn: "result_value", value: resultValue, items: [{ id: rowId }] },
|
||
|
|
{ type: "data-update", targetTable: "process_work_result", targetColumn: "status", value: newStatus, items: [{ id: rowId }] },
|
||
|
|
...(isPassed !== null
|
||
|
|
? [{ type: "data-update", targetTable: "process_work_result", targetColumn: "is_passed", value: isPassed, items: [{ id: rowId }] }]
|
||
|
|
: []),
|
||
|
|
{ type: "data-update", targetTable: "process_work_result", targetColumn: "recorded_by", value: user?.userId ?? "", items: [{ id: rowId }] },
|
||
|
|
{ type: "data-update", targetTable: "process_work_result", targetColumn: "recorded_at", value: new Date().toISOString(), items: [{ id: rowId }] },
|
||
|
|
],
|
||
|
|
data: { items: [{ id: rowId }], fieldValues: {} },
|
||
|
|
});
|
||
|
|
|
||
|
|
setAllResults((prev) =>
|
||
|
|
prev.map((r) =>
|
||
|
|
r.id === rowId
|
||
|
|
? {
|
||
|
|
...r,
|
||
|
|
result_value: resultValue,
|
||
|
|
status: newStatus,
|
||
|
|
is_passed: isPassed,
|
||
|
|
recorded_by: user?.userId ?? null,
|
||
|
|
recorded_at: new Date().toISOString(),
|
||
|
|
}
|
||
|
|
: r
|
||
|
|
)
|
||
|
|
);
|
||
|
|
} catch {
|
||
|
|
toast.error("저장에 실패했습니다.");
|
||
|
|
} finally {
|
||
|
|
setSavingIds((prev) => {
|
||
|
|
const next = new Set(prev);
|
||
|
|
next.delete(rowId);
|
||
|
|
return next;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
},
|
||
|
|
[user?.userId]
|
||
|
|
);
|
||
|
|
|
||
|
|
// ========================================
|
||
|
|
// D-FE4: 타이머
|
||
|
|
// ========================================
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (!cfg.showTimer || !processData?.started_at) return;
|
||
|
|
const id = setInterval(() => setTick(Date.now()), 1000);
|
||
|
|
return () => clearInterval(id);
|
||
|
|
}, [cfg.showTimer, processData?.started_at]);
|
||
|
|
|
||
|
|
const elapsedMs = useMemo(() => {
|
||
|
|
if (!processData?.started_at) return 0;
|
||
|
|
const now = tick;
|
||
|
|
const totalMs = now - new Date(processData.started_at).getTime();
|
||
|
|
const pausedSec = parseInt(processData.total_paused_time || "0", 10);
|
||
|
|
const currentPauseMs = processData.paused_at
|
||
|
|
? now - new Date(processData.paused_at).getTime()
|
||
|
|
: 0;
|
||
|
|
return Math.max(0, totalMs - pausedSec * 1000 - currentPauseMs);
|
||
|
|
}, [processData?.started_at, processData?.paused_at, processData?.total_paused_time, tick]);
|
||
|
|
|
||
|
|
const formattedTime = useMemo(() => {
|
||
|
|
const totalSec = Math.floor(elapsedMs / 1000);
|
||
|
|
const h = String(Math.floor(totalSec / 3600)).padStart(2, "0");
|
||
|
|
const m = String(Math.floor((totalSec % 3600) / 60)).padStart(2, "0");
|
||
|
|
const s = String(totalSec % 60).padStart(2, "0");
|
||
|
|
return `${h}:${m}:${s}`;
|
||
|
|
}, [elapsedMs]);
|
||
|
|
|
||
|
|
const isPaused = !!processData?.paused_at;
|
||
|
|
const isStarted = !!processData?.started_at;
|
||
|
|
|
||
|
|
const handleTimerAction = useCallback(
|
||
|
|
async (action: "start" | "pause" | "resume") => {
|
||
|
|
if (!workOrderProcessId) return;
|
||
|
|
try {
|
||
|
|
await apiClient.post("/api/pop/production/timer", {
|
||
|
|
workOrderProcessId,
|
||
|
|
action,
|
||
|
|
});
|
||
|
|
// 타이머 상태 새로고침
|
||
|
|
const res = await dataApi.getTableData("work_order_process", {
|
||
|
|
size: 1,
|
||
|
|
filters: { id: workOrderProcessId },
|
||
|
|
});
|
||
|
|
const proc = (res.data?.[0] ?? null) as ProcessTimerData | null;
|
||
|
|
if (proc) setProcessData(proc);
|
||
|
|
} catch {
|
||
|
|
toast.error("타이머 제어에 실패했습니다.");
|
||
|
|
}
|
||
|
|
},
|
||
|
|
[workOrderProcessId]
|
||
|
|
);
|
||
|
|
|
||
|
|
// ========================================
|
||
|
|
// D-FE5: 수량 등록 + 완료
|
||
|
|
// ========================================
|
||
|
|
|
||
|
|
const handleQuantityRegister = useCallback(async () => {
|
||
|
|
if (!workOrderProcessId) return;
|
||
|
|
try {
|
||
|
|
await apiClient.post("/pop/execute-action", {
|
||
|
|
tasks: [
|
||
|
|
{ type: "data-update", targetTable: "work_order_process", targetColumn: "good_qty", value: goodQty || "0", items: [{ id: workOrderProcessId }] },
|
||
|
|
{ type: "data-update", targetTable: "work_order_process", targetColumn: "defect_qty", value: defectQty || "0", items: [{ id: workOrderProcessId }] },
|
||
|
|
],
|
||
|
|
data: { items: [{ id: workOrderProcessId }], fieldValues: {} },
|
||
|
|
});
|
||
|
|
toast.success("수량이 등록되었습니다.");
|
||
|
|
} catch {
|
||
|
|
toast.error("수량 등록에 실패했습니다.");
|
||
|
|
}
|
||
|
|
}, [workOrderProcessId, goodQty, defectQty]);
|
||
|
|
|
||
|
|
const handleProcessComplete = useCallback(async () => {
|
||
|
|
if (!workOrderProcessId) return;
|
||
|
|
try {
|
||
|
|
await apiClient.post("/pop/execute-action", {
|
||
|
|
tasks: [
|
||
|
|
{ type: "data-update", targetTable: "work_order_process", targetColumn: "status", value: "completed", items: [{ id: workOrderProcessId }] },
|
||
|
|
{ type: "data-update", targetTable: "work_order_process", targetColumn: "completed_at", value: new Date().toISOString(), items: [{ id: workOrderProcessId }] },
|
||
|
|
],
|
||
|
|
data: { items: [{ id: workOrderProcessId }], fieldValues: {} },
|
||
|
|
});
|
||
|
|
toast.success("공정이 완료되었습니다.");
|
||
|
|
setProcessData((prev) =>
|
||
|
|
prev ? { ...prev, status: "completed" } : prev
|
||
|
|
);
|
||
|
|
} catch {
|
||
|
|
toast.error("공정 완료 처리에 실패했습니다.");
|
||
|
|
}
|
||
|
|
}, [workOrderProcessId]);
|
||
|
|
|
||
|
|
// ========================================
|
||
|
|
// 안전 장치
|
||
|
|
// ========================================
|
||
|
|
|
||
|
|
if (!parentRow) {
|
||
|
|
return (
|
||
|
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||
|
|
<AlertCircle className="mr-2 h-4 w-4" />
|
||
|
|
카드를 선택해주세요
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!workOrderProcessId) {
|
||
|
|
return (
|
||
|
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||
|
|
<AlertCircle className="mr-2 h-4 w-4" />
|
||
|
|
공정 정보를 찾을 수 없습니다
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (loading) {
|
||
|
|
return (
|
||
|
|
<div className="flex h-full items-center justify-center">
|
||
|
|
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (allResults.length === 0) {
|
||
|
|
return (
|
||
|
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||
|
|
<AlertCircle className="mr-2 h-4 w-4" />
|
||
|
|
작업기준이 등록되지 않았습니다
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
const isProcessCompleted = processData?.status === "completed";
|
||
|
|
|
||
|
|
// ========================================
|
||
|
|
// 렌더링
|
||
|
|
// ========================================
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="flex h-full flex-col">
|
||
|
|
{/* 헤더 */}
|
||
|
|
<div className="flex items-center justify-between border-b px-3 py-2">
|
||
|
|
<h3 className="text-sm font-semibold">{processName}</h3>
|
||
|
|
{cfg.showTimer && (
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<Timer className="h-4 w-4 text-muted-foreground" />
|
||
|
|
<span className="font-mono text-sm font-medium tabular-nums">
|
||
|
|
{formattedTime}
|
||
|
|
</span>
|
||
|
|
{!isProcessCompleted && (
|
||
|
|
<>
|
||
|
|
{!isStarted && (
|
||
|
|
<Button size="sm" variant="outline" className="h-7 px-2 text-xs" onClick={() => handleTimerAction("start")}>
|
||
|
|
<Play className="mr-1 h-3 w-3" />
|
||
|
|
시작
|
||
|
|
</Button>
|
||
|
|
)}
|
||
|
|
{isStarted && !isPaused && (
|
||
|
|
<Button size="sm" variant="outline" className="h-7 px-2 text-xs" onClick={() => handleTimerAction("pause")}>
|
||
|
|
<Pause className="mr-1 h-3 w-3" />
|
||
|
|
정지
|
||
|
|
</Button>
|
||
|
|
)}
|
||
|
|
{isStarted && isPaused && (
|
||
|
|
<Button size="sm" variant="outline" className="h-7 px-2 text-xs" onClick={() => handleTimerAction("resume")}>
|
||
|
|
<Play className="mr-1 h-3 w-3" />
|
||
|
|
재개
|
||
|
|
</Button>
|
||
|
|
)}
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 본문: 좌측 사이드바 + 우측 체크리스트 */}
|
||
|
|
<div className="flex flex-1 overflow-hidden">
|
||
|
|
{/* 좌측 사이드바 */}
|
||
|
|
<div className="w-40 shrink-0 overflow-y-auto border-r bg-muted/30">
|
||
|
|
{(["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-3 pb-1 pt-2 text-[10px] font-semibold uppercase text-muted-foreground">
|
||
|
|
{cfg.phaseLabels[phase] ?? phase}
|
||
|
|
</div>
|
||
|
|
{phaseGroups.map((g) => (
|
||
|
|
<button
|
||
|
|
key={g.itemId}
|
||
|
|
className={cn(
|
||
|
|
"flex w-full flex-col px-3 py-1.5 text-left transition-colors",
|
||
|
|
selectedGroupId === g.itemId
|
||
|
|
? "bg-primary/10 text-primary"
|
||
|
|
: "hover:bg-muted/60"
|
||
|
|
)}
|
||
|
|
onClick={() => setSelectedGroupId(g.itemId)}
|
||
|
|
>
|
||
|
|
<span className="text-xs font-medium leading-tight">
|
||
|
|
{g.title}
|
||
|
|
</span>
|
||
|
|
<span className="text-[10px] text-muted-foreground">
|
||
|
|
{g.completed}/{g.total} 완료
|
||
|
|
</span>
|
||
|
|
</button>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 우측 체크리스트 */}
|
||
|
|
<div className="flex-1 overflow-y-auto p-3">
|
||
|
|
{selectedGroupId && (
|
||
|
|
<div className="space-y-2">
|
||
|
|
{currentItems.map((item) => (
|
||
|
|
<ChecklistItem
|
||
|
|
key={item.id}
|
||
|
|
item={item}
|
||
|
|
saving={savingIds.has(item.id)}
|
||
|
|
disabled={isProcessCompleted}
|
||
|
|
onSave={saveResultValue}
|
||
|
|
/>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 하단: 수량 입력 + 완료 */}
|
||
|
|
{cfg.showQuantityInput && (
|
||
|
|
<div className="flex items-center gap-2 border-t px-3 py-2">
|
||
|
|
<Package className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||
|
|
<div className="flex items-center gap-1">
|
||
|
|
<span className="text-xs text-muted-foreground">양품</span>
|
||
|
|
<Input
|
||
|
|
type="number"
|
||
|
|
className="h-7 w-20 text-xs"
|
||
|
|
value={goodQty}
|
||
|
|
onChange={(e) => setGoodQty(e.target.value)}
|
||
|
|
disabled={isProcessCompleted}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div className="flex items-center gap-1">
|
||
|
|
<span className="text-xs text-muted-foreground">불량</span>
|
||
|
|
<Input
|
||
|
|
type="number"
|
||
|
|
className="h-7 w-20 text-xs"
|
||
|
|
value={defectQty}
|
||
|
|
onChange={(e) => setDefectQty(e.target.value)}
|
||
|
|
disabled={isProcessCompleted}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<Button
|
||
|
|
size="sm"
|
||
|
|
variant="outline"
|
||
|
|
className="h-7 text-xs"
|
||
|
|
onClick={handleQuantityRegister}
|
||
|
|
disabled={isProcessCompleted}
|
||
|
|
>
|
||
|
|
수량 등록
|
||
|
|
</Button>
|
||
|
|
<div className="flex-1" />
|
||
|
|
{!isProcessCompleted && (
|
||
|
|
<Button
|
||
|
|
size="sm"
|
||
|
|
variant="default"
|
||
|
|
className="h-7 text-xs"
|
||
|
|
onClick={handleProcessComplete}
|
||
|
|
>
|
||
|
|
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||
|
|
공정 완료
|
||
|
|
</Button>
|
||
|
|
)}
|
||
|
|
{isProcessCompleted && (
|
||
|
|
<Badge variant="outline" className="text-xs text-green-600">
|
||
|
|
완료됨
|
||
|
|
</Badge>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ========================================
|
||
|
|
// 체크리스트 개별 항목
|
||
|
|
// ========================================
|
||
|
|
|
||
|
|
interface ChecklistItemProps {
|
||
|
|
item: WorkResultRow;
|
||
|
|
saving: boolean;
|
||
|
|
disabled: boolean;
|
||
|
|
onSave: (
|
||
|
|
rowId: string,
|
||
|
|
resultValue: string,
|
||
|
|
isPassed: string | null,
|
||
|
|
newStatus: string
|
||
|
|
) => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
function ChecklistItem({ item, saving, disabled, onSave }: ChecklistItemProps) {
|
||
|
|
const isSaving = saving;
|
||
|
|
const isDisabled = disabled || isSaving;
|
||
|
|
|
||
|
|
switch (item.detail_type) {
|
||
|
|
case "check":
|
||
|
|
return <CheckItem item={item} disabled={isDisabled} saving={isSaving} onSave={onSave} />;
|
||
|
|
case "inspect":
|
||
|
|
return <InspectItem item={item} disabled={isDisabled} saving={isSaving} onSave={onSave} />;
|
||
|
|
case "input":
|
||
|
|
return <InputItem item={item} disabled={isDisabled} saving={isSaving} onSave={onSave} />;
|
||
|
|
case "procedure":
|
||
|
|
return <ProcedureItem item={item} disabled={isDisabled} saving={isSaving} onSave={onSave} />;
|
||
|
|
case "material":
|
||
|
|
return <MaterialItem item={item} disabled={isDisabled} saving={isSaving} onSave={onSave} />;
|
||
|
|
default:
|
||
|
|
return (
|
||
|
|
<div className="rounded border p-2 text-xs text-muted-foreground">
|
||
|
|
알 수 없는 유형: {item.detail_type}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ===== check: 체크박스 =====
|
||
|
|
|
||
|
|
function CheckItem({
|
||
|
|
item,
|
||
|
|
disabled,
|
||
|
|
saving,
|
||
|
|
onSave,
|
||
|
|
}: {
|
||
|
|
item: WorkResultRow;
|
||
|
|
disabled: boolean;
|
||
|
|
saving: boolean;
|
||
|
|
onSave: ChecklistItemProps["onSave"];
|
||
|
|
}) {
|
||
|
|
const checked = item.result_value === "Y";
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
className={cn(
|
||
|
|
"flex items-center gap-2 rounded border px-3 py-2",
|
||
|
|
item.status === "completed" && "bg-green-50 border-green-200"
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
<Checkbox
|
||
|
|
checked={checked}
|
||
|
|
disabled={disabled}
|
||
|
|
onCheckedChange={(v) => {
|
||
|
|
const val = v ? "Y" : "N";
|
||
|
|
onSave(item.id, val, v ? "Y" : "N", v ? "completed" : "pending");
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
<span className="flex-1 text-xs">{item.detail_label}</span>
|
||
|
|
{saving && <Loader2 className="h-3 w-3 animate-spin" />}
|
||
|
|
{item.status === "completed" && !saving && (
|
||
|
|
<Badge variant="outline" className="text-[10px] text-green-600">
|
||
|
|
완료
|
||
|
|
</Badge>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ===== inspect: 측정값 입력 (범위 판정) =====
|
||
|
|
|
||
|
|
function InspectItem({
|
||
|
|
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 handleBlur = () => {
|
||
|
|
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");
|
||
|
|
};
|
||
|
|
|
||
|
|
const isPassed = item.is_passed;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
className={cn(
|
||
|
|
"rounded border px-3 py-2",
|
||
|
|
isPassed === "Y" && "bg-green-50 border-green-200",
|
||
|
|
isPassed === "N" && "bg-red-50 border-red-200"
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
<div className="mb-1 flex items-center justify-between">
|
||
|
|
<span className="text-xs font-medium">{item.detail_label}</span>
|
||
|
|
{hasRange && (
|
||
|
|
<span className="text-[10px] text-muted-foreground">
|
||
|
|
기준: {item.lower_limit} ~ {item.upper_limit}
|
||
|
|
{item.spec_value ? ` (표준: ${item.spec_value})` : ""}
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<Input
|
||
|
|
type="number"
|
||
|
|
className="h-7 w-28 text-xs"
|
||
|
|
value={inputVal}
|
||
|
|
onChange={(e) => setInputVal(e.target.value)}
|
||
|
|
onBlur={handleBlur}
|
||
|
|
disabled={disabled}
|
||
|
|
placeholder="측정값 입력"
|
||
|
|
/>
|
||
|
|
{saving && <Loader2 className="h-3 w-3 animate-spin" />}
|
||
|
|
{isPassed === "Y" && !saving && (
|
||
|
|
<Badge variant="outline" className="text-[10px] text-green-600">합격</Badge>
|
||
|
|
)}
|
||
|
|
{isPassed === "N" && !saving && (
|
||
|
|
<Badge variant="outline" className="text-[10px] text-red-600">불합격</Badge>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ===== input: 자유 입력 =====
|
||
|
|
|
||
|
|
function InputItem({
|
||
|
|
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 (
|
||
|
|
<div
|
||
|
|
className={cn(
|
||
|
|
"rounded border px-3 py-2",
|
||
|
|
item.status === "completed" && "bg-green-50 border-green-200"
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
<div className="mb-1 text-xs font-medium">{item.detail_label}</div>
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<Input
|
||
|
|
type={inputType}
|
||
|
|
className="h-7 flex-1 text-xs"
|
||
|
|
value={inputVal}
|
||
|
|
onChange={(e) => setInputVal(e.target.value)}
|
||
|
|
onBlur={handleBlur}
|
||
|
|
disabled={disabled}
|
||
|
|
placeholder="값 입력"
|
||
|
|
/>
|
||
|
|
{saving && <Loader2 className="h-3 w-3 animate-spin" />}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ===== procedure: 절차 확인 (읽기 전용 + 체크) =====
|
||
|
|
|
||
|
|
function ProcedureItem({
|
||
|
|
item,
|
||
|
|
disabled,
|
||
|
|
saving,
|
||
|
|
onSave,
|
||
|
|
}: {
|
||
|
|
item: WorkResultRow;
|
||
|
|
disabled: boolean;
|
||
|
|
saving: boolean;
|
||
|
|
onSave: ChecklistItemProps["onSave"];
|
||
|
|
}) {
|
||
|
|
const checked = item.result_value === "Y";
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
className={cn(
|
||
|
|
"rounded border px-3 py-2",
|
||
|
|
item.status === "completed" && "bg-green-50 border-green-200"
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
<div className="mb-1 text-xs text-muted-foreground">
|
||
|
|
{item.spec_value || item.detail_label}
|
||
|
|
</div>
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<Checkbox
|
||
|
|
checked={checked}
|
||
|
|
disabled={disabled}
|
||
|
|
onCheckedChange={(v) => {
|
||
|
|
onSave(item.id, v ? "Y" : "N", null, v ? "completed" : "pending");
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
<span className="text-xs">확인</span>
|
||
|
|
{saving && <Loader2 className="h-3 w-3 animate-spin" />}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ===== material: 자재/LOT 입력 =====
|
||
|
|
|
||
|
|
function MaterialItem({
|
||
|
|
item,
|
||
|
|
disabled,
|
||
|
|
saving,
|
||
|
|
onSave,
|
||
|
|
}: {
|
||
|
|
item: WorkResultRow;
|
||
|
|
disabled: boolean;
|
||
|
|
saving: boolean;
|
||
|
|
onSave: ChecklistItemProps["onSave"];
|
||
|
|
}) {
|
||
|
|
const [inputVal, setInputVal] = useState(item.result_value ?? "");
|
||
|
|
|
||
|
|
const handleBlur = () => {
|
||
|
|
if (!inputVal || disabled) return;
|
||
|
|
onSave(item.id, inputVal, null, "completed");
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
className={cn(
|
||
|
|
"rounded border px-3 py-2",
|
||
|
|
item.status === "completed" && "bg-green-50 border-green-200"
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
<div className="mb-1 text-xs font-medium">{item.detail_label}</div>
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<Input
|
||
|
|
className="h-7 flex-1 text-xs"
|
||
|
|
value={inputVal}
|
||
|
|
onChange={(e) => setInputVal(e.target.value)}
|
||
|
|
onBlur={handleBlur}
|
||
|
|
disabled={disabled}
|
||
|
|
placeholder="LOT 번호 입력"
|
||
|
|
/>
|
||
|
|
{saving && <Loader2 className="h-3 w-3 animate-spin" />}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|