- POP 생산: 재고 관리, 재작업 이력, BOM 자재투입 기능 추가 - POP 설정: 설정 시스템 + 관리 페이지 (/pop/admin) - POP 화면: 버그 수정 + 설정 연동 + 다음공정 활성화 수정 - PC 코드 무변경 (보류 6건: app.ts, 출고/입고/작업지시 컨트롤러, 레이아웃)
440 lines
19 KiB
TypeScript
440 lines
19 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect } from "react";
|
|
import { apiClient } from "@/lib/api/client";
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Types */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
export interface ProcessStep {
|
|
no: number;
|
|
name: string;
|
|
code: string;
|
|
status: "completed" | "in_progress" | "waiting" | "acceptable";
|
|
inputQty: number;
|
|
goodQty: number;
|
|
defectQty: number;
|
|
planQty: number;
|
|
availableQty: number;
|
|
}
|
|
|
|
interface ReworkChainItem {
|
|
id: string;
|
|
seq_no: string;
|
|
process_code: string;
|
|
process_name: string;
|
|
status: string;
|
|
input_qty: string;
|
|
good_qty: string;
|
|
defect_qty: string;
|
|
concession_qty: string;
|
|
is_rework: string;
|
|
rework_source_id: string | null;
|
|
started_at: string | null;
|
|
completed_at: string | null;
|
|
}
|
|
|
|
interface ReworkChain {
|
|
source: ReworkChainItem;
|
|
reworks: ReworkChainItem[];
|
|
totalReworkCount: number;
|
|
}
|
|
|
|
interface ProcessDetailModalProps {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
workInstructionNo: string;
|
|
totalQty: number;
|
|
steps: ProcessStep[];
|
|
woId?: string;
|
|
showReworkHistory?: boolean;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Component */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
export function ProcessDetailModal({
|
|
open,
|
|
onClose,
|
|
workInstructionNo,
|
|
totalQty,
|
|
steps,
|
|
woId,
|
|
showReworkHistory,
|
|
}: ProcessDetailModalProps) {
|
|
const [activeTab, setActiveTab] = useState<"steps" | "rework">("steps");
|
|
const [reworkChains, setReworkChains] = useState<ReworkChain[]>([]);
|
|
const [reworkLoading, setReworkLoading] = useState(false);
|
|
const [totalReworkCount, setTotalReworkCount] = useState(0);
|
|
|
|
// Fetch rework history when tab switches to rework
|
|
useEffect(() => {
|
|
if (!open || !woId || activeTab !== "rework") return;
|
|
let cancelled = false;
|
|
const fetchHistory = async () => {
|
|
setReworkLoading(true);
|
|
try {
|
|
const res = await apiClient.get(`/pop/production/rework-history/${woId}`);
|
|
if (!cancelled && res.data?.success) {
|
|
setReworkChains(res.data.data.chains || []);
|
|
setTotalReworkCount(res.data.data.total_rework_count || 0);
|
|
}
|
|
} catch {
|
|
// Non-fatal
|
|
} finally {
|
|
if (!cancelled) setReworkLoading(false);
|
|
}
|
|
};
|
|
fetchHistory();
|
|
return () => { cancelled = true; };
|
|
}, [open, woId, activeTab]);
|
|
|
|
// Reset tab on close
|
|
useEffect(() => {
|
|
if (!open) setActiveTab("steps");
|
|
}, [open]);
|
|
|
|
if (!open) return null;
|
|
|
|
const completedCount = steps.filter((s) => s.status === "completed").length;
|
|
const overallPct = steps.length > 0 ? Math.round((completedCount / steps.length) * 100) : 0;
|
|
const hasReworkData = showReworkHistory && woId;
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: "rgba(0,0,0,0.5)" }}>
|
|
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-lg mx-4 max-h-[90vh] flex flex-col overflow-hidden">
|
|
{/* Header */}
|
|
<div className="px-5 py-4 border-b border-gray-100 flex items-center justify-between">
|
|
<div>
|
|
<h3 className="text-lg font-bold text-gray-900">공정 순서 상세</h3>
|
|
<p className="text-xs text-gray-400 mt-0.5">{workInstructionNo}</p>
|
|
</div>
|
|
<button
|
|
onClick={onClose}
|
|
className="w-8 h-8 rounded-lg bg-gray-100 flex items-center justify-center text-gray-500 hover:bg-gray-200 active:scale-95 transition-all"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Tab switcher — only show if rework data available */}
|
|
{hasReworkData && (
|
|
<div className="flex border-b border-gray-100">
|
|
<button
|
|
onClick={() => setActiveTab("steps")}
|
|
className={`flex-1 py-3 text-sm font-bold transition-all ${
|
|
activeTab === "steps"
|
|
? "text-blue-600 border-b-2 border-blue-500"
|
|
: "text-gray-400 hover:text-gray-600"
|
|
}`}
|
|
>
|
|
공정순서
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab("rework")}
|
|
className={`flex-1 py-3 text-sm font-bold transition-all ${
|
|
activeTab === "rework"
|
|
? "text-orange-600 border-b-2 border-orange-500"
|
|
: "text-gray-400 hover:text-gray-600"
|
|
}`}
|
|
>
|
|
재작업 이력
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Scrollable body */}
|
|
<div className="flex-1 overflow-y-auto">
|
|
{/* Summary bar */}
|
|
{activeTab === "steps" && (
|
|
<div className="px-5 py-4">
|
|
<div className="mb-4 p-4 bg-gray-50 rounded-xl">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="text-sm font-bold text-gray-900">작업지시 총량</span>
|
|
<span className="text-xl font-extrabold text-gray-900">
|
|
{totalQty.toLocaleString()} EA
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="h-3 flex-1 bg-gray-200 rounded-full overflow-hidden">
|
|
<div
|
|
className="h-full bg-blue-500 rounded-full transition-all"
|
|
style={{ width: `${overallPct}%` }}
|
|
/>
|
|
</div>
|
|
<span className="text-sm font-bold text-blue-600">
|
|
{completedCount}/{steps.length} 공정
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Steps — shown when activeTab is "steps" */}
|
|
{activeTab === "steps" && (
|
|
<div className="px-5 pb-4 max-h-[400px] overflow-y-auto -mt-2">
|
|
{steps.map((s) => {
|
|
const borderColor =
|
|
s.status === "completed"
|
|
? "border-green-400 bg-green-50"
|
|
: s.status === "in_progress"
|
|
? "border-blue-400 bg-blue-50"
|
|
: s.status === "acceptable"
|
|
? "border-amber-400 bg-amber-50"
|
|
: "border-gray-200 bg-gray-50";
|
|
|
|
const dotColor =
|
|
s.status === "completed"
|
|
? "bg-green-500"
|
|
: s.status === "in_progress"
|
|
? "bg-blue-500"
|
|
: s.status === "acceptable"
|
|
? "bg-amber-500"
|
|
: "bg-gray-400";
|
|
|
|
const badge =
|
|
s.status === "completed" ? (
|
|
<span className="text-xs font-bold px-3 py-1 rounded-full bg-green-100 text-green-700">완료</span>
|
|
) : s.status === "in_progress" ? (
|
|
<span className="text-xs font-bold px-3 py-1 rounded-full bg-blue-100 text-blue-700">진행중</span>
|
|
) : s.status === "acceptable" ? (
|
|
<span className="text-xs font-bold px-3 py-1 rounded-full bg-amber-100 text-amber-700">접수가능</span>
|
|
) : (
|
|
<span className="text-xs font-bold px-3 py-1 rounded-full bg-gray-100 text-gray-500">대기</span>
|
|
);
|
|
|
|
const barColor =
|
|
s.status === "completed"
|
|
? "bg-green-500"
|
|
: s.status === "in_progress"
|
|
? "bg-blue-500"
|
|
: s.status === "acceptable"
|
|
? "bg-amber-500"
|
|
: "bg-gray-300";
|
|
|
|
const barTextColor =
|
|
s.status === "completed"
|
|
? "text-green-600"
|
|
: s.status === "in_progress"
|
|
? "text-blue-600"
|
|
: s.status === "acceptable"
|
|
? "text-amber-600"
|
|
: "text-gray-400";
|
|
|
|
const pct = s.planQty > 0 ? Math.round((s.inputQty / s.planQty) * 100) : 0;
|
|
const unaccept = s.planQty - s.inputQty;
|
|
|
|
return (
|
|
<div key={s.code + "-" + s.no} className={`rounded-xl border-2 ${borderColor} mb-3 overflow-hidden`}>
|
|
{/* Header */}
|
|
<div className="flex items-center gap-3 p-4 pb-2">
|
|
<div
|
|
className={`w-10 h-10 rounded-full ${dotColor} text-white flex items-center justify-center text-base font-bold shrink-0`}
|
|
>
|
|
{s.no}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-base font-bold text-gray-900 truncate">{s.name}</span>
|
|
{badge}
|
|
</div>
|
|
<span className="text-xs text-gray-400">{s.code}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Progress bar */}
|
|
<div className="px-4 pb-2">
|
|
<div className="flex items-center gap-2">
|
|
<div className="h-2.5 flex-1 bg-gray-100 rounded-full overflow-hidden">
|
|
<div
|
|
className={`h-full ${barColor} rounded-full transition-all`}
|
|
style={{ width: `${Math.min(pct, 100)}%` }}
|
|
/>
|
|
</div>
|
|
<span className={`text-xs font-bold ${barTextColor}`}>{pct}%</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Qty grid */}
|
|
<div className="grid grid-cols-4 gap-1 px-4 pb-3">
|
|
<div className="text-center py-1.5">
|
|
<div className="text-[10px] text-gray-400">지시</div>
|
|
<div className="text-base font-extrabold text-gray-900">{s.planQty.toLocaleString()}</div>
|
|
</div>
|
|
<div className="text-center py-1.5">
|
|
<div className="text-[10px] text-blue-500">접수</div>
|
|
<div className="text-base font-extrabold text-blue-700">{s.inputQty.toLocaleString()}</div>
|
|
</div>
|
|
<div className="text-center py-1.5">
|
|
<div className="text-[10px] text-emerald-500">양품</div>
|
|
<div className="text-base font-extrabold text-emerald-600">{s.goodQty.toLocaleString()}</div>
|
|
</div>
|
|
{s.status === "completed" || s.status === "in_progress" ? (
|
|
<div className="text-center py-1.5">
|
|
<div className="text-[10px] text-red-500">불량</div>
|
|
<div className="text-base font-extrabold text-red-600">{s.defectQty.toLocaleString()}</div>
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-1.5">
|
|
<div className="text-[10px] text-gray-400">미접수</div>
|
|
<div className="text-base font-extrabold text-gray-500">{Math.max(0, unaccept).toLocaleString()}</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Additional accept qty */}
|
|
{s.status === "in_progress" && s.availableQty > 0 && (
|
|
<div className="px-4 pb-3 text-right">
|
|
<span className="text-xs text-violet-600 font-semibold">
|
|
추가접수가능 {s.availableQty.toLocaleString()}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{s.status === "acceptable" && s.availableQty > 0 && (
|
|
<div className="px-4 pb-3 text-right">
|
|
<span className="text-xs text-amber-600 font-semibold">
|
|
접수가능 {s.availableQty.toLocaleString()}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Rework History — shown when activeTab is "rework" */}
|
|
{activeTab === "rework" && (
|
|
<div className="px-5 py-4 max-h-[450px] overflow-y-auto">
|
|
{reworkLoading ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<div className="animate-spin w-8 h-8 border-4 border-orange-200 border-t-orange-500 rounded-full" />
|
|
</div>
|
|
) : reworkChains.length === 0 ? (
|
|
<div className="text-center py-12">
|
|
<div className="text-4xl mb-3">🔍</div>
|
|
<p className="text-sm text-gray-400">재작업 이력이 없습니다</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Summary */}
|
|
<div className="mb-4 p-3 bg-orange-50 border border-orange-200 rounded-xl">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-bold text-orange-700">총 재작업</span>
|
|
<span className="text-lg font-extrabold text-orange-600">{totalReworkCount}회</span>
|
|
</div>
|
|
<p className="text-xs text-orange-500 mt-1">{reworkChains.length}개 불량 발생 지점</p>
|
|
</div>
|
|
|
|
{/* Chain tree */}
|
|
{reworkChains.map((chain, chainIdx) => {
|
|
const srcDefect = parseInt(chain.source.defect_qty || "0", 10);
|
|
const srcGood = parseInt(chain.source.good_qty || "0", 10);
|
|
|
|
return (
|
|
<div key={chain.source.id} className={`mb-4 ${chainIdx < reworkChains.length - 1 ? "pb-4 border-b border-gray-100" : ""}`}>
|
|
{/* Source (origin) node */}
|
|
<div className="flex items-start gap-3 mb-2">
|
|
<div className="flex flex-col items-center">
|
|
<div className="w-9 h-9 rounded-full bg-red-500 text-white flex items-center justify-center text-sm font-bold shrink-0">
|
|
{chain.source.seq_no || "?"}
|
|
</div>
|
|
{chain.reworks.length > 0 && (
|
|
<div className="w-0.5 h-4 bg-orange-300" />
|
|
)}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-bold text-gray-900 truncate">
|
|
{chain.source.process_name || chain.source.process_code}
|
|
</span>
|
|
<span className="text-xs font-bold px-2 py-0.5 rounded-full bg-red-100 text-red-600 shrink-0">
|
|
불량 {srcDefect}
|
|
</span>
|
|
</div>
|
|
<div className="flex gap-3 mt-1 text-xs text-gray-500">
|
|
<span>양품 {srcGood}</span>
|
|
<span>불량 {srcDefect}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Rework chain nodes */}
|
|
{chain.reworks.map((rw, rwIdx) => {
|
|
const rwGood = parseInt(rw.good_qty || "0", 10);
|
|
const rwDefect = parseInt(rw.defect_qty || "0", 10);
|
|
const rwInput = parseInt(rw.input_qty || "0", 10);
|
|
const isLast = rwIdx === chain.reworks.length - 1;
|
|
|
|
const statusColor =
|
|
rw.status === "completed" ? "bg-green-500"
|
|
: rw.status === "in_progress" ? "bg-blue-500"
|
|
: rw.status === "acceptable" ? "bg-amber-500"
|
|
: "bg-gray-400";
|
|
|
|
const statusLabel =
|
|
rw.status === "completed" ? "완료"
|
|
: rw.status === "in_progress" ? "진행중"
|
|
: rw.status === "acceptable" ? "접수가능"
|
|
: "대기";
|
|
|
|
return (
|
|
<div key={rw.id} className="flex items-start gap-3 ml-1">
|
|
<div className="flex flex-col items-center">
|
|
{!isLast && <div className="w-0.5 h-2 bg-orange-200" />}
|
|
<div className={`w-7 h-7 rounded-full ${statusColor} text-white flex items-center justify-center text-xs font-bold shrink-0 ring-2 ring-orange-200`}>
|
|
R{rwIdx + 1}
|
|
</div>
|
|
{!isLast && <div className="w-0.5 h-4 bg-orange-200" />}
|
|
</div>
|
|
<div className="flex-1 min-w-0 pb-2">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-semibold text-gray-800 truncate">
|
|
{rw.process_name || rw.process_code}
|
|
</span>
|
|
<span className={`text-[10px] font-bold px-2 py-0.5 rounded-full ${
|
|
rw.status === "completed" ? "bg-green-100 text-green-700"
|
|
: rw.status === "in_progress" ? "bg-blue-100 text-blue-700"
|
|
: rw.status === "acceptable" ? "bg-amber-100 text-amber-700"
|
|
: "bg-gray-100 text-gray-500"
|
|
}`}>
|
|
{statusLabel}
|
|
</span>
|
|
</div>
|
|
<div className="flex gap-3 mt-1 text-xs text-gray-500">
|
|
<span>투입 {rwInput}</span>
|
|
<span>양품 {rwGood}</span>
|
|
{rwDefect > 0 && <span className="text-red-500">불량 {rwDefect}</span>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
})}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
</div>{/* end scrollable body */}
|
|
|
|
{/* Footer */}
|
|
<div className="shrink-0 px-5 py-3 border-t border-gray-100">
|
|
<button
|
|
onClick={onClose}
|
|
className="w-full py-3 rounded-xl text-sm font-bold text-white bg-blue-500 active:scale-[0.98] transition-all"
|
|
>
|
|
닫기
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|