ERP-node/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailComponent.tsx

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>
);
}