Merge pull request 'jskim-node' (#17) from jskim-node into main
Reviewed-on: https://g.wace.me/jskim/vexplor_dev/pulls/17
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -518,7 +518,7 @@ export default function PurchaseItemPage() {
|
||||
}, [selectedItem?.item_number, priceCategoryOptions]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// 공급업체 검색
|
||||
const searchSuppliers = async () => {
|
||||
const searchSuppliers = useCallback(async () => {
|
||||
setSuppSearchLoading(true);
|
||||
try {
|
||||
const filters: any[] = [];
|
||||
@@ -533,7 +533,14 @@ export default function PurchaseItemPage() {
|
||||
const existing = new Set(supplierItems.map((c: any) => c.supplier_id || c.supplier_code));
|
||||
setSuppSearchResults(all.filter((c: any) => !existing.has(c.supplier_code)));
|
||||
} catch { /* skip */ } finally { setSuppSearchLoading(false); }
|
||||
};
|
||||
}, [suppSearchKeyword, supplierItems]);
|
||||
|
||||
// 실시간 검색 (2글자 이상)
|
||||
useEffect(() => {
|
||||
if (!suppSelectOpen) return;
|
||||
if (suppSearchKeyword.length > 0 && suppSearchKeyword.length < 2) return;
|
||||
searchSuppliers();
|
||||
}, [suppSearchKeyword, suppSelectOpen]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// 공급업체 선택 → 상세 모달로 이동
|
||||
const goToSuppDetail = () => {
|
||||
@@ -727,7 +734,7 @@ export default function PurchaseItemPage() {
|
||||
} catch { /* skip */ }
|
||||
|
||||
const priceRows = (suppPrices[custKey] || []).filter((p) =>
|
||||
(p.base_price && Number(p.base_price) > 0) || p.start_date
|
||||
p.base_price || p.start_date || p.currency_code || p.base_price_type
|
||||
);
|
||||
for (const price of priceRows) {
|
||||
await apiClient.post(`/table-management/tables/supplier_item_prices/add`, {
|
||||
@@ -763,7 +770,7 @@ export default function PurchaseItemPage() {
|
||||
}
|
||||
|
||||
const priceRows = (suppPrices[custKey] || []).filter((p) =>
|
||||
(p.base_price && Number(p.base_price) > 0) || p.start_date
|
||||
p.base_price || p.start_date || p.currency_code || p.base_price_type
|
||||
);
|
||||
for (const price of priceRows) {
|
||||
await apiClient.post(`/table-management/tables/supplier_item_prices/add`, {
|
||||
@@ -794,42 +801,63 @@ export default function PurchaseItemPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// 우측: 공급업체 매핑 삭제
|
||||
// 우측: 공급업체 매핑 해제 (소프트 삭제 — item_id를 null 처리)
|
||||
const handleSupplierMappingDelete = async () => {
|
||||
if (supplierCheckedIds.length === 0) return;
|
||||
const ok = await confirm(`선택한 ${supplierCheckedIds.length}개 공급업체 매핑을 삭제하시겠습니까?`, {
|
||||
description: "관련된 단가 정보도 함께 삭제됩니다.",
|
||||
variant: "destructive", confirmText: "삭제",
|
||||
const ok = await confirm(`선택한 ${supplierCheckedIds.length}개 공급업체의 연결을 해제하시겠습니까?`, {
|
||||
description: "해당 공급업체의 품목 연결이 해제됩니다. (데이터는 유지)",
|
||||
variant: "destructive", confirmText: "해제",
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
// 관련 단가 삭제
|
||||
for (const mappingId of supplierCheckedIds) {
|
||||
const supplierCodes = supplierCheckedIds.map((mid) => {
|
||||
const group = Object.values(supplierGroups).find((g) => g.master.id === mid);
|
||||
return group?.master.supplier_id || group?.master.supplier_code || "";
|
||||
}).filter(Boolean);
|
||||
|
||||
for (const suppCode of supplierCodes) {
|
||||
// 해당 공급업체의 모든 매핑 조회 → item_id null 처리
|
||||
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "item_id", operator: "equals", value: selectedItem!.item_number },
|
||||
{ columnName: "supplier_id", operator: "equals", value: suppCode },
|
||||
]}, autoFilter: true,
|
||||
});
|
||||
const allMappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
|
||||
for (const m of allMappings) {
|
||||
await apiClient.put(`/table-management/tables/${MAPPING_TABLE}/edit`, {
|
||||
originalData: { id: m.id },
|
||||
updatedData: { item_id: null },
|
||||
});
|
||||
}
|
||||
|
||||
// 해당 공급업체의 모든 단가 조회 → item_id null 처리
|
||||
try {
|
||||
const priceRes = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, {
|
||||
page: 1, size: 500,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "mapping_id", operator: "equals", value: mappingId }] },
|
||||
autoFilter: true,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "item_id", operator: "equals", value: selectedItem!.item_number },
|
||||
{ columnName: "supplier_id", operator: "equals", value: suppCode },
|
||||
]}, autoFilter: true,
|
||||
});
|
||||
const prices = priceRes.data?.data?.data || priceRes.data?.data?.rows || [];
|
||||
if (prices.length > 0) {
|
||||
await apiClient.delete(`/table-management/tables/supplier_item_prices/delete`, {
|
||||
data: prices.map((p: any) => ({ id: p.id })),
|
||||
for (const p of prices) {
|
||||
await apiClient.put(`/table-management/tables/supplier_item_prices/edit`, {
|
||||
originalData: { id: p.id },
|
||||
updatedData: { item_id: null },
|
||||
});
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
// 매핑 삭제
|
||||
await apiClient.delete(`/table-management/tables/${MAPPING_TABLE}/delete`, {
|
||||
data: supplierCheckedIds.map((id) => ({ id })),
|
||||
});
|
||||
toast.success(`${supplierCheckedIds.length}개 공급업체 매핑이 삭제되었습니다.`);
|
||||
|
||||
toast.success(`${supplierCheckedIds.length}개 공급업체의 연결이 해제되었습니다.`);
|
||||
setSupplierCheckedIds([]);
|
||||
const sid = selectedItemId;
|
||||
setSelectedItemId(null);
|
||||
setTimeout(() => setSelectedItemId(sid), 50);
|
||||
} catch {
|
||||
toast.error("삭제에 실패했습니다.");
|
||||
toast.error("연결 해제에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -807,7 +807,7 @@ export default function SupplierManagementPage() {
|
||||
};
|
||||
|
||||
// 품목 검색
|
||||
const searchItems = async () => {
|
||||
const searchItems = useCallback(async () => {
|
||||
setItemSearchLoading(true);
|
||||
try {
|
||||
const filters: any[] = [];
|
||||
@@ -827,7 +827,14 @@ export default function SupplierManagementPage() {
|
||||
return divCodes.some((code: string) => PURCHASE_CODES.includes(code));
|
||||
}));
|
||||
} catch { /* skip */ } finally { setItemSearchLoading(false); }
|
||||
};
|
||||
}, [itemSearchKeyword, priceItems]);
|
||||
|
||||
// 실��간 검색 (2글자 이상)
|
||||
useEffect(() => {
|
||||
if (!itemSelectOpen) return;
|
||||
if (itemSearchKeyword.length > 0 && itemSearchKeyword.length < 2) return;
|
||||
searchItems();
|
||||
}, [itemSearchKeyword, itemSelectOpen]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// 품목 선택 완료 → 상세 입력 모달로 전환
|
||||
const goToItemDetail = () => {
|
||||
@@ -1090,7 +1097,7 @@ export default function SupplierManagementPage() {
|
||||
|
||||
// 단가 upsert
|
||||
const priceRows = (itemPrices[itemKey] || []).filter((p) =>
|
||||
(p.base_price && Number(p.base_price) > 0) || p.start_date
|
||||
p.base_price || p.start_date || p.currency_code || p.base_price_type
|
||||
);
|
||||
const usedPriceIds = new Set<string>();
|
||||
for (let pi = 0; pi < priceRows.length; pi++) {
|
||||
@@ -1160,7 +1167,7 @@ export default function SupplierManagementPage() {
|
||||
}
|
||||
|
||||
const priceRows = (itemPrices[itemKey] || []).filter((p) =>
|
||||
(p.base_price && Number(p.base_price) > 0) || p.start_date
|
||||
p.base_price || p.start_date || p.currency_code || p.base_price_type
|
||||
);
|
||||
for (const price of priceRows) {
|
||||
await apiClient.post(`/table-management/tables/${PRICE_TABLE}/add`, {
|
||||
@@ -1192,40 +1199,63 @@ export default function SupplierManagementPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// 품목 매핑 삭제
|
||||
// 품목 매핑 해제 (소프트 삭제 — supplier_id를 null 처리)
|
||||
const handlePriceItemDelete = async () => {
|
||||
if (priceCheckedIds.length === 0) return;
|
||||
const ok = await confirm(`선택한 ${priceCheckedIds.length}개 품목 매핑을 삭제하시겠습니까?`, {
|
||||
description: "관련된 단가 정보도 함께 삭제됩니다.",
|
||||
variant: "destructive", confirmText: "삭제",
|
||||
const ok = await confirm(`선택한 ${priceCheckedIds.length}개 품목의 연결을 해제하시겠습니까?`, {
|
||||
description: "해당 품목의 공급업체 연결이 해제됩니다. (데이터는 유지)",
|
||||
variant: "destructive", confirmText: "해제",
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
for (const mappingId of priceCheckedIds) {
|
||||
const itemIds = priceCheckedIds.map((mid) => {
|
||||
const group = Object.values(priceGroups).find((g) => g.master.id === mid);
|
||||
return group?.master.item_id || group?.master.item_number || "";
|
||||
}).filter(Boolean);
|
||||
|
||||
for (const itemId of itemIds) {
|
||||
// 해당 품목의 모든 매핑 조회 → supplier_id null 처리
|
||||
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "supplier_id", operator: "equals", value: selectedSupplier!.supplier_code },
|
||||
{ columnName: "item_id", operator: "equals", value: itemId },
|
||||
]}, autoFilter: true,
|
||||
});
|
||||
const allMappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
|
||||
for (const m of allMappings) {
|
||||
await apiClient.put(`/table-management/tables/${MAPPING_TABLE}/edit`, {
|
||||
originalData: { id: m.id },
|
||||
updatedData: { supplier_id: null },
|
||||
});
|
||||
}
|
||||
|
||||
// 해당 품목의 모든 단가 조회 → supplier_id null 처리
|
||||
try {
|
||||
const priceRes = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "mapping_id", operator: "equals", value: mappingId }] },
|
||||
autoFilter: true,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "supplier_id", operator: "equals", value: selectedSupplier!.supplier_code },
|
||||
{ columnName: "item_id", operator: "equals", value: itemId },
|
||||
]}, autoFilter: true,
|
||||
});
|
||||
const prices = priceRes.data?.data?.data || priceRes.data?.data?.rows || [];
|
||||
if (prices.length > 0) {
|
||||
await apiClient.delete(`/table-management/tables/${PRICE_TABLE}/delete`, {
|
||||
data: prices.map((p: any) => ({ id: p.id })),
|
||||
for (const p of prices) {
|
||||
await apiClient.put(`/table-management/tables/${PRICE_TABLE}/edit`, {
|
||||
originalData: { id: p.id },
|
||||
updatedData: { supplier_id: null },
|
||||
});
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
await apiClient.delete(`/table-management/tables/${MAPPING_TABLE}/delete`, {
|
||||
data: priceCheckedIds.map((id) => ({ id })),
|
||||
});
|
||||
toast.success(`${priceCheckedIds.length}개 품목 매핑이 삭제되었습니다.`);
|
||||
|
||||
toast.success(`${priceCheckedIds.length}개 품목의 연결이 해제되었습니다.`);
|
||||
setPriceCheckedIds([]);
|
||||
const cid = selectedSupplierId;
|
||||
setSelectedSupplierId(null);
|
||||
setTimeout(() => setSelectedSupplierId(cid), 50);
|
||||
} catch {
|
||||
toast.error("삭제에 실패했습니다.");
|
||||
toast.error("연결 해제에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -456,7 +456,7 @@ export default function SalesItemPage() {
|
||||
}, [selectedItem?.item_number, priceCategoryOptions]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// 거래처 검색
|
||||
const searchCustomers = async () => {
|
||||
const searchCustomers = useCallback(async () => {
|
||||
setCustSearchLoading(true);
|
||||
try {
|
||||
const filters: any[] = [];
|
||||
@@ -471,7 +471,14 @@ export default function SalesItemPage() {
|
||||
const existing = new Set(customerItems.map((c: any) => c.customer_id || c.customer_code));
|
||||
setCustSearchResults(all.filter((c: any) => !existing.has(c.customer_code)));
|
||||
} catch { /* skip */ } finally { setCustSearchLoading(false); }
|
||||
};
|
||||
}, [custSearchKeyword, customerItems]);
|
||||
|
||||
// 실시간 검색 (2글자 이상)
|
||||
useEffect(() => {
|
||||
if (!custSelectOpen) return;
|
||||
if (custSearchKeyword.length > 0 && custSearchKeyword.length < 2) return;
|
||||
searchCustomers();
|
||||
}, [custSearchKeyword, custSelectOpen]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// 거래처 선택 → 상세 모달로 이동
|
||||
const goToCustDetail = () => {
|
||||
@@ -691,7 +698,7 @@ export default function SalesItemPage() {
|
||||
} catch { /* skip */ }
|
||||
|
||||
const priceRows = (custPrices[custKey] || []).filter((p) =>
|
||||
(p.base_price && Number(p.base_price) > 0) || p.start_date
|
||||
p.base_price || p.start_date || p.currency_code || p.base_price_type
|
||||
);
|
||||
for (const price of priceRows) {
|
||||
await apiClient.post(`/table-management/tables/customer_item_prices/add`, {
|
||||
@@ -727,7 +734,7 @@ export default function SalesItemPage() {
|
||||
}
|
||||
|
||||
const priceRows = (custPrices[custKey] || []).filter((p) =>
|
||||
(p.base_price && Number(p.base_price) > 0) || p.start_date
|
||||
p.base_price || p.start_date || p.currency_code || p.base_price_type
|
||||
);
|
||||
for (const price of priceRows) {
|
||||
await apiClient.post(`/table-management/tables/customer_item_prices/add`, {
|
||||
@@ -822,42 +829,63 @@ export default function SalesItemPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// 우측: 거래처 매핑 삭제
|
||||
// 우측: 거래처 매핑 해제 (소프트 삭제 — item_id를 null 처리)
|
||||
const handleCustomerMappingDelete = async () => {
|
||||
if (customerCheckedIds.length === 0) return;
|
||||
const ok = await confirm(`선택한 ${customerCheckedIds.length}개 거래처 매핑을 삭제하시겠습니까?`, {
|
||||
description: "관련된 단가 정보도 함께 삭제됩니다.",
|
||||
variant: "destructive", confirmText: "삭제",
|
||||
const ok = await confirm(`선택한 ${customerCheckedIds.length}개 거래처의 연결을 해제하시겠습니까?`, {
|
||||
description: "해당 거래처의 품목 연결이 해제됩니다. (데이터는 유지)",
|
||||
variant: "destructive", confirmText: "해제",
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
// 관련 단가 삭제
|
||||
for (const mappingId of customerCheckedIds) {
|
||||
const customerCodes = customerCheckedIds.map((mid) => {
|
||||
const group = Object.values(priceGroups).find((g) => g.master.id === mid);
|
||||
return group?.master.customer_id || group?.master.customer_code || "";
|
||||
}).filter(Boolean);
|
||||
|
||||
for (const custCode of customerCodes) {
|
||||
// 해당 거래처의 모든 매핑 조회 → item_id null 처리
|
||||
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "item_id", operator: "equals", value: selectedItem!.item_number },
|
||||
{ columnName: "customer_id", operator: "equals", value: custCode },
|
||||
]}, autoFilter: true,
|
||||
});
|
||||
const allMappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
|
||||
for (const m of allMappings) {
|
||||
await apiClient.put(`/table-management/tables/${MAPPING_TABLE}/edit`, {
|
||||
originalData: { id: m.id },
|
||||
updatedData: { item_id: null },
|
||||
});
|
||||
}
|
||||
|
||||
// 해당 거래처의 모든 단가 조회 → item_id null 처리
|
||||
try {
|
||||
const priceRes = await apiClient.post(`/table-management/tables/customer_item_prices/data`, {
|
||||
page: 1, size: 500,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "mapping_id", operator: "equals", value: mappingId }] },
|
||||
autoFilter: true,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "item_id", operator: "equals", value: selectedItem!.item_number },
|
||||
{ columnName: "customer_id", operator: "equals", value: custCode },
|
||||
]}, autoFilter: true,
|
||||
});
|
||||
const prices = priceRes.data?.data?.data || priceRes.data?.data?.rows || [];
|
||||
if (prices.length > 0) {
|
||||
await apiClient.delete(`/table-management/tables/customer_item_prices/delete`, {
|
||||
data: prices.map((p: any) => ({ id: p.id })),
|
||||
for (const p of prices) {
|
||||
await apiClient.put(`/table-management/tables/customer_item_prices/edit`, {
|
||||
originalData: { id: p.id },
|
||||
updatedData: { item_id: null },
|
||||
});
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
// 매핑 삭제
|
||||
await apiClient.delete(`/table-management/tables/${MAPPING_TABLE}/delete`, {
|
||||
data: customerCheckedIds.map((id) => ({ id })),
|
||||
});
|
||||
toast.success(`${customerCheckedIds.length}개 거래처 매핑이 삭제되었습니다.`);
|
||||
|
||||
toast.success(`${customerCheckedIds.length}개 거래처의 연결이 해제되었습니다.`);
|
||||
setCustomerCheckedIds([]);
|
||||
const sid = selectedItemId;
|
||||
setSelectedItemId(null);
|
||||
setTimeout(() => setSelectedItemId(sid), 50);
|
||||
} catch {
|
||||
toast.error("삭제에 실패했습니다.");
|
||||
toast.error("연결 해제에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1517,51 +1517,12 @@ export default function BomManagementPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 상세 카드 */}
|
||||
<div className="border-b shrink-0">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/50">
|
||||
<h3 className="text-[13px] font-bold text-foreground">BOM 상세정보</h3>
|
||||
<Button size="sm" variant="ghost" onClick={openEditModal}>
|
||||
<FileText className="w-3.5 h-3.5 mr-1" />
|
||||
편집
|
||||
</Button>
|
||||
</div>
|
||||
{detailLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : bomHeader ? (
|
||||
<div className="grid grid-cols-2 text-sm">
|
||||
<div className="flex flex-col gap-1 p-3 border-b border-r">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">품목코드</span>
|
||||
<span className="font-mono text-xs">{bomHeader.item_code || bomHeader.item_number || "-"}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 border-b">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">품명</span>
|
||||
<span className="text-xs">{bomHeader.item_name || "-"}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 border-b border-r">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">BOM 유형</span>
|
||||
<span className="text-xs">{BOM_TYPE_OPTIONS.find((o) => o.code === bomHeader.bom_type)?.label || bomHeader.bom_type || "-"}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 border-b">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">버전</span>
|
||||
<span className="text-xs">{bomHeader.version || "-"}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 border-b border-r">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">기준수량</span>
|
||||
<span className="text-xs">{bomHeader.base_qty || "1"} {bomHeader.unit || ""}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 border-b">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">상태</span>
|
||||
{renderStatusBadge(bomHeader.status)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 col-span-2">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">메모</span>
|
||||
<span className="text-xs text-muted-foreground">{bomHeader.remark || "-"}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{/* 편집 버튼 */}
|
||||
<div className="flex items-center justify-end px-4 py-2 border-b bg-muted/50 shrink-0">
|
||||
<Button size="sm" variant="ghost" onClick={openEditModal}>
|
||||
<FileText className="w-3.5 h-3.5 mr-1" />
|
||||
편집
|
||||
</Button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -918,7 +918,7 @@ export default function SalesOrderPage() {
|
||||
{/* 데이터 테이블 (플랫 리스트) */}
|
||||
<div className="flex-1 overflow-hidden rounded-lg border border-border bg-card">
|
||||
<div className="h-full overflow-auto">
|
||||
<Table style={{ minWidth: "1500px" }}>
|
||||
<Table noWrapper style={{ minWidth: "1500px" }}>
|
||||
<colgroup>
|
||||
<col style={{ width: "40px" }} />
|
||||
<col style={{ width: "140px" }} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -502,8 +502,8 @@ export function DataGrid({
|
||||
<div className="flex flex-col h-full flex-1 min-h-0">
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-background z-10 shadow-sm">
|
||||
<Table noWrapper>
|
||||
<TableHeader className="sticky top-0 bg-background z-10 shadow-[0_1px_0_0_hsl(var(--border))]">
|
||||
<SortableContext items={columns.map((c) => c.key)} strategy={horizontalListSortingStrategy}>
|
||||
<TableRow>
|
||||
{showCheckbox && (
|
||||
|
||||
@@ -610,8 +610,8 @@ export function EDataTable<T extends Record<string, any> = any>({
|
||||
<div className={cn("flex flex-col h-full flex-1 min-h-0", className)}>
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<Table className="min-w-max">
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<Table noWrapper className="min-w-max">
|
||||
<TableHeader className="sticky top-0 z-10 bg-muted shadow-[0_1px_0_0_hsl(var(--border))]">
|
||||
<SortableContext items={columns.map((c) => c.key)} strategy={horizontalListSortingStrategy}>
|
||||
<TableRow className="bg-muted hover:bg-muted h-10">
|
||||
{/* 체크박스 */}
|
||||
|
||||
@@ -110,6 +110,7 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
||||
"/COMPANY_7/production/plan-management": dynamic(() => import("@/app/(main)/COMPANY_7/production/plan-management/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_7/production/bom": dynamic(() => import("@/app/(main)/COMPANY_7/production/bom/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_7/equipment/info": dynamic(() => import("@/app/(main)/COMPANY_7/equipment/info/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_7/equipment/inspection-record": dynamic(() => import("@/app/(main)/COMPANY_7/equipment/inspection-record/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_7/equipment/plc-settings": dynamic(() => import("@/app/(main)/COMPANY_7/equipment/plc-settings/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_7/logistics/material-status": dynamic(() => import("@/app/(main)/COMPANY_7/logistics/material-status/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_7/logistics/outbound": dynamic(() => import("@/app/(main)/COMPANY_7/logistics/outbound/page"), { ssr: false, loading: LoadingFallback }),
|
||||
@@ -144,19 +145,21 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
||||
"/COMPANY_16/production/plan-management": dynamic(() => import("@/app/(main)/COMPANY_16/production/plan-management/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/production/bom": dynamic(() => import("@/app/(main)/COMPANY_16/production/bom/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/equipment/info": dynamic(() => import("@/app/(main)/COMPANY_16/equipment/info/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/equipment/inspection-record": dynamic(() => import("@/app/(main)/COMPANY_16/equipment/inspection-record/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/equipment/plc-settings": dynamic(() => import("@/app/(main)/COMPANY_16/equipment/plc-settings/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/monitoring/production": dynamic(() => import("@/app/(main)/COMPANY_16/monitoring/production/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/monitoring/equipment": dynamic(() => import("@/app/(main)/COMPANY_16/monitoring/equipment/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/monitoring/quality": dynamic(() => import("@/app/(main)/COMPANY_16/monitoring/quality/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/monitoring/settings": dynamic(() => import("@/app/(main)/COMPANY_16/monitoring/settings/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/monitoring/settings": dynamic(() => import("@/components/monitoring/MonitoringSettingsPage"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_7/monitoring/production": dynamic(() => import("@/app/(main)/COMPANY_7/monitoring/production/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_7/monitoring/equipment": dynamic(() => import("@/app/(main)/COMPANY_7/monitoring/equipment/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_7/monitoring/quality": dynamic(() => import("@/app/(main)/COMPANY_7/monitoring/quality/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_7/monitoring/settings": dynamic(() => import("@/app/(main)/COMPANY_7/monitoring/settings/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_7/monitoring/settings": dynamic(() => import("@/components/monitoring/MonitoringSettingsPage"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_7/purchase/order": dynamic(() => import("@/app/(main)/COMPANY_7/purchase/order/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_7/purchase/purchase-item": dynamic(() => import("@/app/(main)/COMPANY_7/purchase/purchase-item/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_7/purchase/supplier": dynamic(() => import("@/app/(main)/COMPANY_7/purchase/supplier/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_7/quality/inspection": dynamic(() => import("@/app/(main)/COMPANY_7/quality/inspection/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_7/quality/inspection-result": dynamic(() => import("@/app/(main)/COMPANY_7/quality/inspection-result/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_7/quality/item-inspection": dynamic(() => import("@/app/(main)/COMPANY_7/quality/item-inspection/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_7/mold/info": dynamic(() => import("@/app/(main)/COMPANY_7/mold/info/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/logistics/material-status": dynamic(() => import("@/app/(main)/COMPANY_16/logistics/material-status/page"), { ssr: false, loading: LoadingFallback }),
|
||||
@@ -173,6 +176,7 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
||||
"/COMPANY_16/purchase/purchase-item": dynamic(() => import("@/app/(main)/COMPANY_16/purchase/purchase-item/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/purchase/supplier": dynamic(() => import("@/app/(main)/COMPANY_16/purchase/supplier/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/quality/inspection": dynamic(() => import("@/app/(main)/COMPANY_16/quality/inspection/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/quality/inspection-result": dynamic(() => import("@/app/(main)/COMPANY_16/quality/inspection-result/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/quality/item-inspection": dynamic(() => import("@/app/(main)/COMPANY_16/quality/item-inspection/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/mold/info": dynamic(() => import("@/app/(main)/COMPANY_16/mold/info/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/design/project": dynamic(() => import("@/app/(main)/COMPANY_16/design/project/page"), { ssr: false, loading: LoadingFallback }),
|
||||
@@ -198,11 +202,12 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
||||
"/COMPANY_8/production/plan-management": dynamic(() => import("@/app/(main)/COMPANY_8/production/plan-management/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_8/production/bom": dynamic(() => import("@/app/(main)/COMPANY_8/production/bom/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_8/equipment/info": dynamic(() => import("@/app/(main)/COMPANY_8/equipment/info/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_8/equipment/inspection-record": dynamic(() => import("@/app/(main)/COMPANY_8/equipment/inspection-record/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_8/equipment/plc-settings": dynamic(() => import("@/app/(main)/COMPANY_8/equipment/plc-settings/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_8/monitoring/production": dynamic(() => import("@/app/(main)/COMPANY_8/monitoring/production/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_8/monitoring/equipment": dynamic(() => import("@/app/(main)/COMPANY_8/monitoring/equipment/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_8/monitoring/quality": dynamic(() => import("@/app/(main)/COMPANY_8/monitoring/quality/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_8/monitoring/settings": dynamic(() => import("@/app/(main)/COMPANY_8/monitoring/settings/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_8/monitoring/settings": dynamic(() => import("@/components/monitoring/MonitoringSettingsPage"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_8/logistics/material-status": dynamic(() => import("@/app/(main)/COMPANY_8/logistics/material-status/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_8/logistics/outbound": dynamic(() => import("@/app/(main)/COMPANY_8/logistics/outbound/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_8/logistics/inbound-outbound": dynamic(() => import("@/app/(main)/COMPANY_8/logistics/inbound-outbound/page"), { ssr: false, loading: LoadingFallback }),
|
||||
@@ -217,6 +222,7 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
||||
"/COMPANY_8/purchase/purchase-item": dynamic(() => import("@/app/(main)/COMPANY_8/purchase/purchase-item/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_8/purchase/supplier": dynamic(() => import("@/app/(main)/COMPANY_8/purchase/supplier/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_8/quality/inspection": dynamic(() => import("@/app/(main)/COMPANY_8/quality/inspection/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_8/quality/inspection-result": dynamic(() => import("@/app/(main)/COMPANY_8/quality/inspection-result/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_8/quality/item-inspection": dynamic(() => import("@/app/(main)/COMPANY_8/quality/item-inspection/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_8/mold/info": dynamic(() => import("@/app/(main)/COMPANY_8/mold/info/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_8/design/project": dynamic(() => import("@/app/(main)/COMPANY_8/design/project/page"), { ssr: false, loading: LoadingFallback }),
|
||||
@@ -242,11 +248,12 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
||||
"/COMPANY_10/production/plan-management": dynamic(() => import("@/app/(main)/COMPANY_10/production/plan-management/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_10/production/bom": dynamic(() => import("@/app/(main)/COMPANY_10/production/bom/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_10/equipment/info": dynamic(() => import("@/app/(main)/COMPANY_10/equipment/info/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_10/equipment/inspection-record": dynamic(() => import("@/app/(main)/COMPANY_10/equipment/inspection-record/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_10/equipment/plc-settings": dynamic(() => import("@/app/(main)/COMPANY_10/equipment/plc-settings/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_10/monitoring/production": dynamic(() => import("@/app/(main)/COMPANY_10/monitoring/production/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_10/monitoring/equipment": dynamic(() => import("@/app/(main)/COMPANY_10/monitoring/equipment/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_10/monitoring/quality": dynamic(() => import("@/app/(main)/COMPANY_10/monitoring/quality/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_10/monitoring/settings": dynamic(() => import("@/app/(main)/COMPANY_10/monitoring/settings/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_10/monitoring/settings": dynamic(() => import("@/components/monitoring/MonitoringSettingsPage"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_10/logistics/material-status": dynamic(() => import("@/app/(main)/COMPANY_10/logistics/material-status/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_10/logistics/outbound": dynamic(() => import("@/app/(main)/COMPANY_10/logistics/outbound/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_10/logistics/inbound-outbound": dynamic(() => import("@/app/(main)/COMPANY_10/logistics/inbound-outbound/page"), { ssr: false, loading: LoadingFallback }),
|
||||
@@ -261,6 +268,7 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
||||
"/COMPANY_10/purchase/purchase-item": dynamic(() => import("@/app/(main)/COMPANY_10/purchase/purchase-item/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_10/purchase/supplier": dynamic(() => import("@/app/(main)/COMPANY_10/purchase/supplier/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_10/quality/inspection": dynamic(() => import("@/app/(main)/COMPANY_10/quality/inspection/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_10/quality/inspection-result": dynamic(() => import("@/app/(main)/COMPANY_10/quality/inspection-result/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_10/quality/item-inspection": dynamic(() => import("@/app/(main)/COMPANY_10/quality/item-inspection/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_10/mold/info": dynamic(() => import("@/app/(main)/COMPANY_10/mold/info/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_10/design/project": dynamic(() => import("@/app/(main)/COMPANY_10/design/project/page"), { ssr: false, loading: LoadingFallback }),
|
||||
@@ -286,11 +294,12 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
||||
"/COMPANY_29/production/plan-management": dynamic(() => import("@/app/(main)/COMPANY_29/production/plan-management/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/production/bom": dynamic(() => import("@/app/(main)/COMPANY_29/production/bom/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/equipment/info": dynamic(() => import("@/app/(main)/COMPANY_29/equipment/info/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/equipment/inspection-record": dynamic(() => import("@/app/(main)/COMPANY_29/equipment/inspection-record/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/equipment/plc-settings": dynamic(() => import("@/app/(main)/COMPANY_29/equipment/plc-settings/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/monitoring/production": dynamic(() => import("@/app/(main)/COMPANY_29/monitoring/production/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/monitoring/equipment": dynamic(() => import("@/app/(main)/COMPANY_29/monitoring/equipment/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/monitoring/quality": dynamic(() => import("@/app/(main)/COMPANY_29/monitoring/quality/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/monitoring/settings": dynamic(() => import("@/app/(main)/COMPANY_29/monitoring/settings/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/monitoring/settings": dynamic(() => import("@/components/monitoring/MonitoringSettingsPage"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/logistics/material-status": dynamic(() => import("@/app/(main)/COMPANY_29/logistics/material-status/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/logistics/outbound": dynamic(() => import("@/app/(main)/COMPANY_29/logistics/outbound/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/logistics/inbound-outbound": dynamic(() => import("@/app/(main)/COMPANY_29/logistics/inbound-outbound/page"), { ssr: false, loading: LoadingFallback }),
|
||||
@@ -305,6 +314,7 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
||||
"/COMPANY_29/purchase/purchase-item": dynamic(() => import("@/app/(main)/COMPANY_29/purchase/purchase-item/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/purchase/supplier": dynamic(() => import("@/app/(main)/COMPANY_29/purchase/supplier/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/quality/inspection": dynamic(() => import("@/app/(main)/COMPANY_29/quality/inspection/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/quality/inspection-result": dynamic(() => import("@/app/(main)/COMPANY_29/quality/inspection-result/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/quality/item-inspection": dynamic(() => import("@/app/(main)/COMPANY_29/quality/item-inspection/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/mold/info": dynamic(() => import("@/app/(main)/COMPANY_29/mold/info/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/design/project": dynamic(() => import("@/app/(main)/COMPANY_29/design/project/page"), { ssr: false, loading: LoadingFallback }),
|
||||
@@ -330,11 +340,12 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
||||
"/COMPANY_9/production/plan-management": dynamic(() => import("@/app/(main)/COMPANY_9/production/plan-management/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_9/production/bom": dynamic(() => import("@/app/(main)/COMPANY_9/production/bom/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_9/equipment/info": dynamic(() => import("@/app/(main)/COMPANY_9/equipment/info/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_9/equipment/inspection-record": dynamic(() => import("@/app/(main)/COMPANY_9/equipment/inspection-record/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_9/equipment/plc-settings": dynamic(() => import("@/app/(main)/COMPANY_9/equipment/plc-settings/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_9/monitoring/production": dynamic(() => import("@/app/(main)/COMPANY_9/monitoring/production/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_9/monitoring/equipment": dynamic(() => import("@/app/(main)/COMPANY_9/monitoring/equipment/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_9/monitoring/quality": dynamic(() => import("@/app/(main)/COMPANY_9/monitoring/quality/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_9/monitoring/settings": dynamic(() => import("@/app/(main)/COMPANY_9/monitoring/settings/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_9/monitoring/settings": dynamic(() => import("@/components/monitoring/MonitoringSettingsPage"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_9/logistics/material-status": dynamic(() => import("@/app/(main)/COMPANY_9/logistics/material-status/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_9/logistics/outbound": dynamic(() => import("@/app/(main)/COMPANY_9/logistics/outbound/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_9/logistics/inbound-outbound": dynamic(() => import("@/app/(main)/COMPANY_9/logistics/inbound-outbound/page"), { ssr: false, loading: LoadingFallback }),
|
||||
@@ -349,6 +360,7 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
||||
"/COMPANY_9/purchase/purchase-item": dynamic(() => import("@/app/(main)/COMPANY_9/purchase/purchase-item/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_9/purchase/supplier": dynamic(() => import("@/app/(main)/COMPANY_9/purchase/supplier/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_9/quality/inspection": dynamic(() => import("@/app/(main)/COMPANY_9/quality/inspection/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_9/quality/inspection-result": dynamic(() => import("@/app/(main)/COMPANY_9/quality/inspection-result/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_9/quality/item-inspection": dynamic(() => import("@/app/(main)/COMPANY_9/quality/item-inspection/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_9/mold/info": dynamic(() => import("@/app/(main)/COMPANY_9/mold/info/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_9/design/project": dynamic(() => import("@/app/(main)/COMPANY_9/design/project/page"), { ssr: false, loading: LoadingFallback }),
|
||||
@@ -375,11 +387,12 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
||||
"/COMPANY_30/production/plan-management": dynamic(() => import("@/app/(main)/COMPANY_30/production/plan-management/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_30/production/bom": dynamic(() => import("@/app/(main)/COMPANY_30/production/bom/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_30/equipment/info": dynamic(() => import("@/app/(main)/COMPANY_30/equipment/info/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_30/equipment/inspection-record": dynamic(() => import("@/app/(main)/COMPANY_30/equipment/inspection-record/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_30/equipment/plc-settings": dynamic(() => import("@/app/(main)/COMPANY_30/equipment/plc-settings/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_30/monitoring/production": dynamic(() => import("@/app/(main)/COMPANY_30/monitoring/production/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_30/monitoring/equipment": dynamic(() => import("@/app/(main)/COMPANY_30/monitoring/equipment/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_30/monitoring/quality": dynamic(() => import("@/app/(main)/COMPANY_30/monitoring/quality/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_30/monitoring/settings": dynamic(() => import("@/app/(main)/COMPANY_30/monitoring/settings/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_30/monitoring/settings": dynamic(() => import("@/components/monitoring/MonitoringSettingsPage"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_30/logistics/material-status": dynamic(() => import("@/app/(main)/COMPANY_30/logistics/material-status/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_30/logistics/outbound": dynamic(() => import("@/app/(main)/COMPANY_30/logistics/outbound/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_30/logistics/inbound-outbound": dynamic(() => import("@/app/(main)/COMPANY_30/logistics/inbound-outbound/page"), { ssr: false, loading: LoadingFallback }),
|
||||
@@ -394,6 +407,7 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
||||
"/COMPANY_30/purchase/purchase-item": dynamic(() => import("@/app/(main)/COMPANY_30/purchase/purchase-item/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_30/purchase/supplier": dynamic(() => import("@/app/(main)/COMPANY_30/purchase/supplier/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_30/quality/inspection": dynamic(() => import("@/app/(main)/COMPANY_30/quality/inspection/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_30/quality/inspection-result": dynamic(() => import("@/app/(main)/COMPANY_30/quality/inspection-result/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_30/quality/item-inspection": dynamic(() => import("@/app/(main)/COMPANY_30/quality/item-inspection/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_30/mold/info": dynamic(() => import("@/app/(main)/COMPANY_30/mold/info/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_30/design/project": dynamic(() => import("@/app/(main)/COMPANY_30/design/project/page"), { ssr: false, loading: LoadingFallback }),
|
||||
|
||||
@@ -10,15 +10,9 @@ interface TableProps extends React.ComponentProps<"table"> {
|
||||
}
|
||||
|
||||
function Table({ className, noWrapper, divClassName, ...props }: TableProps) {
|
||||
if (noWrapper) {
|
||||
return <table data-slot="table" className={cn("w-full caption-bottom text-sm", className)} {...props} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-slot="table-container" className={cn("relative w-full overflow-x-auto", divClassName)}>
|
||||
<table data-slot="table" className={cn("w-full caption-bottom text-sm", className)} {...props} />
|
||||
</div>
|
||||
);
|
||||
// noWrapper 여부 관계없이 wrapper를 제거하여 sticky header가 부모 overflow-auto 기준으로 동작하도록 함
|
||||
// 가로 스크롤은 부모의 overflow-auto에서 처리
|
||||
return <table data-slot="table" className={cn("w-full caption-bottom text-sm", className)} {...props} />;
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
|
||||
@@ -49,7 +49,7 @@ export const V2TableListDefinition = createComponentDefinition({
|
||||
// 컬럼 설정
|
||||
columns: [],
|
||||
autoWidth: true,
|
||||
stickyHeader: false,
|
||||
stickyHeader: true,
|
||||
|
||||
// 가로 스크롤 및 컬럼 고정 설정
|
||||
horizontalScroll: {
|
||||
|
||||
Generated
+13
-3
@@ -33,6 +33,7 @@
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@react-three/drei": "^10.7.6",
|
||||
"@react-three/fiber": "^9.4.0",
|
||||
"@swc/helpers": "^0.5.21",
|
||||
"@tanstack/react-query": "^5.86.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.13.23",
|
||||
@@ -3413,9 +3414,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.15",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||
"integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
|
||||
"version": "0.5.21",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.21.tgz",
|
||||
"integrity": "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
@@ -12572,6 +12573,15 @@
|
||||
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/next/node_modules/@swc/helpers": {
|
||||
"version": "0.5.15",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||
"integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/next/node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@react-three/drei": "^10.7.6",
|
||||
"@react-three/fiber": "^9.4.0",
|
||||
"@swc/helpers": "^0.5.21",
|
||||
"@tanstack/react-query": "^5.86.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.13.23",
|
||||
|
||||
Reference in New Issue
Block a user