Merge branch 'jskim-node' of https://g.wace.me/jskim/vexplor_dev into jskim-node

This commit is contained in:
DDD1542
2026-04-10 10:51:06 +09:00
31 changed files with 6116 additions and 152 deletions
@@ -0,0 +1,351 @@
"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, Wrench, 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 InspectionRecord {
id: string;
equipment_code: string;
inspection_item_objid: string;
inspection_date: string;
status: string;
inspector: string;
remark: string;
created_date: string;
}
interface InspectionItem {
id: string;
equipment_code: string;
inspection_item: string;
inspection_cycle: string;
inspection_content: string;
inspection_method: string;
lower_limit: string;
upper_limit: string;
unit: string;
}
interface EquipmentInfo {
id: string;
equipment_code: string;
equipment_name: string;
}
const RECORD_TABLE = "equipment_inspection_record";
// ─── 메인 컴포넌트 ───────────────────────────────────────
export default function EquipmentInspectionRecordPage() {
const [records, setRecords] = useState<InspectionRecord[]>([]);
const [inspectionItems, setInspectionItems] = useState<Map<string, InspectionItem>>(new Map());
const [equipments, setEquipments] = useState<Map<string, EquipmentInfo>>(new Map());
const [categoryMap, setCategoryMap] = useState<Record<string, Record<string, string>>>({});
const [loading, setLoading] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [filterValues, setFilterValues] = useState<FilterValue[]>([]);
// ─── 데이터 조회 ────────────────────────────────────────
const fetchData = useCallback(async () => {
setLoading(true);
try {
const search: Record<string, any> = {};
filterValues.forEach((f) => {
if (f.value) search[f.column] = f.value;
});
const [recordRes, itemRes, equipRes] = await Promise.all([
apiClient.post(`/table-management/tables/${RECORD_TABLE}/data`, {
page: 1,
size: 1000,
autoFilter: true,
search,
}),
apiClient.post(`/table-management/tables/equipment_inspection_item/data`, {
page: 1,
size: 1000,
autoFilter: true,
}).catch(() => ({ data: { data: { data: [] } } })),
apiClient.post(`/table-management/tables/equipment_mng/data`, {
page: 1,
size: 500,
autoFilter: true,
}).catch(() => ({ data: { data: { data: [] } } })),
]);
const rRows: InspectionRecord[] = recordRes.data?.data?.data ?? recordRes.data?.data?.rows ?? [];
setRecords(rRows);
const iRows: InspectionItem[] = itemRes.data?.data?.data ?? itemRes.data?.data?.rows ?? [];
const iMap = new Map<string, InspectionItem>();
iRows.forEach((i) => iMap.set(i.id, i));
setInspectionItems(iMap);
const eRows: EquipmentInfo[] = equipRes.data?.data?.data ?? equipRes.data?.data?.rows ?? [];
const eMap = new Map<string, EquipmentInfo>();
eRows.forEach((e) => {
eMap.set(e.equipment_code, e);
eMap.set(e.id, e);
});
setEquipments(eMap);
// 카테고리 코드→라벨 매핑 (점검주기, 점검방법)
try {
const catCols = ["inspection_cycle", "inspection_method"];
const catResults = await Promise.all(
catCols.map((col) =>
apiClient.get(`/table-categories/equipment_inspection_item/${col}/values`).catch(() => ({ data: [] })),
),
);
const cMap: Record<string, Record<string, string>> = {};
catCols.forEach((col, idx) => {
const vals: { code: string; label: string }[] = catResults[idx].data?.data ?? catResults[idx].data ?? [];
cMap[col] = {};
vals.forEach((v: any) => { cMap[col][v.valueCode || v.code] = v.valueLabel || v.label; });
});
setCategoryMap(cMap);
} catch {
// 카테고리 조회 실패 무시
}
} catch (err) {
console.error("점검기록 조회 실패:", err);
} finally {
setLoading(false);
}
}, [filterValues]);
useEffect(() => {
fetchData();
}, [fetchData]);
// ─── 선택된 레코드 ─────────────────────────────────────
const selectedRecord = useMemo(() => records.find((r) => r.id === selectedId), [records, selectedId]);
const selectedItem = useMemo(
() => (selectedRecord ? inspectionItems.get(selectedRecord.inspection_item_objid) : undefined),
[selectedRecord, inspectionItems],
);
// ─── 설비명 조회 ───────────────────────────────────────
const getEquipName = (code: string) => equipments.get(code)?.equipment_name || code || "-";
// ─── 카테고리 코드→라벨 변환 ──────────────────────────
const resolveCategory = (col: string, code: string) => {
if (!code) return "-";
return categoryMap[col]?.[code] || code;
};
// ─── 엑셀 다운로드 ─────────────────────────────────────
const handleExcel = async () => {
const data = records.map((r) => ({
설비코드: r.equipment_code,
설비명: getEquipName(r.equipment_code),
점검항목: inspectionItems.get(r.inspection_item_objid)?.inspection_item || "-",
점검일자: fmtDate(r.inspection_date),
상태: r.status,
점검자: r.inspector,
비고: r.remark,
}));
await exportToExcel(data, "설비점검기록.xlsx", "점검기록");
};
// ─── 날짜/상태 포맷 ────────────────────────────────────
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;
}
};
const statusBadge = (v: string) => {
if (!v) return <Badge variant="outline" className="text-xs">-</Badge>;
const lower = v.toLowerCase();
if (["완료", "pass", "정상", "합격", "completed"].includes(lower))
return <Badge className="bg-emerald-500/10 text-emerald-600 border-emerald-200 text-xs">{v}</Badge>;
if (["이상", "fail", "불합격", "비정상"].includes(lower))
return <Badge className="bg-red-500/10 text-red-600 border-red-200 text-xs">{v}</Badge>;
if (["점검중", "진행", "in_progress"].includes(lower))
return <Badge className="bg-blue-500/10 text-blue-600 border-blue-200 text-xs">{v}</Badge>;
return <Badge variant="outline" className="text-xs">{v}</Badge>;
};
// ─── 렌더링 ─────────────────────────────────────────────
return (
<div className="flex h-full flex-col">
{/* 검색 필터 */}
<div className="shrink-0">
<DynamicSearchFilter
tableName={RECORD_TABLE}
filterId="equip-inspection-record"
onFilterChange={(filters) => setFilterValues(filters)}
dataCount={records.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">
<Wrench className="h-5 w-5 text-primary" />
<h2 className="text-base font-bold"></h2>
<Badge variant="secondary">{records.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={fetchData} 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={60} minSize={35}>
<div className="h-full overflow-auto">
{loading && records.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>
) : records.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-[100px]"></TableHead>
<TableHead className="min-w-[120px]"></TableHead>
<TableHead className="min-w-[140px]"></TableHead>
<TableHead className="min-w-[100px]"></TableHead>
<TableHead className="min-w-[70px] text-center"></TableHead>
<TableHead className="min-w-[80px]"></TableHead>
<TableHead className="min-w-[150px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{records.map((row, idx) => {
const item = inspectionItems.get(row.inspection_item_objid);
return (
<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.equipment_code || "-"}</TableCell>
<TableCell className="text-sm">{getEquipName(row.equipment_code)}</TableCell>
<TableCell className="text-sm">{item?.inspection_item || "-"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{fmtDate(row.inspection_date)}</TableCell>
<TableCell className="text-center">{statusBadge(row.status)}</TableCell>
<TableCell className="text-sm">{row.inspector || "-"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{row.remark || "-"}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 점검항목 상세 */}
<ResizablePanel defaultSize={40} minSize={25}>
{!selectedId || !selectedRecord ? (
<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 overflow-auto">
{/* 점검 기록 요약 */}
<div className="px-4 py-3 border-b bg-muted/50 shrink-0">
<h3 className="text-[13px] font-bold mb-2"> </h3>
<div className="grid grid-cols-2 gap-2 text-sm">
<DetailRow label="설비" value={getEquipName(selectedRecord.equipment_code)} />
<DetailRow label="설비코드" value={selectedRecord.equipment_code} />
<DetailRow label="점검일자" value={fmtDate(selectedRecord.inspection_date)} />
<DetailRow label="점검자" value={selectedRecord.inspector || "-"} />
<DetailRow label="상태" value={selectedRecord.status || "-"} badge={statusBadge(selectedRecord.status)} />
<DetailRow label="비고" value={selectedRecord.remark || "-"} />
</div>
</div>
{/* 점검 항목 상세 */}
{selectedItem ? (
<div className="px-4 py-3">
<h3 className="text-[13px] font-bold mb-2"> </h3>
<div className="grid grid-cols-2 gap-2 text-sm">
<DetailRow label="점검항목" value={selectedItem.inspection_item || "-"} />
<DetailRow label="점검주기" value={resolveCategory("inspection_cycle", selectedItem.inspection_cycle)} />
<DetailRow label="점검내용" value={selectedItem.inspection_content || "-"} span2 />
<DetailRow label="점검방법" value={resolveCategory("inspection_method", selectedItem.inspection_method)} span2 />
<DetailRow label="하한치" value={selectedItem.lower_limit || "-"} />
<DetailRow label="상한치" value={selectedItem.upper_limit || "-"} />
<DetailRow label="단위" value={selectedItem.unit || "-"} />
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<p className="text-sm"> </p>
</div>
)}
</div>
)}
</ResizablePanel>
</ResizablePanelGroup>
</div>
);
}
// ─── 상세 행 ──────────────────────────────────────────────
function DetailRow({
label,
value,
badge,
span2,
}: {
label: string;
value: string;
badge?: React.ReactNode;
span2?: boolean;
}) {
return (
<div className={cn("flex flex-col gap-0.5", span2 && "col-span-2")}>
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">{label}</span>
{badge ? <div className="w-fit">{badge}</div> : <span className="text-xs">{value}</span>}
</div>
);
}
@@ -0,0 +1,351 @@
"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>
);
}
@@ -0,0 +1,351 @@
"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, Wrench, 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 InspectionRecord {
id: string;
equipment_code: string;
inspection_item_objid: string;
inspection_date: string;
status: string;
inspector: string;
remark: string;
created_date: string;
}
interface InspectionItem {
id: string;
equipment_code: string;
inspection_item: string;
inspection_cycle: string;
inspection_content: string;
inspection_method: string;
lower_limit: string;
upper_limit: string;
unit: string;
}
interface EquipmentInfo {
id: string;
equipment_code: string;
equipment_name: string;
}
const RECORD_TABLE = "equipment_inspection_record";
// ─── 메인 컴포넌트 ───────────────────────────────────────
export default function EquipmentInspectionRecordPage() {
const [records, setRecords] = useState<InspectionRecord[]>([]);
const [inspectionItems, setInspectionItems] = useState<Map<string, InspectionItem>>(new Map());
const [equipments, setEquipments] = useState<Map<string, EquipmentInfo>>(new Map());
const [categoryMap, setCategoryMap] = useState<Record<string, Record<string, string>>>({});
const [loading, setLoading] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [filterValues, setFilterValues] = useState<FilterValue[]>([]);
// ─── 데이터 조회 ────────────────────────────────────────
const fetchData = useCallback(async () => {
setLoading(true);
try {
const search: Record<string, any> = {};
filterValues.forEach((f) => {
if (f.value) search[f.column] = f.value;
});
const [recordRes, itemRes, equipRes] = await Promise.all([
apiClient.post(`/table-management/tables/${RECORD_TABLE}/data`, {
page: 1,
size: 1000,
autoFilter: true,
search,
}),
apiClient.post(`/table-management/tables/equipment_inspection_item/data`, {
page: 1,
size: 1000,
autoFilter: true,
}).catch(() => ({ data: { data: { data: [] } } })),
apiClient.post(`/table-management/tables/equipment_mng/data`, {
page: 1,
size: 500,
autoFilter: true,
}).catch(() => ({ data: { data: { data: [] } } })),
]);
const rRows: InspectionRecord[] = recordRes.data?.data?.data ?? recordRes.data?.data?.rows ?? [];
setRecords(rRows);
const iRows: InspectionItem[] = itemRes.data?.data?.data ?? itemRes.data?.data?.rows ?? [];
const iMap = new Map<string, InspectionItem>();
iRows.forEach((i) => iMap.set(i.id, i));
setInspectionItems(iMap);
const eRows: EquipmentInfo[] = equipRes.data?.data?.data ?? equipRes.data?.data?.rows ?? [];
const eMap = new Map<string, EquipmentInfo>();
eRows.forEach((e) => {
eMap.set(e.equipment_code, e);
eMap.set(e.id, e);
});
setEquipments(eMap);
// 카테고리 코드→라벨 매핑 (점검주기, 점검방법)
try {
const catCols = ["inspection_cycle", "inspection_method"];
const catResults = await Promise.all(
catCols.map((col) =>
apiClient.get(`/table-categories/equipment_inspection_item/${col}/values`).catch(() => ({ data: [] })),
),
);
const cMap: Record<string, Record<string, string>> = {};
catCols.forEach((col, idx) => {
const vals: { code: string; label: string }[] = catResults[idx].data?.data ?? catResults[idx].data ?? [];
cMap[col] = {};
vals.forEach((v: any) => { cMap[col][v.valueCode || v.code] = v.valueLabel || v.label; });
});
setCategoryMap(cMap);
} catch {
// 카테고리 조회 실패 무시
}
} catch (err) {
console.error("점검기록 조회 실패:", err);
} finally {
setLoading(false);
}
}, [filterValues]);
useEffect(() => {
fetchData();
}, [fetchData]);
// ─── 선택된 레코드 ─────────────────────────────────────
const selectedRecord = useMemo(() => records.find((r) => r.id === selectedId), [records, selectedId]);
const selectedItem = useMemo(
() => (selectedRecord ? inspectionItems.get(selectedRecord.inspection_item_objid) : undefined),
[selectedRecord, inspectionItems],
);
// ─── 설비명 조회 ───────────────────────────────────────
const getEquipName = (code: string) => equipments.get(code)?.equipment_name || code || "-";
// ─── 카테고리 코드→라벨 변환 ──────────────────────────
const resolveCategory = (col: string, code: string) => {
if (!code) return "-";
return categoryMap[col]?.[code] || code;
};
// ─── 엑셀 다운로드 ─────────────────────────────────────
const handleExcel = async () => {
const data = records.map((r) => ({
설비코드: r.equipment_code,
설비명: getEquipName(r.equipment_code),
점검항목: inspectionItems.get(r.inspection_item_objid)?.inspection_item || "-",
점검일자: fmtDate(r.inspection_date),
상태: r.status,
점검자: r.inspector,
비고: r.remark,
}));
await exportToExcel(data, "설비점검기록.xlsx", "점검기록");
};
// ─── 날짜/상태 포맷 ────────────────────────────────────
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;
}
};
const statusBadge = (v: string) => {
if (!v) return <Badge variant="outline" className="text-xs">-</Badge>;
const lower = v.toLowerCase();
if (["완료", "pass", "정상", "합격", "completed"].includes(lower))
return <Badge className="bg-emerald-500/10 text-emerald-600 border-emerald-200 text-xs">{v}</Badge>;
if (["이상", "fail", "불합격", "비정상"].includes(lower))
return <Badge className="bg-red-500/10 text-red-600 border-red-200 text-xs">{v}</Badge>;
if (["점검중", "진행", "in_progress"].includes(lower))
return <Badge className="bg-blue-500/10 text-blue-600 border-blue-200 text-xs">{v}</Badge>;
return <Badge variant="outline" className="text-xs">{v}</Badge>;
};
// ─── 렌더링 ─────────────────────────────────────────────
return (
<div className="flex h-full flex-col">
{/* 검색 필터 */}
<div className="shrink-0">
<DynamicSearchFilter
tableName={RECORD_TABLE}
filterId="equip-inspection-record"
onFilterChange={(filters) => setFilterValues(filters)}
dataCount={records.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">
<Wrench className="h-5 w-5 text-primary" />
<h2 className="text-base font-bold"></h2>
<Badge variant="secondary">{records.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={fetchData} 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={60} minSize={35}>
<div className="h-full overflow-auto">
{loading && records.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>
) : records.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-[100px]"></TableHead>
<TableHead className="min-w-[120px]"></TableHead>
<TableHead className="min-w-[140px]"></TableHead>
<TableHead className="min-w-[100px]"></TableHead>
<TableHead className="min-w-[70px] text-center"></TableHead>
<TableHead className="min-w-[80px]"></TableHead>
<TableHead className="min-w-[150px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{records.map((row, idx) => {
const item = inspectionItems.get(row.inspection_item_objid);
return (
<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.equipment_code || "-"}</TableCell>
<TableCell className="text-sm">{getEquipName(row.equipment_code)}</TableCell>
<TableCell className="text-sm">{item?.inspection_item || "-"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{fmtDate(row.inspection_date)}</TableCell>
<TableCell className="text-center">{statusBadge(row.status)}</TableCell>
<TableCell className="text-sm">{row.inspector || "-"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{row.remark || "-"}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 점검항목 상세 */}
<ResizablePanel defaultSize={40} minSize={25}>
{!selectedId || !selectedRecord ? (
<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 overflow-auto">
{/* 점검 기록 요약 */}
<div className="px-4 py-3 border-b bg-muted/50 shrink-0">
<h3 className="text-[13px] font-bold mb-2"> </h3>
<div className="grid grid-cols-2 gap-2 text-sm">
<DetailRow label="설비" value={getEquipName(selectedRecord.equipment_code)} />
<DetailRow label="설비코드" value={selectedRecord.equipment_code} />
<DetailRow label="점검일자" value={fmtDate(selectedRecord.inspection_date)} />
<DetailRow label="점검자" value={selectedRecord.inspector || "-"} />
<DetailRow label="상태" value={selectedRecord.status || "-"} badge={statusBadge(selectedRecord.status)} />
<DetailRow label="비고" value={selectedRecord.remark || "-"} />
</div>
</div>
{/* 점검 항목 상세 */}
{selectedItem ? (
<div className="px-4 py-3">
<h3 className="text-[13px] font-bold mb-2"> </h3>
<div className="grid grid-cols-2 gap-2 text-sm">
<DetailRow label="점검항목" value={selectedItem.inspection_item || "-"} />
<DetailRow label="점검주기" value={resolveCategory("inspection_cycle", selectedItem.inspection_cycle)} />
<DetailRow label="점검내용" value={selectedItem.inspection_content || "-"} span2 />
<DetailRow label="점검방법" value={resolveCategory("inspection_method", selectedItem.inspection_method)} span2 />
<DetailRow label="하한치" value={selectedItem.lower_limit || "-"} />
<DetailRow label="상한치" value={selectedItem.upper_limit || "-"} />
<DetailRow label="단위" value={selectedItem.unit || "-"} />
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<p className="text-sm"> </p>
</div>
)}
</div>
)}
</ResizablePanel>
</ResizablePanelGroup>
</div>
);
}
// ─── 상세 행 ──────────────────────────────────────────────
function DetailRow({
label,
value,
badge,
span2,
}: {
label: string;
value: string;
badge?: React.ReactNode;
span2?: boolean;
}) {
return (
<div className={cn("flex flex-col gap-0.5", span2 && "col-span-2")}>
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">{label}</span>
{badge ? <div className="w-fit">{badge}</div> : <span className="text-xs">{value}</span>}
</div>
);
}
@@ -0,0 +1,351 @@
"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>
);
}
@@ -0,0 +1,351 @@
"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, Wrench, 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 InspectionRecord {
id: string;
equipment_code: string;
inspection_item_objid: string;
inspection_date: string;
status: string;
inspector: string;
remark: string;
created_date: string;
}
interface InspectionItem {
id: string;
equipment_code: string;
inspection_item: string;
inspection_cycle: string;
inspection_content: string;
inspection_method: string;
lower_limit: string;
upper_limit: string;
unit: string;
}
interface EquipmentInfo {
id: string;
equipment_code: string;
equipment_name: string;
}
const RECORD_TABLE = "equipment_inspection_record";
// ─── 메인 컴포넌트 ───────────────────────────────────────
export default function EquipmentInspectionRecordPage() {
const [records, setRecords] = useState<InspectionRecord[]>([]);
const [inspectionItems, setInspectionItems] = useState<Map<string, InspectionItem>>(new Map());
const [equipments, setEquipments] = useState<Map<string, EquipmentInfo>>(new Map());
const [categoryMap, setCategoryMap] = useState<Record<string, Record<string, string>>>({});
const [loading, setLoading] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [filterValues, setFilterValues] = useState<FilterValue[]>([]);
// ─── 데이터 조회 ────────────────────────────────────────
const fetchData = useCallback(async () => {
setLoading(true);
try {
const search: Record<string, any> = {};
filterValues.forEach((f) => {
if (f.value) search[f.column] = f.value;
});
const [recordRes, itemRes, equipRes] = await Promise.all([
apiClient.post(`/table-management/tables/${RECORD_TABLE}/data`, {
page: 1,
size: 1000,
autoFilter: true,
search,
}),
apiClient.post(`/table-management/tables/equipment_inspection_item/data`, {
page: 1,
size: 1000,
autoFilter: true,
}).catch(() => ({ data: { data: { data: [] } } })),
apiClient.post(`/table-management/tables/equipment_mng/data`, {
page: 1,
size: 500,
autoFilter: true,
}).catch(() => ({ data: { data: { data: [] } } })),
]);
const rRows: InspectionRecord[] = recordRes.data?.data?.data ?? recordRes.data?.data?.rows ?? [];
setRecords(rRows);
const iRows: InspectionItem[] = itemRes.data?.data?.data ?? itemRes.data?.data?.rows ?? [];
const iMap = new Map<string, InspectionItem>();
iRows.forEach((i) => iMap.set(i.id, i));
setInspectionItems(iMap);
const eRows: EquipmentInfo[] = equipRes.data?.data?.data ?? equipRes.data?.data?.rows ?? [];
const eMap = new Map<string, EquipmentInfo>();
eRows.forEach((e) => {
eMap.set(e.equipment_code, e);
eMap.set(e.id, e);
});
setEquipments(eMap);
// 카테고리 코드→라벨 매핑 (점검주기, 점검방법)
try {
const catCols = ["inspection_cycle", "inspection_method"];
const catResults = await Promise.all(
catCols.map((col) =>
apiClient.get(`/table-categories/equipment_inspection_item/${col}/values`).catch(() => ({ data: [] })),
),
);
const cMap: Record<string, Record<string, string>> = {};
catCols.forEach((col, idx) => {
const vals: { code: string; label: string }[] = catResults[idx].data?.data ?? catResults[idx].data ?? [];
cMap[col] = {};
vals.forEach((v: any) => { cMap[col][v.valueCode || v.code] = v.valueLabel || v.label; });
});
setCategoryMap(cMap);
} catch {
// 카테고리 조회 실패 무시
}
} catch (err) {
console.error("점검기록 조회 실패:", err);
} finally {
setLoading(false);
}
}, [filterValues]);
useEffect(() => {
fetchData();
}, [fetchData]);
// ─── 선택된 레코드 ─────────────────────────────────────
const selectedRecord = useMemo(() => records.find((r) => r.id === selectedId), [records, selectedId]);
const selectedItem = useMemo(
() => (selectedRecord ? inspectionItems.get(selectedRecord.inspection_item_objid) : undefined),
[selectedRecord, inspectionItems],
);
// ─── 설비명 조회 ───────────────────────────────────────
const getEquipName = (code: string) => equipments.get(code)?.equipment_name || code || "-";
// ─── 카테고리 코드→라벨 변환 ──────────────────────────
const resolveCategory = (col: string, code: string) => {
if (!code) return "-";
return categoryMap[col]?.[code] || code;
};
// ─── 엑셀 다운로드 ─────────────────────────────────────
const handleExcel = async () => {
const data = records.map((r) => ({
설비코드: r.equipment_code,
설비명: getEquipName(r.equipment_code),
점검항목: inspectionItems.get(r.inspection_item_objid)?.inspection_item || "-",
점검일자: fmtDate(r.inspection_date),
상태: r.status,
점검자: r.inspector,
비고: r.remark,
}));
await exportToExcel(data, "설비점검기록.xlsx", "점검기록");
};
// ─── 날짜/상태 포맷 ────────────────────────────────────
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;
}
};
const statusBadge = (v: string) => {
if (!v) return <Badge variant="outline" className="text-xs">-</Badge>;
const lower = v.toLowerCase();
if (["완료", "pass", "정상", "합격", "completed"].includes(lower))
return <Badge className="bg-emerald-500/10 text-emerald-600 border-emerald-200 text-xs">{v}</Badge>;
if (["이상", "fail", "불합격", "비정상"].includes(lower))
return <Badge className="bg-red-500/10 text-red-600 border-red-200 text-xs">{v}</Badge>;
if (["점검중", "진행", "in_progress"].includes(lower))
return <Badge className="bg-blue-500/10 text-blue-600 border-blue-200 text-xs">{v}</Badge>;
return <Badge variant="outline" className="text-xs">{v}</Badge>;
};
// ─── 렌더링 ─────────────────────────────────────────────
return (
<div className="flex h-full flex-col">
{/* 검색 필터 */}
<div className="shrink-0">
<DynamicSearchFilter
tableName={RECORD_TABLE}
filterId="equip-inspection-record"
onFilterChange={(filters) => setFilterValues(filters)}
dataCount={records.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">
<Wrench className="h-5 w-5 text-primary" />
<h2 className="text-base font-bold"></h2>
<Badge variant="secondary">{records.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={fetchData} 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={60} minSize={35}>
<div className="h-full overflow-auto">
{loading && records.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>
) : records.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-[100px]"></TableHead>
<TableHead className="min-w-[120px]"></TableHead>
<TableHead className="min-w-[140px]"></TableHead>
<TableHead className="min-w-[100px]"></TableHead>
<TableHead className="min-w-[70px] text-center"></TableHead>
<TableHead className="min-w-[80px]"></TableHead>
<TableHead className="min-w-[150px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{records.map((row, idx) => {
const item = inspectionItems.get(row.inspection_item_objid);
return (
<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.equipment_code || "-"}</TableCell>
<TableCell className="text-sm">{getEquipName(row.equipment_code)}</TableCell>
<TableCell className="text-sm">{item?.inspection_item || "-"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{fmtDate(row.inspection_date)}</TableCell>
<TableCell className="text-center">{statusBadge(row.status)}</TableCell>
<TableCell className="text-sm">{row.inspector || "-"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{row.remark || "-"}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 점검항목 상세 */}
<ResizablePanel defaultSize={40} minSize={25}>
{!selectedId || !selectedRecord ? (
<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 overflow-auto">
{/* 점검 기록 요약 */}
<div className="px-4 py-3 border-b bg-muted/50 shrink-0">
<h3 className="text-[13px] font-bold mb-2"> </h3>
<div className="grid grid-cols-2 gap-2 text-sm">
<DetailRow label="설비" value={getEquipName(selectedRecord.equipment_code)} />
<DetailRow label="설비코드" value={selectedRecord.equipment_code} />
<DetailRow label="점검일자" value={fmtDate(selectedRecord.inspection_date)} />
<DetailRow label="점검자" value={selectedRecord.inspector || "-"} />
<DetailRow label="상태" value={selectedRecord.status || "-"} badge={statusBadge(selectedRecord.status)} />
<DetailRow label="비고" value={selectedRecord.remark || "-"} />
</div>
</div>
{/* 점검 항목 상세 */}
{selectedItem ? (
<div className="px-4 py-3">
<h3 className="text-[13px] font-bold mb-2"> </h3>
<div className="grid grid-cols-2 gap-2 text-sm">
<DetailRow label="점검항목" value={selectedItem.inspection_item || "-"} />
<DetailRow label="점검주기" value={resolveCategory("inspection_cycle", selectedItem.inspection_cycle)} />
<DetailRow label="점검내용" value={selectedItem.inspection_content || "-"} span2 />
<DetailRow label="점검방법" value={resolveCategory("inspection_method", selectedItem.inspection_method)} span2 />
<DetailRow label="하한치" value={selectedItem.lower_limit || "-"} />
<DetailRow label="상한치" value={selectedItem.upper_limit || "-"} />
<DetailRow label="단위" value={selectedItem.unit || "-"} />
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<p className="text-sm"> </p>
</div>
)}
</div>
)}
</ResizablePanel>
</ResizablePanelGroup>
</div>
);
}
// ─── 상세 행 ──────────────────────────────────────────────
function DetailRow({
label,
value,
badge,
span2,
}: {
label: string;
value: string;
badge?: React.ReactNode;
span2?: boolean;
}) {
return (
<div className={cn("flex flex-col gap-0.5", span2 && "col-span-2")}>
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">{label}</span>
{badge ? <div className="w-fit">{badge}</div> : <span className="text-xs">{value}</span>}
</div>
);
}
@@ -0,0 +1,351 @@
"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>
);
}
@@ -0,0 +1,351 @@
"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, Wrench, 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 InspectionRecord {
id: string;
equipment_code: string;
inspection_item_objid: string;
inspection_date: string;
status: string;
inspector: string;
remark: string;
created_date: string;
}
interface InspectionItem {
id: string;
equipment_code: string;
inspection_item: string;
inspection_cycle: string;
inspection_content: string;
inspection_method: string;
lower_limit: string;
upper_limit: string;
unit: string;
}
interface EquipmentInfo {
id: string;
equipment_code: string;
equipment_name: string;
}
const RECORD_TABLE = "equipment_inspection_record";
// ─── 메인 컴포넌트 ───────────────────────────────────────
export default function EquipmentInspectionRecordPage() {
const [records, setRecords] = useState<InspectionRecord[]>([]);
const [inspectionItems, setInspectionItems] = useState<Map<string, InspectionItem>>(new Map());
const [equipments, setEquipments] = useState<Map<string, EquipmentInfo>>(new Map());
const [categoryMap, setCategoryMap] = useState<Record<string, Record<string, string>>>({});
const [loading, setLoading] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [filterValues, setFilterValues] = useState<FilterValue[]>([]);
// ─── 데이터 조회 ────────────────────────────────────────
const fetchData = useCallback(async () => {
setLoading(true);
try {
const search: Record<string, any> = {};
filterValues.forEach((f) => {
if (f.value) search[f.column] = f.value;
});
const [recordRes, itemRes, equipRes] = await Promise.all([
apiClient.post(`/table-management/tables/${RECORD_TABLE}/data`, {
page: 1,
size: 1000,
autoFilter: true,
search,
}),
apiClient.post(`/table-management/tables/equipment_inspection_item/data`, {
page: 1,
size: 1000,
autoFilter: true,
}).catch(() => ({ data: { data: { data: [] } } })),
apiClient.post(`/table-management/tables/equipment_mng/data`, {
page: 1,
size: 500,
autoFilter: true,
}).catch(() => ({ data: { data: { data: [] } } })),
]);
const rRows: InspectionRecord[] = recordRes.data?.data?.data ?? recordRes.data?.data?.rows ?? [];
setRecords(rRows);
const iRows: InspectionItem[] = itemRes.data?.data?.data ?? itemRes.data?.data?.rows ?? [];
const iMap = new Map<string, InspectionItem>();
iRows.forEach((i) => iMap.set(i.id, i));
setInspectionItems(iMap);
const eRows: EquipmentInfo[] = equipRes.data?.data?.data ?? equipRes.data?.data?.rows ?? [];
const eMap = new Map<string, EquipmentInfo>();
eRows.forEach((e) => {
eMap.set(e.equipment_code, e);
eMap.set(e.id, e);
});
setEquipments(eMap);
// 카테고리 코드→라벨 매핑 (점검주기, 점검방법)
try {
const catCols = ["inspection_cycle", "inspection_method"];
const catResults = await Promise.all(
catCols.map((col) =>
apiClient.get(`/table-categories/equipment_inspection_item/${col}/values`).catch(() => ({ data: [] })),
),
);
const cMap: Record<string, Record<string, string>> = {};
catCols.forEach((col, idx) => {
const vals: { code: string; label: string }[] = catResults[idx].data?.data ?? catResults[idx].data ?? [];
cMap[col] = {};
vals.forEach((v: any) => { cMap[col][v.valueCode || v.code] = v.valueLabel || v.label; });
});
setCategoryMap(cMap);
} catch {
// 카테고리 조회 실패 무시
}
} catch (err) {
console.error("점검기록 조회 실패:", err);
} finally {
setLoading(false);
}
}, [filterValues]);
useEffect(() => {
fetchData();
}, [fetchData]);
// ─── 선택된 레코드 ─────────────────────────────────────
const selectedRecord = useMemo(() => records.find((r) => r.id === selectedId), [records, selectedId]);
const selectedItem = useMemo(
() => (selectedRecord ? inspectionItems.get(selectedRecord.inspection_item_objid) : undefined),
[selectedRecord, inspectionItems],
);
// ─── 설비명 조회 ───────────────────────────────────────
const getEquipName = (code: string) => equipments.get(code)?.equipment_name || code || "-";
// ─── 카테고리 코드→라벨 변환 ──────────────────────────
const resolveCategory = (col: string, code: string) => {
if (!code) return "-";
return categoryMap[col]?.[code] || code;
};
// ─── 엑셀 다운로드 ─────────────────────────────────────
const handleExcel = async () => {
const data = records.map((r) => ({
설비코드: r.equipment_code,
설비명: getEquipName(r.equipment_code),
점검항목: inspectionItems.get(r.inspection_item_objid)?.inspection_item || "-",
점검일자: fmtDate(r.inspection_date),
상태: r.status,
점검자: r.inspector,
비고: r.remark,
}));
await exportToExcel(data, "설비점검기록.xlsx", "점검기록");
};
// ─── 날짜/상태 포맷 ────────────────────────────────────
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;
}
};
const statusBadge = (v: string) => {
if (!v) return <Badge variant="outline" className="text-xs">-</Badge>;
const lower = v.toLowerCase();
if (["완료", "pass", "정상", "합격", "completed"].includes(lower))
return <Badge className="bg-emerald-500/10 text-emerald-600 border-emerald-200 text-xs">{v}</Badge>;
if (["이상", "fail", "불합격", "비정상"].includes(lower))
return <Badge className="bg-red-500/10 text-red-600 border-red-200 text-xs">{v}</Badge>;
if (["점검중", "진행", "in_progress"].includes(lower))
return <Badge className="bg-blue-500/10 text-blue-600 border-blue-200 text-xs">{v}</Badge>;
return <Badge variant="outline" className="text-xs">{v}</Badge>;
};
// ─── 렌더링 ─────────────────────────────────────────────
return (
<div className="flex h-full flex-col">
{/* 검색 필터 */}
<div className="shrink-0">
<DynamicSearchFilter
tableName={RECORD_TABLE}
filterId="equip-inspection-record"
onFilterChange={(filters) => setFilterValues(filters)}
dataCount={records.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">
<Wrench className="h-5 w-5 text-primary" />
<h2 className="text-base font-bold"></h2>
<Badge variant="secondary">{records.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={fetchData} 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={60} minSize={35}>
<div className="h-full overflow-auto">
{loading && records.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>
) : records.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-[100px]"></TableHead>
<TableHead className="min-w-[120px]"></TableHead>
<TableHead className="min-w-[140px]"></TableHead>
<TableHead className="min-w-[100px]"></TableHead>
<TableHead className="min-w-[70px] text-center"></TableHead>
<TableHead className="min-w-[80px]"></TableHead>
<TableHead className="min-w-[150px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{records.map((row, idx) => {
const item = inspectionItems.get(row.inspection_item_objid);
return (
<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.equipment_code || "-"}</TableCell>
<TableCell className="text-sm">{getEquipName(row.equipment_code)}</TableCell>
<TableCell className="text-sm">{item?.inspection_item || "-"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{fmtDate(row.inspection_date)}</TableCell>
<TableCell className="text-center">{statusBadge(row.status)}</TableCell>
<TableCell className="text-sm">{row.inspector || "-"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{row.remark || "-"}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 점검항목 상세 */}
<ResizablePanel defaultSize={40} minSize={25}>
{!selectedId || !selectedRecord ? (
<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 overflow-auto">
{/* 점검 기록 요약 */}
<div className="px-4 py-3 border-b bg-muted/50 shrink-0">
<h3 className="text-[13px] font-bold mb-2"> </h3>
<div className="grid grid-cols-2 gap-2 text-sm">
<DetailRow label="설비" value={getEquipName(selectedRecord.equipment_code)} />
<DetailRow label="설비코드" value={selectedRecord.equipment_code} />
<DetailRow label="점검일자" value={fmtDate(selectedRecord.inspection_date)} />
<DetailRow label="점검자" value={selectedRecord.inspector || "-"} />
<DetailRow label="상태" value={selectedRecord.status || "-"} badge={statusBadge(selectedRecord.status)} />
<DetailRow label="비고" value={selectedRecord.remark || "-"} />
</div>
</div>
{/* 점검 항목 상세 */}
{selectedItem ? (
<div className="px-4 py-3">
<h3 className="text-[13px] font-bold mb-2"> </h3>
<div className="grid grid-cols-2 gap-2 text-sm">
<DetailRow label="점검항목" value={selectedItem.inspection_item || "-"} />
<DetailRow label="점검주기" value={resolveCategory("inspection_cycle", selectedItem.inspection_cycle)} />
<DetailRow label="점검내용" value={selectedItem.inspection_content || "-"} span2 />
<DetailRow label="점검방법" value={resolveCategory("inspection_method", selectedItem.inspection_method)} span2 />
<DetailRow label="하한치" value={selectedItem.lower_limit || "-"} />
<DetailRow label="상한치" value={selectedItem.upper_limit || "-"} />
<DetailRow label="단위" value={selectedItem.unit || "-"} />
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<p className="text-sm"> </p>
</div>
)}
</div>
)}
</ResizablePanel>
</ResizablePanelGroup>
</div>
);
}
// ─── 상세 행 ──────────────────────────────────────────────
function DetailRow({
label,
value,
badge,
span2,
}: {
label: string;
value: string;
badge?: React.ReactNode;
span2?: boolean;
}) {
return (
<div className={cn("flex flex-col gap-0.5", span2 && "col-span-2")}>
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">{label}</span>
{badge ? <div className="w-fit">{badge}</div> : <span className="text-xs">{value}</span>}
</div>
);
}
@@ -0,0 +1,351 @@
"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>
);
}
@@ -0,0 +1,351 @@
"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, Wrench, 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 InspectionRecord {
id: string;
equipment_code: string;
inspection_item_objid: string;
inspection_date: string;
status: string;
inspector: string;
remark: string;
created_date: string;
}
interface InspectionItem {
id: string;
equipment_code: string;
inspection_item: string;
inspection_cycle: string;
inspection_content: string;
inspection_method: string;
lower_limit: string;
upper_limit: string;
unit: string;
}
interface EquipmentInfo {
id: string;
equipment_code: string;
equipment_name: string;
}
const RECORD_TABLE = "equipment_inspection_record";
// ─── 메인 컴포넌트 ───────────────────────────────────────
export default function EquipmentInspectionRecordPage() {
const [records, setRecords] = useState<InspectionRecord[]>([]);
const [inspectionItems, setInspectionItems] = useState<Map<string, InspectionItem>>(new Map());
const [equipments, setEquipments] = useState<Map<string, EquipmentInfo>>(new Map());
const [categoryMap, setCategoryMap] = useState<Record<string, Record<string, string>>>({});
const [loading, setLoading] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [filterValues, setFilterValues] = useState<FilterValue[]>([]);
// ─── 데이터 조회 ────────────────────────────────────────
const fetchData = useCallback(async () => {
setLoading(true);
try {
const search: Record<string, any> = {};
filterValues.forEach((f) => {
if (f.value) search[f.column] = f.value;
});
const [recordRes, itemRes, equipRes] = await Promise.all([
apiClient.post(`/table-management/tables/${RECORD_TABLE}/data`, {
page: 1,
size: 1000,
autoFilter: true,
search,
}),
apiClient.post(`/table-management/tables/equipment_inspection_item/data`, {
page: 1,
size: 1000,
autoFilter: true,
}).catch(() => ({ data: { data: { data: [] } } })),
apiClient.post(`/table-management/tables/equipment_mng/data`, {
page: 1,
size: 500,
autoFilter: true,
}).catch(() => ({ data: { data: { data: [] } } })),
]);
const rRows: InspectionRecord[] = recordRes.data?.data?.data ?? recordRes.data?.data?.rows ?? [];
setRecords(rRows);
const iRows: InspectionItem[] = itemRes.data?.data?.data ?? itemRes.data?.data?.rows ?? [];
const iMap = new Map<string, InspectionItem>();
iRows.forEach((i) => iMap.set(i.id, i));
setInspectionItems(iMap);
const eRows: EquipmentInfo[] = equipRes.data?.data?.data ?? equipRes.data?.data?.rows ?? [];
const eMap = new Map<string, EquipmentInfo>();
eRows.forEach((e) => {
eMap.set(e.equipment_code, e);
eMap.set(e.id, e);
});
setEquipments(eMap);
// 카테고리 코드→라벨 매핑 (점검주기, 점검방법)
try {
const catCols = ["inspection_cycle", "inspection_method"];
const catResults = await Promise.all(
catCols.map((col) =>
apiClient.get(`/table-categories/equipment_inspection_item/${col}/values`).catch(() => ({ data: [] })),
),
);
const cMap: Record<string, Record<string, string>> = {};
catCols.forEach((col, idx) => {
const vals: { code: string; label: string }[] = catResults[idx].data?.data ?? catResults[idx].data ?? [];
cMap[col] = {};
vals.forEach((v: any) => { cMap[col][v.valueCode || v.code] = v.valueLabel || v.label; });
});
setCategoryMap(cMap);
} catch {
// 카테고리 조회 실패 무시
}
} catch (err) {
console.error("점검기록 조회 실패:", err);
} finally {
setLoading(false);
}
}, [filterValues]);
useEffect(() => {
fetchData();
}, [fetchData]);
// ─── 선택된 레코드 ─────────────────────────────────────
const selectedRecord = useMemo(() => records.find((r) => r.id === selectedId), [records, selectedId]);
const selectedItem = useMemo(
() => (selectedRecord ? inspectionItems.get(selectedRecord.inspection_item_objid) : undefined),
[selectedRecord, inspectionItems],
);
// ─── 설비명 조회 ───────────────────────────────────────
const getEquipName = (code: string) => equipments.get(code)?.equipment_name || code || "-";
// ─── 카테고리 코드→라벨 변환 ──────────────────────────
const resolveCategory = (col: string, code: string) => {
if (!code) return "-";
return categoryMap[col]?.[code] || code;
};
// ─── 엑셀 다운로드 ─────────────────────────────────────
const handleExcel = async () => {
const data = records.map((r) => ({
설비코드: r.equipment_code,
설비명: getEquipName(r.equipment_code),
점검항목: inspectionItems.get(r.inspection_item_objid)?.inspection_item || "-",
점검일자: fmtDate(r.inspection_date),
상태: r.status,
점검자: r.inspector,
비고: r.remark,
}));
await exportToExcel(data, "설비점검기록.xlsx", "점검기록");
};
// ─── 날짜/상태 포맷 ────────────────────────────────────
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;
}
};
const statusBadge = (v: string) => {
if (!v) return <Badge variant="outline" className="text-xs">-</Badge>;
const lower = v.toLowerCase();
if (["완료", "pass", "정상", "합격", "completed"].includes(lower))
return <Badge className="bg-emerald-500/10 text-emerald-600 border-emerald-200 text-xs">{v}</Badge>;
if (["이상", "fail", "불합격", "비정상"].includes(lower))
return <Badge className="bg-red-500/10 text-red-600 border-red-200 text-xs">{v}</Badge>;
if (["점검중", "진행", "in_progress"].includes(lower))
return <Badge className="bg-blue-500/10 text-blue-600 border-blue-200 text-xs">{v}</Badge>;
return <Badge variant="outline" className="text-xs">{v}</Badge>;
};
// ─── 렌더링 ─────────────────────────────────────────────
return (
<div className="flex h-full flex-col">
{/* 검색 필터 */}
<div className="shrink-0">
<DynamicSearchFilter
tableName={RECORD_TABLE}
filterId="equip-inspection-record"
onFilterChange={(filters) => setFilterValues(filters)}
dataCount={records.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">
<Wrench className="h-5 w-5 text-primary" />
<h2 className="text-base font-bold"></h2>
<Badge variant="secondary">{records.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={fetchData} 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={60} minSize={35}>
<div className="h-full overflow-auto">
{loading && records.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>
) : records.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-[100px]"></TableHead>
<TableHead className="min-w-[120px]"></TableHead>
<TableHead className="min-w-[140px]"></TableHead>
<TableHead className="min-w-[100px]"></TableHead>
<TableHead className="min-w-[70px] text-center"></TableHead>
<TableHead className="min-w-[80px]"></TableHead>
<TableHead className="min-w-[150px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{records.map((row, idx) => {
const item = inspectionItems.get(row.inspection_item_objid);
return (
<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.equipment_code || "-"}</TableCell>
<TableCell className="text-sm">{getEquipName(row.equipment_code)}</TableCell>
<TableCell className="text-sm">{item?.inspection_item || "-"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{fmtDate(row.inspection_date)}</TableCell>
<TableCell className="text-center">{statusBadge(row.status)}</TableCell>
<TableCell className="text-sm">{row.inspector || "-"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{row.remark || "-"}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 점검항목 상세 */}
<ResizablePanel defaultSize={40} minSize={25}>
{!selectedId || !selectedRecord ? (
<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 overflow-auto">
{/* 점검 기록 요약 */}
<div className="px-4 py-3 border-b bg-muted/50 shrink-0">
<h3 className="text-[13px] font-bold mb-2"> </h3>
<div className="grid grid-cols-2 gap-2 text-sm">
<DetailRow label="설비" value={getEquipName(selectedRecord.equipment_code)} />
<DetailRow label="설비코드" value={selectedRecord.equipment_code} />
<DetailRow label="점검일자" value={fmtDate(selectedRecord.inspection_date)} />
<DetailRow label="점검자" value={selectedRecord.inspector || "-"} />
<DetailRow label="상태" value={selectedRecord.status || "-"} badge={statusBadge(selectedRecord.status)} />
<DetailRow label="비고" value={selectedRecord.remark || "-"} />
</div>
</div>
{/* 점검 항목 상세 */}
{selectedItem ? (
<div className="px-4 py-3">
<h3 className="text-[13px] font-bold mb-2"> </h3>
<div className="grid grid-cols-2 gap-2 text-sm">
<DetailRow label="점검항목" value={selectedItem.inspection_item || "-"} />
<DetailRow label="점검주기" value={resolveCategory("inspection_cycle", selectedItem.inspection_cycle)} />
<DetailRow label="점검내용" value={selectedItem.inspection_content || "-"} span2 />
<DetailRow label="점검방법" value={resolveCategory("inspection_method", selectedItem.inspection_method)} span2 />
<DetailRow label="하한치" value={selectedItem.lower_limit || "-"} />
<DetailRow label="상한치" value={selectedItem.upper_limit || "-"} />
<DetailRow label="단위" value={selectedItem.unit || "-"} />
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<p className="text-sm"> </p>
</div>
)}
</div>
)}
</ResizablePanel>
</ResizablePanelGroup>
</div>
);
}
// ─── 상세 행 ──────────────────────────────────────────────
function DetailRow({
label,
value,
badge,
span2,
}: {
label: string;
value: string;
badge?: React.ReactNode;
span2?: boolean;
}) {
return (
<div className={cn("flex flex-col gap-0.5", span2 && "col-span-2")}>
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">{label}</span>
{badge ? <div className="w-fit">{badge}</div> : <span className="text-xs">{value}</span>}
</div>
);
}
@@ -0,0 +1,351 @@
"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>
);
}
@@ -0,0 +1,351 @@
"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, Wrench, 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 InspectionRecord {
id: string;
equipment_code: string;
inspection_item_objid: string;
inspection_date: string;
status: string;
inspector: string;
remark: string;
created_date: string;
}
interface InspectionItem {
id: string;
equipment_code: string;
inspection_item: string;
inspection_cycle: string;
inspection_content: string;
inspection_method: string;
lower_limit: string;
upper_limit: string;
unit: string;
}
interface EquipmentInfo {
id: string;
equipment_code: string;
equipment_name: string;
}
const RECORD_TABLE = "equipment_inspection_record";
// ─── 메인 컴포넌트 ───────────────────────────────────────
export default function EquipmentInspectionRecordPage() {
const [records, setRecords] = useState<InspectionRecord[]>([]);
const [inspectionItems, setInspectionItems] = useState<Map<string, InspectionItem>>(new Map());
const [equipments, setEquipments] = useState<Map<string, EquipmentInfo>>(new Map());
const [categoryMap, setCategoryMap] = useState<Record<string, Record<string, string>>>({});
const [loading, setLoading] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [filterValues, setFilterValues] = useState<FilterValue[]>([]);
// ─── 데이터 조회 ────────────────────────────────────────
const fetchData = useCallback(async () => {
setLoading(true);
try {
const search: Record<string, any> = {};
filterValues.forEach((f) => {
if (f.value) search[f.column] = f.value;
});
const [recordRes, itemRes, equipRes] = await Promise.all([
apiClient.post(`/table-management/tables/${RECORD_TABLE}/data`, {
page: 1,
size: 1000,
autoFilter: true,
search,
}),
apiClient.post(`/table-management/tables/equipment_inspection_item/data`, {
page: 1,
size: 1000,
autoFilter: true,
}).catch(() => ({ data: { data: { data: [] } } })),
apiClient.post(`/table-management/tables/equipment_mng/data`, {
page: 1,
size: 500,
autoFilter: true,
}).catch(() => ({ data: { data: { data: [] } } })),
]);
const rRows: InspectionRecord[] = recordRes.data?.data?.data ?? recordRes.data?.data?.rows ?? [];
setRecords(rRows);
const iRows: InspectionItem[] = itemRes.data?.data?.data ?? itemRes.data?.data?.rows ?? [];
const iMap = new Map<string, InspectionItem>();
iRows.forEach((i) => iMap.set(i.id, i));
setInspectionItems(iMap);
const eRows: EquipmentInfo[] = equipRes.data?.data?.data ?? equipRes.data?.data?.rows ?? [];
const eMap = new Map<string, EquipmentInfo>();
eRows.forEach((e) => {
eMap.set(e.equipment_code, e);
eMap.set(e.id, e);
});
setEquipments(eMap);
// 카테고리 코드→라벨 매핑 (점검주기, 점검방법)
try {
const catCols = ["inspection_cycle", "inspection_method"];
const catResults = await Promise.all(
catCols.map((col) =>
apiClient.get(`/table-categories/equipment_inspection_item/${col}/values`).catch(() => ({ data: [] })),
),
);
const cMap: Record<string, Record<string, string>> = {};
catCols.forEach((col, idx) => {
const vals: { code: string; label: string }[] = catResults[idx].data?.data ?? catResults[idx].data ?? [];
cMap[col] = {};
vals.forEach((v: any) => { cMap[col][v.valueCode || v.code] = v.valueLabel || v.label; });
});
setCategoryMap(cMap);
} catch {
// 카테고리 조회 실패 무시
}
} catch (err) {
console.error("점검기록 조회 실패:", err);
} finally {
setLoading(false);
}
}, [filterValues]);
useEffect(() => {
fetchData();
}, [fetchData]);
// ─── 선택된 레코드 ─────────────────────────────────────
const selectedRecord = useMemo(() => records.find((r) => r.id === selectedId), [records, selectedId]);
const selectedItem = useMemo(
() => (selectedRecord ? inspectionItems.get(selectedRecord.inspection_item_objid) : undefined),
[selectedRecord, inspectionItems],
);
// ─── 설비명 조회 ───────────────────────────────────────
const getEquipName = (code: string) => equipments.get(code)?.equipment_name || code || "-";
// ─── 카테고리 코드→라벨 변환 ──────────────────────────
const resolveCategory = (col: string, code: string) => {
if (!code) return "-";
return categoryMap[col]?.[code] || code;
};
// ─── 엑셀 다운로드 ─────────────────────────────────────
const handleExcel = async () => {
const data = records.map((r) => ({
설비코드: r.equipment_code,
설비명: getEquipName(r.equipment_code),
점검항목: inspectionItems.get(r.inspection_item_objid)?.inspection_item || "-",
점검일자: fmtDate(r.inspection_date),
상태: r.status,
점검자: r.inspector,
비고: r.remark,
}));
await exportToExcel(data, "설비점검기록.xlsx", "점검기록");
};
// ─── 날짜/상태 포맷 ────────────────────────────────────
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;
}
};
const statusBadge = (v: string) => {
if (!v) return <Badge variant="outline" className="text-xs">-</Badge>;
const lower = v.toLowerCase();
if (["완료", "pass", "정상", "합격", "completed"].includes(lower))
return <Badge className="bg-emerald-500/10 text-emerald-600 border-emerald-200 text-xs">{v}</Badge>;
if (["이상", "fail", "불합격", "비정상"].includes(lower))
return <Badge className="bg-red-500/10 text-red-600 border-red-200 text-xs">{v}</Badge>;
if (["점검중", "진행", "in_progress"].includes(lower))
return <Badge className="bg-blue-500/10 text-blue-600 border-blue-200 text-xs">{v}</Badge>;
return <Badge variant="outline" className="text-xs">{v}</Badge>;
};
// ─── 렌더링 ─────────────────────────────────────────────
return (
<div className="flex h-full flex-col">
{/* 검색 필터 */}
<div className="shrink-0">
<DynamicSearchFilter
tableName={RECORD_TABLE}
filterId="equip-inspection-record"
onFilterChange={(filters) => setFilterValues(filters)}
dataCount={records.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">
<Wrench className="h-5 w-5 text-primary" />
<h2 className="text-base font-bold"></h2>
<Badge variant="secondary">{records.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={fetchData} 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={60} minSize={35}>
<div className="h-full overflow-auto">
{loading && records.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>
) : records.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-[100px]"></TableHead>
<TableHead className="min-w-[120px]"></TableHead>
<TableHead className="min-w-[140px]"></TableHead>
<TableHead className="min-w-[100px]"></TableHead>
<TableHead className="min-w-[70px] text-center"></TableHead>
<TableHead className="min-w-[80px]"></TableHead>
<TableHead className="min-w-[150px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{records.map((row, idx) => {
const item = inspectionItems.get(row.inspection_item_objid);
return (
<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.equipment_code || "-"}</TableCell>
<TableCell className="text-sm">{getEquipName(row.equipment_code)}</TableCell>
<TableCell className="text-sm">{item?.inspection_item || "-"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{fmtDate(row.inspection_date)}</TableCell>
<TableCell className="text-center">{statusBadge(row.status)}</TableCell>
<TableCell className="text-sm">{row.inspector || "-"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{row.remark || "-"}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 점검항목 상세 */}
<ResizablePanel defaultSize={40} minSize={25}>
{!selectedId || !selectedRecord ? (
<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 overflow-auto">
{/* 점검 기록 요약 */}
<div className="px-4 py-3 border-b bg-muted/50 shrink-0">
<h3 className="text-[13px] font-bold mb-2"> </h3>
<div className="grid grid-cols-2 gap-2 text-sm">
<DetailRow label="설비" value={getEquipName(selectedRecord.equipment_code)} />
<DetailRow label="설비코드" value={selectedRecord.equipment_code} />
<DetailRow label="점검일자" value={fmtDate(selectedRecord.inspection_date)} />
<DetailRow label="점검자" value={selectedRecord.inspector || "-"} />
<DetailRow label="상태" value={selectedRecord.status || "-"} badge={statusBadge(selectedRecord.status)} />
<DetailRow label="비고" value={selectedRecord.remark || "-"} />
</div>
</div>
{/* 점검 항목 상세 */}
{selectedItem ? (
<div className="px-4 py-3">
<h3 className="text-[13px] font-bold mb-2"> </h3>
<div className="grid grid-cols-2 gap-2 text-sm">
<DetailRow label="점검항목" value={selectedItem.inspection_item || "-"} />
<DetailRow label="점검주기" value={resolveCategory("inspection_cycle", selectedItem.inspection_cycle)} />
<DetailRow label="점검내용" value={selectedItem.inspection_content || "-"} span2 />
<DetailRow label="점검방법" value={resolveCategory("inspection_method", selectedItem.inspection_method)} span2 />
<DetailRow label="하한치" value={selectedItem.lower_limit || "-"} />
<DetailRow label="상한치" value={selectedItem.upper_limit || "-"} />
<DetailRow label="단위" value={selectedItem.unit || "-"} />
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<p className="text-sm"> </p>
</div>
)}
</div>
)}
</ResizablePanel>
</ResizablePanelGroup>
</div>
);
}
// ─── 상세 행 ──────────────────────────────────────────────
function DetailRow({
label,
value,
badge,
span2,
}: {
label: string;
value: string;
badge?: React.ReactNode;
span2?: boolean;
}) {
return (
<div className={cn("flex flex-col gap-0.5", span2 && "col-span-2")}>
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">{label}</span>
{badge ? <div className="w-fit">{badge}</div> : <span className="text-xs">{value}</span>}
</div>
);
}
@@ -0,0 +1,351 @@
"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>
);
}
@@ -0,0 +1,351 @@
"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, Wrench, 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 InspectionRecord {
id: string;
equipment_code: string;
inspection_item_objid: string;
inspection_date: string;
status: string;
inspector: string;
remark: string;
created_date: string;
}
interface InspectionItem {
id: string;
equipment_code: string;
inspection_item: string;
inspection_cycle: string;
inspection_content: string;
inspection_method: string;
lower_limit: string;
upper_limit: string;
unit: string;
}
interface EquipmentInfo {
id: string;
equipment_code: string;
equipment_name: string;
}
const RECORD_TABLE = "equipment_inspection_record";
// ─── 메인 컴포넌트 ───────────────────────────────────────
export default function EquipmentInspectionRecordPage() {
const [records, setRecords] = useState<InspectionRecord[]>([]);
const [inspectionItems, setInspectionItems] = useState<Map<string, InspectionItem>>(new Map());
const [equipments, setEquipments] = useState<Map<string, EquipmentInfo>>(new Map());
const [categoryMap, setCategoryMap] = useState<Record<string, Record<string, string>>>({});
const [loading, setLoading] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [filterValues, setFilterValues] = useState<FilterValue[]>([]);
// ─── 데이터 조회 ────────────────────────────────────────
const fetchData = useCallback(async () => {
setLoading(true);
try {
const search: Record<string, any> = {};
filterValues.forEach((f) => {
if (f.value) search[f.column] = f.value;
});
const [recordRes, itemRes, equipRes] = await Promise.all([
apiClient.post(`/table-management/tables/${RECORD_TABLE}/data`, {
page: 1,
size: 1000,
autoFilter: true,
search,
}),
apiClient.post(`/table-management/tables/equipment_inspection_item/data`, {
page: 1,
size: 1000,
autoFilter: true,
}).catch(() => ({ data: { data: { data: [] } } })),
apiClient.post(`/table-management/tables/equipment_mng/data`, {
page: 1,
size: 500,
autoFilter: true,
}).catch(() => ({ data: { data: { data: [] } } })),
]);
const rRows: InspectionRecord[] = recordRes.data?.data?.data ?? recordRes.data?.data?.rows ?? [];
setRecords(rRows);
const iRows: InspectionItem[] = itemRes.data?.data?.data ?? itemRes.data?.data?.rows ?? [];
const iMap = new Map<string, InspectionItem>();
iRows.forEach((i) => iMap.set(i.id, i));
setInspectionItems(iMap);
const eRows: EquipmentInfo[] = equipRes.data?.data?.data ?? equipRes.data?.data?.rows ?? [];
const eMap = new Map<string, EquipmentInfo>();
eRows.forEach((e) => {
eMap.set(e.equipment_code, e);
eMap.set(e.id, e);
});
setEquipments(eMap);
// 카테고리 코드→라벨 매핑 (점검주기, 점검방법)
try {
const catCols = ["inspection_cycle", "inspection_method"];
const catResults = await Promise.all(
catCols.map((col) =>
apiClient.get(`/table-categories/equipment_inspection_item/${col}/values`).catch(() => ({ data: [] })),
),
);
const cMap: Record<string, Record<string, string>> = {};
catCols.forEach((col, idx) => {
const vals: { code: string; label: string }[] = catResults[idx].data?.data ?? catResults[idx].data ?? [];
cMap[col] = {};
vals.forEach((v: any) => { cMap[col][v.valueCode || v.code] = v.valueLabel || v.label; });
});
setCategoryMap(cMap);
} catch {
// 카테고리 조회 실패 무시
}
} catch (err) {
console.error("점검기록 조회 실패:", err);
} finally {
setLoading(false);
}
}, [filterValues]);
useEffect(() => {
fetchData();
}, [fetchData]);
// ─── 선택된 레코드 ─────────────────────────────────────
const selectedRecord = useMemo(() => records.find((r) => r.id === selectedId), [records, selectedId]);
const selectedItem = useMemo(
() => (selectedRecord ? inspectionItems.get(selectedRecord.inspection_item_objid) : undefined),
[selectedRecord, inspectionItems],
);
// ─── 설비명 조회 ───────────────────────────────────────
const getEquipName = (code: string) => equipments.get(code)?.equipment_name || code || "-";
// ─── 카테고리 코드→라벨 변환 ──────────────────────────
const resolveCategory = (col: string, code: string) => {
if (!code) return "-";
return categoryMap[col]?.[code] || code;
};
// ─── 엑셀 다운로드 ─────────────────────────────────────
const handleExcel = async () => {
const data = records.map((r) => ({
설비코드: r.equipment_code,
설비명: getEquipName(r.equipment_code),
점검항목: inspectionItems.get(r.inspection_item_objid)?.inspection_item || "-",
점검일자: fmtDate(r.inspection_date),
상태: r.status,
점검자: r.inspector,
비고: r.remark,
}));
await exportToExcel(data, "설비점검기록.xlsx", "점검기록");
};
// ─── 날짜/상태 포맷 ────────────────────────────────────
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;
}
};
const statusBadge = (v: string) => {
if (!v) return <Badge variant="outline" className="text-xs">-</Badge>;
const lower = v.toLowerCase();
if (["완료", "pass", "정상", "합격", "completed"].includes(lower))
return <Badge className="bg-emerald-500/10 text-emerald-600 border-emerald-200 text-xs">{v}</Badge>;
if (["이상", "fail", "불합격", "비정상"].includes(lower))
return <Badge className="bg-red-500/10 text-red-600 border-red-200 text-xs">{v}</Badge>;
if (["점검중", "진행", "in_progress"].includes(lower))
return <Badge className="bg-blue-500/10 text-blue-600 border-blue-200 text-xs">{v}</Badge>;
return <Badge variant="outline" className="text-xs">{v}</Badge>;
};
// ─── 렌더링 ─────────────────────────────────────────────
return (
<div className="flex h-full flex-col">
{/* 검색 필터 */}
<div className="shrink-0">
<DynamicSearchFilter
tableName={RECORD_TABLE}
filterId="equip-inspection-record"
onFilterChange={(filters) => setFilterValues(filters)}
dataCount={records.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">
<Wrench className="h-5 w-5 text-primary" />
<h2 className="text-base font-bold"></h2>
<Badge variant="secondary">{records.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={fetchData} 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={60} minSize={35}>
<div className="h-full overflow-auto">
{loading && records.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>
) : records.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-[100px]"></TableHead>
<TableHead className="min-w-[120px]"></TableHead>
<TableHead className="min-w-[140px]"></TableHead>
<TableHead className="min-w-[100px]"></TableHead>
<TableHead className="min-w-[70px] text-center"></TableHead>
<TableHead className="min-w-[80px]"></TableHead>
<TableHead className="min-w-[150px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{records.map((row, idx) => {
const item = inspectionItems.get(row.inspection_item_objid);
return (
<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.equipment_code || "-"}</TableCell>
<TableCell className="text-sm">{getEquipName(row.equipment_code)}</TableCell>
<TableCell className="text-sm">{item?.inspection_item || "-"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{fmtDate(row.inspection_date)}</TableCell>
<TableCell className="text-center">{statusBadge(row.status)}</TableCell>
<TableCell className="text-sm">{row.inspector || "-"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{row.remark || "-"}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 점검항목 상세 */}
<ResizablePanel defaultSize={40} minSize={25}>
{!selectedId || !selectedRecord ? (
<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 overflow-auto">
{/* 점검 기록 요약 */}
<div className="px-4 py-3 border-b bg-muted/50 shrink-0">
<h3 className="text-[13px] font-bold mb-2"> </h3>
<div className="grid grid-cols-2 gap-2 text-sm">
<DetailRow label="설비" value={getEquipName(selectedRecord.equipment_code)} />
<DetailRow label="설비코드" value={selectedRecord.equipment_code} />
<DetailRow label="점검일자" value={fmtDate(selectedRecord.inspection_date)} />
<DetailRow label="점검자" value={selectedRecord.inspector || "-"} />
<DetailRow label="상태" value={selectedRecord.status || "-"} badge={statusBadge(selectedRecord.status)} />
<DetailRow label="비고" value={selectedRecord.remark || "-"} />
</div>
</div>
{/* 점검 항목 상세 */}
{selectedItem ? (
<div className="px-4 py-3">
<h3 className="text-[13px] font-bold mb-2"> </h3>
<div className="grid grid-cols-2 gap-2 text-sm">
<DetailRow label="점검항목" value={selectedItem.inspection_item || "-"} />
<DetailRow label="점검주기" value={resolveCategory("inspection_cycle", selectedItem.inspection_cycle)} />
<DetailRow label="점검내용" value={selectedItem.inspection_content || "-"} span2 />
<DetailRow label="점검방법" value={resolveCategory("inspection_method", selectedItem.inspection_method)} span2 />
<DetailRow label="하한치" value={selectedItem.lower_limit || "-"} />
<DetailRow label="상한치" value={selectedItem.upper_limit || "-"} />
<DetailRow label="단위" value={selectedItem.unit || "-"} />
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<p className="text-sm"> </p>
</div>
)}
</div>
)}
</ResizablePanel>
</ResizablePanelGroup>
</div>
);
}
// ─── 상세 행 ──────────────────────────────────────────────
function DetailRow({
label,
value,
badge,
span2,
}: {
label: string;
value: string;
badge?: React.ReactNode;
span2?: boolean;
}) {
return (
<div className={cn("flex flex-col gap-0.5", span2 && "col-span-2")}>
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">{label}</span>
{badge ? <div className="w-fit">{badge}</div> : <span className="text-xs">{value}</span>}
</div>
);
}
@@ -0,0 +1,351 @@
"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>
);
}
@@ -0,0 +1,6 @@
"use client";
import { EquipmentInspection } from "@/components/pop/hardcoded/equipment/EquipmentInspection";
export default function EquipmentInspectionPage() {
return <EquipmentInspection />;
}
@@ -0,0 +1,6 @@
"use client";
import { EquipmentList } from "@/components/pop/hardcoded/equipment/EquipmentList";
export default function EquipmentManagementPage() {
return <EquipmentList />;
}
@@ -0,0 +1,6 @@
"use client";
import { EquipmentHome } from "@/components/pop/hardcoded/equipment/EquipmentHome";
export default function EquipmentPage() {
return <EquipmentHome />;
}
@@ -0,0 +1,6 @@
"use client";
import { InventoryMove } from "@/components/pop/hardcoded/inventory/InventoryMove";
export default function InventoryMovePage() {
return <InventoryMove />;
}
@@ -0,0 +1,6 @@
"use client";
import { InventoryTransfer } from "@/components/pop/hardcoded/inventory/InventoryTransfer";
export default function TransferPage() {
return <InventoryTransfer />;
}