Files
vexplor/frontend/components/pop/hardcoded/quality/InspectionList.tsx
SeongHyun Kim 327b4d01c2 feat: POP 시연 준비 — 5개 화면 + 버그 수정 + 자동 창고 매칭
- 구매입고: 검사기준 API 수정, 검사결과 DB 저장, 검사 미완료 확정 차단
- 판매출고: 재고 부족 사전 검증, 수주상세 ship_qty 반영, 에러 메시지 개선
- 공정실행: seq_no 비순차 대응(3곳), 자재투입 자동 창고 매칭 재고차감, 불필요 버튼 제거
- 검사관리+입출고관리: 신규 화면 (quality, inventory)
- 공통: ConfirmModal 커스텀 모달 (native confirm 대체)
2026-04-09 14:38:28 +09:00

746 lines
23 KiB
TypeScript

"use client";
import { useRouter } from "next/navigation";
import React, { useCallback, useEffect, useState } from "react";
import { apiClient } from "@/lib/api/client";
import { DateRangePicker } from "../inventory/DateRangePicker";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface InspectionRow {
id: string;
inspectionNumber: string;
itemCode: string;
itemName: string;
inspectionType: string;
totalQty: number;
goodQty: number;
badQty: number;
passRate: number;
overallJudgment: string;
defectDescription: string;
referenceTable: string;
referenceId: string;
memo: string;
inspector: string;
supplierCode: string;
supplierName: string;
isCompleted: string;
completedDate: string;
createdDate: string;
time: string;
date: string;
fullDate: string;
}
interface DetailRow {
inspectionItemName: string;
inspectionStandard: string;
passCriteria: string;
measuredValue: string;
judgment: string;
}
interface KpiData {
total: number;
pass: number;
fail: number;
waiting: number;
passRate: number;
}
type TabKey = "all" | "incoming" | "process" | "outgoing";
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function getJudgmentStyle(judgment: string): { color: string; label: string } {
if (judgment === "합격" || judgment === "pass")
return { color: "text-green-600 bg-green-50", label: "합격" };
if (judgment === "불합격" || judgment === "fail")
return { color: "text-red-600 bg-red-50", label: "불합격" };
return { color: "text-amber-600 bg-amber-50", label: "대기" };
}
function classifyTab(inspectionType: string): TabKey {
if (inspectionType?.includes("입고")) return "incoming";
if (inspectionType?.includes("공정") || inspectionType?.includes("생산"))
return "process";
if (inspectionType?.includes("출하") || inspectionType?.includes("출고"))
return "outgoing";
return "all";
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export function InspectionList() {
const router = useRouter();
const [dateFrom, setDateFrom] = useState(() =>
new Date().toISOString().slice(0, 10),
);
const [dateTo, setDateTo] = useState(() =>
new Date().toISOString().slice(0, 10),
);
const [keyword, setKeyword] = useState("");
const [judgmentFilter, setJudgmentFilter] = useState("전체");
const [items, setItems] = useState<InspectionRow[]>([]);
const [kpi, setKpi] = useState<KpiData>({
total: 0,
pass: 0,
fail: 0,
waiting: 0,
passRate: 0,
});
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<TabKey>("all");
const [selectedItem, setSelectedItem] = useState<InspectionRow | null>(null);
const [selectedDetails, setSelectedDetails] = useState<DetailRow[]>([]);
/* Fetch data — 마스터 (inspection_result_mng) */
const fetchData = useCallback(async () => {
setLoading(true);
try {
const res = await apiClient.post(
"/table-management/tables/inspection_result_mng/data",
{
page: 1,
pageSize: 500,
},
);
const rows: any[] =
res.data?.data?.data ?? res.data?.data ?? res.data?.rows ?? [];
const filtered = rows.filter((r: any) => {
const d = (r.inspection_date || r.created_date || "").slice(0, 10);
if (!d) return true;
if (dateFrom && d < dateFrom) return false;
if (dateTo && d > dateTo) return false;
return true;
});
const mapped: InspectionRow[] = filtered.map((r: any, idx: number) => {
const overall = r.overall_judgment || "";
const totalQ = Number(r.total_qty || 0);
const goodQ = Number(r.good_qty || 0);
const passRate = totalQ > 0 ? Math.round((goodQ / totalQ) * 100) : 0;
return {
id: `${r.id || idx}`,
inspectionNumber: r.inspection_number || "",
itemCode: r.item_code || "",
itemName: r.item_name || "-",
inspectionType: r.inspection_type || "",
totalQty: totalQ,
goodQty: goodQ,
badQty: Number(r.bad_qty || 0),
passRate,
overallJudgment: overall,
defectDescription: r.defect_description || "",
referenceTable: r.reference_table || "",
referenceId: r.reference_id || "",
memo: r.memo || "",
inspector: r.inspector || r.writer || "",
supplierCode: r.supplier_code || "",
supplierName: r.supplier_name || "",
isCompleted: r.is_completed || "N",
completedDate: r.completed_date || "",
createdDate: r.created_date || "",
time: r.inspection_date
? new Date(r.inspection_date).toLocaleTimeString("ko-KR", {
hour: "2-digit",
minute: "2-digit",
})
: "--:--",
date: (r.inspection_date || r.created_date || "").slice(0, 10),
fullDate: r.inspection_date
? new Date(r.inspection_date).toLocaleString("ko-KR")
: "-",
};
});
setItems(mapped);
const total = mapped.length;
const pass = mapped.filter((m) => m.overallJudgment === "합격").length;
const fail = mapped.filter((m) => m.overallJudgment === "불합격").length;
const waiting = total - pass - fail;
const passRate = total > 0 ? Math.round((pass / total) * 100) : 0;
setKpi({ total, pass, fail, waiting, passRate });
} catch {
setItems([]);
setKpi({ total: 0, pass: 0, fail: 0, waiting: 0, passRate: 0 });
} finally {
setLoading(false);
}
}, [dateFrom, dateTo]);
/* Fetch detail when selected */
useEffect(() => {
if (!selectedItem) {
setSelectedDetails([]);
return;
}
apiClient
.post("/table-management/tables/inspection_result/data", {
page: 1,
pageSize: 100,
filters: { master_id: selectedItem.id },
})
.then((res) => {
const rows: any[] = res.data?.data?.data ?? res.data?.data ?? [];
const details: DetailRow[] = rows
.filter((r: any) => r.master_id === selectedItem.id)
.map((r: any) => ({
inspectionItemName: r.inspection_item_name || "-",
inspectionStandard: r.inspection_standard || r.pass_criteria || "-",
passCriteria: r.pass_criteria || "-",
measuredValue: r.measured_value || "-",
judgment: r.judgment || "",
}));
setSelectedDetails(details);
})
.catch(() => setSelectedDetails([]));
}, [selectedItem]);
useEffect(() => {
fetchData();
}, [fetchData]);
/* Filter */
const filtered = items.filter((item) => {
if (activeTab !== "all") {
const tab = classifyTab(item.inspectionType);
if (tab !== activeTab) return false;
}
if (keyword) {
const kw = keyword.toLowerCase();
if (
!item.itemName.toLowerCase().includes(kw) &&
!item.itemCode.toLowerCase().includes(kw)
)
return false;
}
if (judgmentFilter !== "전체") {
const j = item.overallJudgment;
if (judgmentFilter === "합격" && !(j === "합격" || j === "pass"))
return false;
if (judgmentFilter === "불합격" && !(j === "불합격" || j === "fail"))
return false;
if (
judgmentFilter === "대기" &&
(j === "합격" || j === "pass" || j === "불합격" || j === "fail")
)
return false;
}
return true;
});
// 탭별 카운트
const counts = {
all: items.length,
incoming: items.filter((i) => classifyTab(i.inspectionType) === "incoming")
.length,
process: items.filter((i) => classifyTab(i.inspectionType) === "process")
.length,
outgoing: items.filter((i) => classifyTab(i.inspectionType) === "outgoing")
.length,
};
const TABS: { key: TabKey; label: string; count: number }[] = [
{ key: "all", label: "전체", count: counts.all },
{ key: "incoming", label: "입고검사", count: counts.incoming },
{ key: "process", label: "공정검사", count: counts.process },
{ key: "outgoing", label: "출하검사", count: counts.outgoing },
];
return (
<div className="flex flex-col gap-4">
{/* Back + Title */}
<div className="flex items-center gap-3">
<button
onClick={() => router.push("/pop/quality")}
className="w-10 h-10 rounded-xl bg-white border border-gray-200 flex items-center justify-center text-gray-500 hover:bg-gray-50 active:scale-95 transition-all"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.75 19.5L8.25 12l7.5-7.5"
/>
</svg>
</button>
<div>
<h1 className="text-xl sm:text-2xl font-bold text-gray-900 tracking-tight">
</h1>
<p className="text-xs text-gray-400 mt-0.5">
</p>
</div>
</div>
{/* Filters */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-3 sm:p-4">
<div className="flex items-end gap-2">
<div className="flex-1 grid grid-cols-1 sm:grid-cols-3 gap-2">
<DateRangePicker
from={dateFrom}
to={dateTo}
onChange={(f, t) => {
setDateFrom(f);
setDateTo(t);
}}
/>
<div>
<label className="text-[10px] font-semibold text-gray-400 mb-1 block">
/
</label>
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="품목명 또는 검사번호"
className="w-full px-2 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:border-violet-400"
/>
</div>
<div>
<label className="text-[10px] font-semibold text-gray-400 mb-1 block">
</label>
<select
value={judgmentFilter}
onChange={(e) => setJudgmentFilter(e.target.value)}
className="w-full px-2 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:border-violet-400 bg-white"
>
<option value="전체"></option>
<option value="합격"></option>
<option value="불합격"></option>
<option value="대기"></option>
</select>
</div>
</div>
<div className="flex gap-1.5 shrink-0 pb-[1px]">
<button
onClick={fetchData}
className="h-[42px] px-4 rounded-lg text-sm font-semibold text-white active:scale-95 transition-all"
style={{ background: "linear-gradient(135deg,#8b5cf6,#6d28d9)" }}
>
</button>
<button
onClick={() => {
setDateFrom(new Date().toISOString().slice(0, 10));
setDateTo(new Date().toISOString().slice(0, 10));
setKeyword("");
setJudgmentFilter("전체");
}}
className="h-[42px] w-[42px] rounded-lg text-sm font-semibold text-gray-500 bg-gray-100 active:scale-95 transition-all flex items-center justify-center"
>
</button>
</div>
</div>
</div>
{/* KPI */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-3 sm:p-4">
<div className="grid grid-cols-5 gap-0">
<KpiCell
icon="📋"
value={loading ? "-" : kpi.total.toLocaleString()}
label="전체"
color="text-gray-900"
/>
<KpiCell
icon="✅"
value={loading ? "-" : kpi.pass.toLocaleString()}
label="합격"
color="text-green-600"
/>
<KpiCell
icon="❌"
value={loading ? "-" : kpi.fail.toLocaleString()}
label="불합격"
color="text-red-600"
/>
<KpiCell
icon="⏳"
value={loading ? "-" : kpi.waiting.toLocaleString()}
label="대기"
color="text-amber-600"
/>
<KpiCell
icon="📊"
value={loading ? "-" : `${kpi.passRate}%`}
label="합격률"
color="text-blue-600"
/>
</div>
</div>
{/* Tabs */}
<div className="flex gap-2 overflow-x-auto">
{TABS.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`shrink-0 px-4 py-2 rounded-full text-sm font-semibold transition-all ${
activeTab === tab.key
? "text-white shadow-sm"
: "text-gray-600 bg-gray-100 hover:bg-gray-200 active:scale-95"
}`}
style={
activeTab === tab.key
? { background: "linear-gradient(135deg,#8b5cf6,#6d28d9)" }
: undefined
}
>
{tab.label} {tab.count}
</button>
))}
</div>
{/* List */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between px-1">
<span className="text-xs font-semibold text-gray-500"> </span>
<span className="text-xs text-gray-400"> {filtered.length}</span>
</div>
{loading ? (
<div className="flex flex-col gap-3 py-4">
{[1, 2, 3, 4].map((i) => (
<div
key={i}
className="bg-white rounded-2xl border border-gray-100 p-4 animate-pulse"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gray-100" />
<div className="flex-1 flex flex-col gap-2">
<div className="h-4 bg-gray-100 rounded w-2/3" />
<div className="h-3 bg-gray-50 rounded w-1/2" />
</div>
<div className="h-5 w-12 bg-gray-100 rounded-full" />
</div>
</div>
))}
</div>
) : filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
<svg
className="w-16 h-16 mb-4 opacity-20"
fill="none"
stroke="currentColor"
strokeWidth={1}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z"
/>
</svg>
<p className="text-sm font-medium text-gray-500 mb-1">
</p>
<p className="text-xs text-gray-400">
/
</p>
</div>
) : (
filtered.map((item) => {
const js = getJudgmentStyle(item.overallJudgment);
return (
<div
key={item.id}
onClick={() => setSelectedItem(item)}
className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 hover:shadow-md transition-shadow cursor-pointer active:scale-[0.98]"
>
<div className="flex items-center gap-3">
<div
className="w-10 h-10 rounded-xl flex items-center justify-center text-white text-lg shrink-0"
style={{
background: "linear-gradient(135deg,#8b5cf6,#6d28d9)",
}}
>
🔍
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-[10px] font-semibold text-violet-600">
{item.inspectionNumber}
</span>
<span
className={`text-[10px] font-bold px-1.5 py-0.5 rounded-full shrink-0 ${js.color}`}
>
{js.label}
</span>
</div>
<div className="text-sm font-bold text-gray-900 truncate mt-0.5">
{item.itemName}
{item.itemCode ? ` (${item.itemCode})` : ""}
</div>
<div className="text-xs text-gray-400 mt-0.5 truncate">
{item.inspectionType}
{item.supplierName ? ` · ${item.supplierName}` : ""}
</div>
</div>
<div className="text-right shrink-0">
<p className="text-sm font-bold text-gray-700">
<span className="text-green-600">{item.goodQty}</span>
<span className="text-gray-300 mx-0.5">/</span>
<span className="text-red-600">{item.badQty}</span>
</p>
<p className="text-[10px] text-violet-600 font-semibold mt-0.5">
{item.passRate}%
</p>
<p className="text-[10px] text-gray-400">{item.time}</p>
</div>
</div>
</div>
);
})
)}
</div>
{/* Detail Bottom Sheet */}
{selectedItem && (
<div
className="fixed inset-0 z-50"
onClick={() => setSelectedItem(null)}
>
<div className="absolute inset-0 bg-black/40" />
<div
className="absolute bottom-0 left-1/2 -translate-x-1/2 w-full max-w-lg bg-white rounded-t-3xl shadow-2xl overflow-y-auto z-10"
style={{ maxHeight: "85vh" }}
onClick={(e) => e.stopPropagation()}
>
<div className="sticky top-0 bg-white pt-3 pb-2 flex justify-center rounded-t-3xl z-10">
<div className="w-10 h-1 rounded-full bg-gray-300" />
</div>
<div className="flex items-center justify-between px-5 pb-4 border-b border-gray-100">
<h3 className="text-lg font-bold text-gray-900">
{selectedItem.inspectionType} {" "}
{selectedItem.inspectionNumber}
</h3>
<button
onClick={() => setSelectedItem(null)}
className="w-8 h-8 rounded-lg flex items-center justify-center text-gray-400 hover:bg-gray-100"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div className="px-5 py-4 space-y-5">
<div className="grid grid-cols-2 gap-4">
<DetailField
label="검사번호"
value={selectedItem.inspectionNumber}
/>
<div>
<p className="text-[11px] font-semibold text-violet-600 mb-1">
</p>
<span className="inline-block text-xs font-bold px-2.5 py-1 rounded-full bg-violet-50 text-violet-700">
{selectedItem.inspectionType}
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<DetailField label="검사일시" value={selectedItem.fullDate} />
<div>
<p className="text-[11px] font-semibold text-violet-600 mb-1">
</p>
<span
className={`inline-block text-xs font-bold px-2.5 py-1 rounded-full ${getJudgmentStyle(selectedItem.overallJudgment).color}`}
>
{getJudgmentStyle(selectedItem.overallJudgment).label}
</span>
</div>
</div>
<div className="border-t border-gray-100" />
<div>
<p className="text-[11px] font-semibold text-violet-600 mb-1">
</p>
<p className="text-base font-bold text-gray-900">
{selectedItem.itemName}
{selectedItem.itemCode ? ` (${selectedItem.itemCode})` : ""}
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<DetailField
label="거래처"
value={selectedItem.supplierName || "-"}
/>
<div>
<p className="text-[11px] font-semibold text-violet-600 mb-1">
</p>
<p className="text-lg font-bold text-violet-600">
{selectedItem.passRate}%
</p>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<p className="text-[11px] font-semibold text-violet-600 mb-1">
</p>
<p className="text-lg font-bold text-gray-900">
{selectedItem.totalQty.toLocaleString()}
</p>
</div>
<div>
<p className="text-[11px] font-semibold text-green-600 mb-1">
</p>
<p className="text-lg font-bold text-green-600">
{selectedItem.goodQty.toLocaleString()}
</p>
</div>
<div>
<p className="text-[11px] font-semibold text-red-600 mb-1">
</p>
<p className="text-lg font-bold text-red-600">
{selectedItem.badQty.toLocaleString()}
</p>
</div>
</div>
{selectedItem.defectDescription && (
<DetailField
label="불량내용"
value={selectedItem.defectDescription}
/>
)}
<DetailField
label="검사자"
value={selectedItem.inspector || "-"}
/>
{selectedItem.memo && (
<DetailField label="비고" value={selectedItem.memo} />
)}
{/* 검사 항목별 결과 (디테일) */}
{selectedDetails.length > 0 && (
<div>
<p className="text-sm font-bold text-gray-900 mb-2">
</p>
<div className="bg-gray-50 rounded-xl p-3 space-y-2">
{selectedDetails.map((d, idx) => {
const dj = getJudgmentStyle(d.judgment);
return (
<div
key={idx}
className="bg-white rounded-lg p-3 border border-gray-100"
>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-bold text-gray-900">
{d.inspectionItemName}
</span>
<span
className={`text-[10px] font-bold px-1.5 py-0.5 rounded-full ${dj.color}`}
>
{dj.label}
</span>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div>
<span className="text-gray-400"></span>
<p className="text-gray-700">
{d.inspectionStandard}
</p>
</div>
<div>
<span className="text-gray-400"></span>
<p className="text-gray-700 font-semibold">
{d.measuredValue}
</p>
</div>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
<div className="px-5 py-4 border-t border-gray-100">
<button
onClick={() => setSelectedItem(null)}
className="w-full py-3 rounded-xl text-sm font-bold text-gray-600 bg-gray-100 active:scale-95 transition-all"
>
</button>
</div>
</div>
</div>
)}
</div>
);
}
function DetailField({ label, value }: { label: string; value: string }) {
return (
<div>
<p className="text-[11px] font-semibold text-violet-600 mb-1">{label}</p>
<p className="text-sm font-semibold text-gray-900 break-all">{value}</p>
</div>
);
}
function KpiCell({
icon,
value,
label,
color,
}: {
icon: string;
value: string;
label: string;
color: string;
}) {
return (
<div className="flex flex-col items-center py-2">
<span className="text-lg mb-0.5">{icon}</span>
<span
className={`text-xl sm:text-2xl font-extrabold leading-none ${color}`}
style={{ fontVariantNumeric: "tabular-nums" }}
>
{value}
</span>
<span className="text-[10px] font-medium text-gray-400 mt-1">
{label}
</span>
</div>
);
}