0c91688896
- Introduced new pages for managing equipment inspection records and quality inspection results across COMPANY_10, COMPANY_16, COMPANY_29, and COMPANY_30. - Implemented dynamic search filters, data fetching, and Excel export functionality to enhance user experience. - Added responsive table layouts with loading states and badges for status representation, improving data visibility and interaction. These changes aim to provide a comprehensive interface for monitoring and managing inspection processes across multiple companies.
352 lines
16 KiB
TypeScript
352 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
import {
|
|
ResizableHandle,
|
|
ResizablePanel,
|
|
ResizablePanelGroup,
|
|
} from "@/components/ui/resizable";
|
|
import { RefreshCw, Loader2, Inbox, ClipboardCheck, Download } from "lucide-react";
|
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
|
import { cn } from "@/lib/utils";
|
|
import { apiClient } from "@/lib/api/client";
|
|
import { exportToExcel } from "@/lib/utils/excelExport";
|
|
|
|
// ─── 타입 ─────────────────────────────────────────────────
|
|
|
|
interface InspectionMng {
|
|
id: string;
|
|
inspection_number: string;
|
|
item_code: string;
|
|
item_name: string;
|
|
inspection_type: string;
|
|
total_qty: number;
|
|
good_qty: number;
|
|
bad_qty: number;
|
|
overall_judgment: string;
|
|
inspector: string;
|
|
inspection_date: string;
|
|
is_completed: string;
|
|
defect_description: string;
|
|
memo: string;
|
|
supplier_name: string;
|
|
supplier_code: string;
|
|
created_date: string;
|
|
}
|
|
|
|
interface InspectionDetail {
|
|
id: string;
|
|
master_id: string;
|
|
inspection_item_name: string;
|
|
inspection_standard: string;
|
|
pass_criteria: string;
|
|
measured_value: string;
|
|
judgment: string;
|
|
is_required: string;
|
|
memo: string;
|
|
item_code: string;
|
|
item_name: string;
|
|
inspection_type: string;
|
|
}
|
|
|
|
const MASTER_TABLE = "inspection_result_mng";
|
|
const DETAIL_TABLE = "inspection_result";
|
|
|
|
// ─── 메인 컴포넌트 ───────────────────────────────────────
|
|
|
|
export default function InspectionResultPage() {
|
|
const [masterData, setMasterData] = useState<InspectionMng[]>([]);
|
|
const [detailData, setDetailData] = useState<InspectionDetail[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [detailLoading, setDetailLoading] = useState(false);
|
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
const [filterValues, setFilterValues] = useState<FilterValue[]>([]);
|
|
|
|
// ─── 마스터 데이터 조회 ─────────────────────────────────
|
|
|
|
const fetchMaster = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const search: Record<string, any> = {};
|
|
filterValues.forEach((f) => {
|
|
if (f.value) search[f.column] = f.value;
|
|
});
|
|
|
|
const res = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
|
|
page: 1,
|
|
size: 1000,
|
|
autoFilter: true,
|
|
search,
|
|
});
|
|
const rows: InspectionMng[] = res.data?.data?.data ?? res.data?.data?.rows ?? [];
|
|
setMasterData(rows);
|
|
} catch (err) {
|
|
console.error("검사결과 조회 실패:", err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [filterValues]);
|
|
|
|
useEffect(() => {
|
|
fetchMaster();
|
|
}, [fetchMaster]);
|
|
|
|
// ─── 디테일 데이터 조회 ─────────────────────────────────
|
|
|
|
const fetchDetail = useCallback(async (masterId: string) => {
|
|
setDetailLoading(true);
|
|
try {
|
|
const res = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
|
|
page: 1,
|
|
size: 500,
|
|
autoFilter: true,
|
|
search: { master_id: masterId },
|
|
});
|
|
const rows: InspectionDetail[] = res.data?.data?.data ?? res.data?.data?.rows ?? [];
|
|
setDetailData(rows);
|
|
} catch (err) {
|
|
console.error("검사상세 조회 실패:", err);
|
|
setDetailData([]);
|
|
} finally {
|
|
setDetailLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (selectedId) fetchDetail(selectedId);
|
|
else setDetailData([]);
|
|
}, [selectedId, fetchDetail]);
|
|
|
|
// ─── 선택된 마스터 ──────────────────────────────────────
|
|
|
|
const selectedMaster = useMemo(
|
|
() => masterData.find((m) => m.id === selectedId),
|
|
[masterData, selectedId],
|
|
);
|
|
|
|
// ─── 엑셀 다운로드 ─────────────────────────────────────
|
|
|
|
const handleExcel = async () => {
|
|
await exportToExcel(masterData, "검사결과관리.xlsx", "검사결과");
|
|
};
|
|
|
|
// ─── 판정 배지 ──────────────────────────────────────────
|
|
|
|
const judgmentBadge = (v: string) => {
|
|
if (!v) return null;
|
|
const lower = v.toLowerCase();
|
|
if (["합격", "pass", "적합"].includes(lower))
|
|
return <Badge className="bg-emerald-500/10 text-emerald-600 border-emerald-200">{v}</Badge>;
|
|
if (["불합격", "fail", "부적합"].includes(lower))
|
|
return <Badge className="bg-red-500/10 text-red-600 border-red-200">{v}</Badge>;
|
|
return <Badge variant="outline">{v}</Badge>;
|
|
};
|
|
|
|
// ─── 날짜 포맷 ──────────────────────────────────────────
|
|
|
|
const fmtDate = (d: string) => {
|
|
if (!d) return "-";
|
|
try {
|
|
return new Date(d).toLocaleDateString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit" });
|
|
} catch {
|
|
return d;
|
|
}
|
|
};
|
|
|
|
// ─── 렌더링 ─────────────────────────────────────────────
|
|
|
|
return (
|
|
<div className="flex h-full flex-col">
|
|
{/* 검색 필터 */}
|
|
<div className="shrink-0">
|
|
<DynamicSearchFilter
|
|
tableName={MASTER_TABLE}
|
|
filterId="inspection-result-mng"
|
|
onFilterChange={(filters) => {
|
|
setFilterValues(filters);
|
|
}}
|
|
dataCount={masterData.length}
|
|
/>
|
|
</div>
|
|
|
|
{/* 헤더 */}
|
|
<div className="flex items-center justify-between border-b px-4 py-2.5 shrink-0">
|
|
<div className="flex items-center gap-2">
|
|
<ClipboardCheck className="h-5 w-5 text-primary" />
|
|
<h2 className="text-base font-bold">검사관리</h2>
|
|
<Badge variant="secondary">{masterData.length}건</Badge>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button variant="outline" size="sm" onClick={handleExcel}>
|
|
<Download className="h-4 w-4 mr-1" />
|
|
엑셀 다운로드
|
|
</Button>
|
|
<Button variant="outline" size="sm" onClick={fetchMaster} disabled={loading}>
|
|
<RefreshCw className={cn("h-4 w-4 mr-1", loading && "animate-spin")} />
|
|
새로고침
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 분할 패널 */}
|
|
<ResizablePanelGroup direction="horizontal" className="flex-1 min-h-0">
|
|
{/* 좌측: 마스터 테이블 */}
|
|
<ResizablePanel defaultSize={55} minSize={30}>
|
|
<div className="h-full overflow-auto">
|
|
{loading && masterData.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
|
<Loader2 className="h-8 w-8 animate-spin mb-3" />
|
|
<span className="text-sm">데이터를 불러오는 중...</span>
|
|
</div>
|
|
) : masterData.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
|
<Inbox className="h-10 w-10 mb-3" />
|
|
<span className="text-sm">검사 데이터가 없습니다</span>
|
|
</div>
|
|
) : (
|
|
<Table noWrapper className="min-w-max">
|
|
<TableHeader className="sticky top-0 z-10 bg-muted shadow-[0_1px_0_0_hsl(var(--border))]">
|
|
<TableRow className="hover:bg-muted">
|
|
<TableHead className="w-10 text-center">#</TableHead>
|
|
<TableHead className="min-w-[120px]">검사번호</TableHead>
|
|
<TableHead className="min-w-[80px]">검사유형</TableHead>
|
|
<TableHead className="min-w-[120px]">품목코드</TableHead>
|
|
<TableHead className="min-w-[140px]">품목명</TableHead>
|
|
<TableHead className="min-w-[80px] text-right">검사수량</TableHead>
|
|
<TableHead className="min-w-[80px] text-right">양품</TableHead>
|
|
<TableHead className="min-w-[80px] text-right">불량</TableHead>
|
|
<TableHead className="min-w-[70px] text-center">판정</TableHead>
|
|
<TableHead className="min-w-[80px]">검사자</TableHead>
|
|
<TableHead className="min-w-[100px]">검사일자</TableHead>
|
|
<TableHead className="min-w-[80px] text-center">완료</TableHead>
|
|
<TableHead className="min-w-[120px]">거래처</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{masterData.map((row, idx) => (
|
|
<TableRow
|
|
key={row.id}
|
|
className={cn(
|
|
"cursor-pointer",
|
|
selectedId === row.id && "bg-primary/5",
|
|
)}
|
|
onClick={() => setSelectedId(row.id)}
|
|
>
|
|
<TableCell className="text-center text-muted-foreground text-xs">{idx + 1}</TableCell>
|
|
<TableCell className="font-mono text-sm">{row.inspection_number || "-"}</TableCell>
|
|
<TableCell>
|
|
<Badge variant="outline" className="text-xs">{row.inspection_type || "-"}</Badge>
|
|
</TableCell>
|
|
<TableCell className="font-mono text-sm">{row.item_code || "-"}</TableCell>
|
|
<TableCell className="text-sm">{row.item_name || "-"}</TableCell>
|
|
<TableCell className="text-right text-sm">{row.total_qty ?? "-"}</TableCell>
|
|
<TableCell className="text-right text-sm text-emerald-600">{row.good_qty ?? "-"}</TableCell>
|
|
<TableCell className="text-right text-sm text-red-600">{row.bad_qty ?? "-"}</TableCell>
|
|
<TableCell className="text-center">{judgmentBadge(row.overall_judgment)}</TableCell>
|
|
<TableCell className="text-sm">{row.inspector || "-"}</TableCell>
|
|
<TableCell className="text-sm text-muted-foreground">{fmtDate(row.inspection_date)}</TableCell>
|
|
<TableCell className="text-center">
|
|
{row.is_completed === "Y" ? (
|
|
<Badge className="bg-emerald-500/10 text-emerald-600 border-emerald-200 text-xs">완료</Badge>
|
|
) : (
|
|
<Badge variant="outline" className="text-xs">진행중</Badge>
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="text-sm">{row.supplier_name || "-"}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</div>
|
|
</ResizablePanel>
|
|
|
|
<ResizableHandle withHandle />
|
|
|
|
{/* 우측: 디테일 */}
|
|
<ResizablePanel defaultSize={45} minSize={25}>
|
|
{!selectedId ? (
|
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
|
|
<Inbox className="w-10 h-10 text-muted-foreground/40" />
|
|
<p className="text-sm">좌측에서 검사를 선택해주세요</p>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col h-full">
|
|
{/* 상세 헤더 */}
|
|
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/50 shrink-0">
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="text-[13px] font-bold">검사 상세</h3>
|
|
{selectedMaster && (
|
|
<Badge variant="outline" className="text-xs">
|
|
{selectedMaster.inspection_number}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
{selectedMaster && (
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<span>{selectedMaster.item_name}</span>
|
|
<span>·</span>
|
|
{judgmentBadge(selectedMaster.overall_judgment)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 상세 테이블 */}
|
|
<div className="flex-1 overflow-auto">
|
|
{detailLoading ? (
|
|
<div className="flex items-center justify-center py-16 text-muted-foreground">
|
|
<Loader2 className="h-6 w-6 animate-spin" />
|
|
</div>
|
|
) : detailData.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
|
<Inbox className="h-8 w-8 mb-2 opacity-40" />
|
|
<p className="text-sm">검사 항목이 없습니다</p>
|
|
</div>
|
|
) : (
|
|
<Table noWrapper>
|
|
<TableHeader className="sticky top-0 z-10 bg-muted shadow-[0_1px_0_0_hsl(var(--border))]">
|
|
<TableRow className="hover:bg-muted">
|
|
<TableHead className="w-10 text-center">#</TableHead>
|
|
<TableHead className="min-w-[140px]">검사항목</TableHead>
|
|
<TableHead className="min-w-[120px]">검사기준</TableHead>
|
|
<TableHead className="min-w-[100px]">합격기준</TableHead>
|
|
<TableHead className="min-w-[90px]">측정값</TableHead>
|
|
<TableHead className="min-w-[70px] text-center">판정</TableHead>
|
|
<TableHead className="min-w-[60px] text-center">필수</TableHead>
|
|
<TableHead className="min-w-[120px]">비고</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{detailData.map((d, idx) => (
|
|
<TableRow key={d.id}>
|
|
<TableCell className="text-center text-muted-foreground text-xs">{idx + 1}</TableCell>
|
|
<TableCell className="text-sm font-medium">{d.inspection_item_name || "-"}</TableCell>
|
|
<TableCell className="text-sm">{d.inspection_standard || "-"}</TableCell>
|
|
<TableCell className="text-sm">{d.pass_criteria || "-"}</TableCell>
|
|
<TableCell className="text-sm font-mono">{d.measured_value || "-"}</TableCell>
|
|
<TableCell className="text-center">{judgmentBadge(d.judgment)}</TableCell>
|
|
<TableCell className="text-center">
|
|
{d.is_required === "Y" ? (
|
|
<Badge className="bg-amber-500/10 text-amber-600 border-amber-200 text-xs">필수</Badge>
|
|
) : (
|
|
<span className="text-xs text-muted-foreground">-</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="text-sm text-muted-foreground">{d.memo || "-"}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</ResizablePanel>
|
|
</ResizablePanelGroup>
|
|
</div>
|
|
);
|
|
}
|