Merge branch 'jskim-node' of https://g.wace.me/jskim/vexplor_dev into jskim-node
This commit is contained in:
@@ -359,11 +359,21 @@ export const syncWorkInstructions = async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 미동기화 작업지시 조회: routing이 있지만 work_order_process가 없는 항목
|
// 미동기화 작업지시 조회: routing이 있지만 work_order_process가 없는 항목
|
||||||
|
// header에 routing 없으면 detail에서 가져옴 (PC가 detail에만 저장하는 경우 대응)
|
||||||
const unsyncedResult = await pool.query(
|
const unsyncedResult = await pool.query(
|
||||||
`SELECT wi.id, wi.work_instruction_no, wi.routing, wi.qty
|
`SELECT wi.id, wi.work_instruction_no,
|
||||||
|
COALESCE(wi.routing, wid.routing_version_id) AS routing,
|
||||||
|
COALESCE(NULLIF(wi.qty, ''), wid.qty) AS qty,
|
||||||
|
COALESCE(wi.item_id, (SELECT id FROM item_info WHERE item_number = wid.item_number AND company_code = $1 LIMIT 1)) AS item_id
|
||||||
FROM work_instruction wi
|
FROM work_instruction wi
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT routing_version_id, qty, item_number
|
||||||
|
FROM work_instruction_detail
|
||||||
|
WHERE work_instruction_no = wi.work_instruction_no AND company_code = $1
|
||||||
|
LIMIT 1
|
||||||
|
) wid ON true
|
||||||
WHERE wi.company_code = $1
|
WHERE wi.company_code = $1
|
||||||
AND wi.routing IS NOT NULL
|
AND COALESCE(wi.routing, wid.routing_version_id) IS NOT NULL
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM work_order_process wop
|
SELECT 1 FROM work_order_process wop
|
||||||
WHERE wop.wo_id = wi.id AND wop.company_code = $1
|
WHERE wop.wo_id = wi.id AND wop.company_code = $1
|
||||||
@@ -373,6 +383,20 @@ export const syncWorkInstructions = async (
|
|||||||
|
|
||||||
const unsynced = unsyncedResult.rows;
|
const unsynced = unsyncedResult.rows;
|
||||||
|
|
||||||
|
// header에 routing/qty/item_id가 비어있으면 자동 보정 (detail → header 동기화)
|
||||||
|
for (const wi of unsynced) {
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE work_instruction SET
|
||||||
|
routing = COALESCE(routing, $2),
|
||||||
|
qty = COALESCE(NULLIF(qty, ''), $3),
|
||||||
|
item_id = COALESCE(item_id, $4),
|
||||||
|
updated_date = NOW()
|
||||||
|
WHERE id = $1 AND company_code = $5
|
||||||
|
AND (routing IS NULL OR qty IS NULL OR qty = '' OR item_id IS NULL)`,
|
||||||
|
[wi.id, wi.routing, wi.qty, wi.item_id, companyCode],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (unsynced.length === 0) {
|
if (unsynced.length === 0) {
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -1050,7 +1050,7 @@ export async function getWarehouses(req: AuthenticatedRequest, res: Response) {
|
|||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT warehouse_code, warehouse_name, warehouse_type
|
`SELECT warehouse_code, warehouse_name, warehouse_type
|
||||||
FROM warehouse_info
|
FROM warehouse_info
|
||||||
WHERE company_code = $1 AND status != '삭제'
|
WHERE company_code = $1 AND COALESCE(status, '') != '삭제'
|
||||||
ORDER BY warehouse_name`,
|
ORDER BY warehouse_name`,
|
||||||
[companyCode],
|
[companyCode],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,351 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
ResizableHandle,
|
||||||
|
ResizablePanel,
|
||||||
|
ResizablePanelGroup,
|
||||||
|
} from "@/components/ui/resizable";
|
||||||
|
import { RefreshCw, Loader2, Inbox, Wrench, Download } from "lucide-react";
|
||||||
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||||
|
|
||||||
|
// ─── 타입 ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface InspectionRecord {
|
||||||
|
id: string;
|
||||||
|
equipment_code: string;
|
||||||
|
inspection_item_objid: string;
|
||||||
|
inspection_date: string;
|
||||||
|
status: string;
|
||||||
|
inspector: string;
|
||||||
|
remark: string;
|
||||||
|
created_date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InspectionItem {
|
||||||
|
id: string;
|
||||||
|
equipment_code: string;
|
||||||
|
inspection_item: string;
|
||||||
|
inspection_cycle: string;
|
||||||
|
inspection_content: string;
|
||||||
|
inspection_method: string;
|
||||||
|
lower_limit: string;
|
||||||
|
upper_limit: string;
|
||||||
|
unit: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EquipmentInfo {
|
||||||
|
id: string;
|
||||||
|
equipment_code: string;
|
||||||
|
equipment_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RECORD_TABLE = "equipment_inspection_record";
|
||||||
|
|
||||||
|
// ─── 메인 컴포넌트 ───────────────────────────────────────
|
||||||
|
|
||||||
|
export default function EquipmentInspectionRecordPage() {
|
||||||
|
const [records, setRecords] = useState<InspectionRecord[]>([]);
|
||||||
|
const [inspectionItems, setInspectionItems] = useState<Map<string, InspectionItem>>(new Map());
|
||||||
|
const [equipments, setEquipments] = useState<Map<string, EquipmentInfo>>(new Map());
|
||||||
|
const [categoryMap, setCategoryMap] = useState<Record<string, Record<string, string>>>({});
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [filterValues, setFilterValues] = useState<FilterValue[]>([]);
|
||||||
|
|
||||||
|
// ─── 데이터 조회 ────────────────────────────────────────
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const search: Record<string, any> = {};
|
||||||
|
filterValues.forEach((f) => {
|
||||||
|
if (f.value) search[f.column] = f.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const [recordRes, itemRes, equipRes] = await Promise.all([
|
||||||
|
apiClient.post(`/table-management/tables/${RECORD_TABLE}/data`, {
|
||||||
|
page: 1,
|
||||||
|
size: 1000,
|
||||||
|
autoFilter: true,
|
||||||
|
search,
|
||||||
|
}),
|
||||||
|
apiClient.post(`/table-management/tables/equipment_inspection_item/data`, {
|
||||||
|
page: 1,
|
||||||
|
size: 1000,
|
||||||
|
autoFilter: true,
|
||||||
|
}).catch(() => ({ data: { data: { data: [] } } })),
|
||||||
|
apiClient.post(`/table-management/tables/equipment_mng/data`, {
|
||||||
|
page: 1,
|
||||||
|
size: 500,
|
||||||
|
autoFilter: true,
|
||||||
|
}).catch(() => ({ data: { data: { data: [] } } })),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const rRows: InspectionRecord[] = recordRes.data?.data?.data ?? recordRes.data?.data?.rows ?? [];
|
||||||
|
setRecords(rRows);
|
||||||
|
|
||||||
|
const iRows: InspectionItem[] = itemRes.data?.data?.data ?? itemRes.data?.data?.rows ?? [];
|
||||||
|
const iMap = new Map<string, InspectionItem>();
|
||||||
|
iRows.forEach((i) => iMap.set(i.id, i));
|
||||||
|
setInspectionItems(iMap);
|
||||||
|
|
||||||
|
const eRows: EquipmentInfo[] = equipRes.data?.data?.data ?? equipRes.data?.data?.rows ?? [];
|
||||||
|
const eMap = new Map<string, EquipmentInfo>();
|
||||||
|
eRows.forEach((e) => {
|
||||||
|
eMap.set(e.equipment_code, e);
|
||||||
|
eMap.set(e.id, e);
|
||||||
|
});
|
||||||
|
setEquipments(eMap);
|
||||||
|
|
||||||
|
// 카테고리 코드→라벨 매핑 (점검주기, 점검방법)
|
||||||
|
try {
|
||||||
|
const catCols = ["inspection_cycle", "inspection_method"];
|
||||||
|
const catResults = await Promise.all(
|
||||||
|
catCols.map((col) =>
|
||||||
|
apiClient.get(`/table-categories/equipment_inspection_item/${col}/values`).catch(() => ({ data: [] })),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const cMap: Record<string, Record<string, string>> = {};
|
||||||
|
catCols.forEach((col, idx) => {
|
||||||
|
const vals: { code: string; label: string }[] = catResults[idx].data?.data ?? catResults[idx].data ?? [];
|
||||||
|
cMap[col] = {};
|
||||||
|
vals.forEach((v: any) => { cMap[col][v.valueCode || v.code] = v.valueLabel || v.label; });
|
||||||
|
});
|
||||||
|
setCategoryMap(cMap);
|
||||||
|
} catch {
|
||||||
|
// 카테고리 조회 실패 무시
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("점검기록 조회 실패:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [filterValues]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
// ─── 선택된 레코드 ─────────────────────────────────────
|
||||||
|
|
||||||
|
const selectedRecord = useMemo(() => records.find((r) => r.id === selectedId), [records, selectedId]);
|
||||||
|
const selectedItem = useMemo(
|
||||||
|
() => (selectedRecord ? inspectionItems.get(selectedRecord.inspection_item_objid) : undefined),
|
||||||
|
[selectedRecord, inspectionItems],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── 설비명 조회 ───────────────────────────────────────
|
||||||
|
|
||||||
|
const getEquipName = (code: string) => equipments.get(code)?.equipment_name || code || "-";
|
||||||
|
|
||||||
|
// ─── 카테고리 코드→라벨 변환 ──────────────────────────
|
||||||
|
|
||||||
|
const resolveCategory = (col: string, code: string) => {
|
||||||
|
if (!code) return "-";
|
||||||
|
return categoryMap[col]?.[code] || code;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 엑셀 다운로드 ─────────────────────────────────────
|
||||||
|
|
||||||
|
const handleExcel = async () => {
|
||||||
|
const data = records.map((r) => ({
|
||||||
|
설비코드: r.equipment_code,
|
||||||
|
설비명: getEquipName(r.equipment_code),
|
||||||
|
점검항목: inspectionItems.get(r.inspection_item_objid)?.inspection_item || "-",
|
||||||
|
점검일자: fmtDate(r.inspection_date),
|
||||||
|
상태: r.status,
|
||||||
|
점검자: r.inspector,
|
||||||
|
비고: r.remark,
|
||||||
|
}));
|
||||||
|
await exportToExcel(data, "설비점검기록.xlsx", "점검기록");
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 날짜/상태 포맷 ────────────────────────────────────
|
||||||
|
|
||||||
|
const fmtDate = (d: string) => {
|
||||||
|
if (!d) return "-";
|
||||||
|
try {
|
||||||
|
return new Date(d).toLocaleDateString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit" });
|
||||||
|
} catch {
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusBadge = (v: string) => {
|
||||||
|
if (!v) return <Badge variant="outline" className="text-xs">-</Badge>;
|
||||||
|
const lower = v.toLowerCase();
|
||||||
|
if (["완료", "pass", "정상", "합격", "completed"].includes(lower))
|
||||||
|
return <Badge className="bg-emerald-500/10 text-emerald-600 border-emerald-200 text-xs">{v}</Badge>;
|
||||||
|
if (["이상", "fail", "불합격", "비정상"].includes(lower))
|
||||||
|
return <Badge className="bg-red-500/10 text-red-600 border-red-200 text-xs">{v}</Badge>;
|
||||||
|
if (["점검중", "진행", "in_progress"].includes(lower))
|
||||||
|
return <Badge className="bg-blue-500/10 text-blue-600 border-blue-200 text-xs">{v}</Badge>;
|
||||||
|
return <Badge variant="outline" className="text-xs">{v}</Badge>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 렌더링 ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
{/* 검색 필터 */}
|
||||||
|
<div className="shrink-0">
|
||||||
|
<DynamicSearchFilter
|
||||||
|
tableName={RECORD_TABLE}
|
||||||
|
filterId="equip-inspection-record"
|
||||||
|
onFilterChange={(filters) => setFilterValues(filters)}
|
||||||
|
dataCount={records.length}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between border-b px-4 py-2.5 shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Wrench className="h-5 w-5 text-primary" />
|
||||||
|
<h2 className="text-base font-bold">점검관리</h2>
|
||||||
|
<Badge variant="secondary">{records.length}건</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleExcel}>
|
||||||
|
<Download className="h-4 w-4 mr-1" />
|
||||||
|
엑셀 다운로드
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={fetchData} disabled={loading}>
|
||||||
|
<RefreshCw className={cn("h-4 w-4 mr-1", loading && "animate-spin")} />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 분할 패널 */}
|
||||||
|
<ResizablePanelGroup direction="horizontal" className="flex-1 min-h-0">
|
||||||
|
{/* 좌측: 점검기록 테이블 */}
|
||||||
|
<ResizablePanel defaultSize={60} minSize={35}>
|
||||||
|
<div className="h-full overflow-auto">
|
||||||
|
{loading && records.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin mb-3" />
|
||||||
|
<span className="text-sm">데이터를 불러오는 중...</span>
|
||||||
|
</div>
|
||||||
|
) : records.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||||
|
<Inbox className="h-10 w-10 mb-3" />
|
||||||
|
<span className="text-sm">점검 기록이 없습니다</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table noWrapper className="min-w-max">
|
||||||
|
<TableHeader className="sticky top-0 z-10 bg-muted shadow-[0_1px_0_0_hsl(var(--border))]">
|
||||||
|
<TableRow className="hover:bg-muted">
|
||||||
|
<TableHead className="w-10 text-center">#</TableHead>
|
||||||
|
<TableHead className="min-w-[100px]">설비코드</TableHead>
|
||||||
|
<TableHead className="min-w-[120px]">설비명</TableHead>
|
||||||
|
<TableHead className="min-w-[140px]">점검항목</TableHead>
|
||||||
|
<TableHead className="min-w-[100px]">점검일자</TableHead>
|
||||||
|
<TableHead className="min-w-[70px] text-center">상태</TableHead>
|
||||||
|
<TableHead className="min-w-[80px]">점검자</TableHead>
|
||||||
|
<TableHead className="min-w-[150px]">비고</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{records.map((row, idx) => {
|
||||||
|
const item = inspectionItems.get(row.inspection_item_objid);
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
className={cn("cursor-pointer", selectedId === row.id && "bg-primary/5")}
|
||||||
|
onClick={() => setSelectedId(row.id)}
|
||||||
|
>
|
||||||
|
<TableCell className="text-center text-muted-foreground text-xs">{idx + 1}</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm">{row.equipment_code || "-"}</TableCell>
|
||||||
|
<TableCell className="text-sm">{getEquipName(row.equipment_code)}</TableCell>
|
||||||
|
<TableCell className="text-sm">{item?.inspection_item || "-"}</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{fmtDate(row.inspection_date)}</TableCell>
|
||||||
|
<TableCell className="text-center">{statusBadge(row.status)}</TableCell>
|
||||||
|
<TableCell className="text-sm">{row.inspector || "-"}</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{row.remark || "-"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
|
||||||
|
<ResizableHandle withHandle />
|
||||||
|
|
||||||
|
{/* 우측: 점검항목 상세 */}
|
||||||
|
<ResizablePanel defaultSize={40} minSize={25}>
|
||||||
|
{!selectedId || !selectedRecord ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
|
||||||
|
<Inbox className="w-10 h-10 text-muted-foreground/40" />
|
||||||
|
<p className="text-sm">좌측에서 점검 기록을 선택해주세요</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col h-full overflow-auto">
|
||||||
|
{/* 점검 기록 요약 */}
|
||||||
|
<div className="px-4 py-3 border-b bg-muted/50 shrink-0">
|
||||||
|
<h3 className="text-[13px] font-bold mb-2">점검 기록 정보</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<DetailRow label="설비" value={getEquipName(selectedRecord.equipment_code)} />
|
||||||
|
<DetailRow label="설비코드" value={selectedRecord.equipment_code} />
|
||||||
|
<DetailRow label="점검일자" value={fmtDate(selectedRecord.inspection_date)} />
|
||||||
|
<DetailRow label="점검자" value={selectedRecord.inspector || "-"} />
|
||||||
|
<DetailRow label="상태" value={selectedRecord.status || "-"} badge={statusBadge(selectedRecord.status)} />
|
||||||
|
<DetailRow label="비고" value={selectedRecord.remark || "-"} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 점검 항목 상세 */}
|
||||||
|
{selectedItem ? (
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<h3 className="text-[13px] font-bold mb-2">점검 항목 상세</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<DetailRow label="점검항목" value={selectedItem.inspection_item || "-"} />
|
||||||
|
<DetailRow label="점검주기" value={resolveCategory("inspection_cycle", selectedItem.inspection_cycle)} />
|
||||||
|
<DetailRow label="점검내용" value={selectedItem.inspection_content || "-"} span2 />
|
||||||
|
<DetailRow label="점검방법" value={resolveCategory("inspection_method", selectedItem.inspection_method)} span2 />
|
||||||
|
<DetailRow label="하한치" value={selectedItem.lower_limit || "-"} />
|
||||||
|
<DetailRow label="상한치" value={selectedItem.upper_limit || "-"} />
|
||||||
|
<DetailRow label="단위" value={selectedItem.unit || "-"} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||||
|
<p className="text-sm">점검 항목 정보가 없습니다</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 상세 행 ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
function DetailRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
badge,
|
||||||
|
span2,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
badge?: React.ReactNode;
|
||||||
|
span2?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-col gap-0.5", span2 && "col-span-2")}>
|
||||||
|
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">{label}</span>
|
||||||
|
{badge ? <div className="w-fit">{badge}</div> : <span className="text-xs">{value}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
ResizableHandle,
|
||||||
|
ResizablePanel,
|
||||||
|
ResizablePanelGroup,
|
||||||
|
} from "@/components/ui/resizable";
|
||||||
|
import { RefreshCw, Loader2, Inbox, ClipboardCheck, Download } from "lucide-react";
|
||||||
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||||
|
|
||||||
|
// ─── 타입 ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface InspectionMng {
|
||||||
|
id: string;
|
||||||
|
inspection_number: string;
|
||||||
|
item_code: string;
|
||||||
|
item_name: string;
|
||||||
|
inspection_type: string;
|
||||||
|
total_qty: number;
|
||||||
|
good_qty: number;
|
||||||
|
bad_qty: number;
|
||||||
|
overall_judgment: string;
|
||||||
|
inspector: string;
|
||||||
|
inspection_date: string;
|
||||||
|
is_completed: string;
|
||||||
|
defect_description: string;
|
||||||
|
memo: string;
|
||||||
|
supplier_name: string;
|
||||||
|
supplier_code: string;
|
||||||
|
created_date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InspectionDetail {
|
||||||
|
id: string;
|
||||||
|
master_id: string;
|
||||||
|
inspection_item_name: string;
|
||||||
|
inspection_standard: string;
|
||||||
|
pass_criteria: string;
|
||||||
|
measured_value: string;
|
||||||
|
judgment: string;
|
||||||
|
is_required: string;
|
||||||
|
memo: string;
|
||||||
|
item_code: string;
|
||||||
|
item_name: string;
|
||||||
|
inspection_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MASTER_TABLE = "inspection_result_mng";
|
||||||
|
const DETAIL_TABLE = "inspection_result";
|
||||||
|
|
||||||
|
// ─── 메인 컴포넌트 ───────────────────────────────────────
|
||||||
|
|
||||||
|
export default function InspectionResultPage() {
|
||||||
|
const [masterData, setMasterData] = useState<InspectionMng[]>([]);
|
||||||
|
const [detailData, setDetailData] = useState<InspectionDetail[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [filterValues, setFilterValues] = useState<FilterValue[]>([]);
|
||||||
|
|
||||||
|
// ─── 마스터 데이터 조회 ─────────────────────────────────
|
||||||
|
|
||||||
|
const fetchMaster = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const search: Record<string, any> = {};
|
||||||
|
filterValues.forEach((f) => {
|
||||||
|
if (f.value) search[f.column] = f.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
|
||||||
|
page: 1,
|
||||||
|
size: 1000,
|
||||||
|
autoFilter: true,
|
||||||
|
search,
|
||||||
|
});
|
||||||
|
const rows: InspectionMng[] = res.data?.data?.data ?? res.data?.data?.rows ?? [];
|
||||||
|
setMasterData(rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("검사결과 조회 실패:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [filterValues]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMaster();
|
||||||
|
}, [fetchMaster]);
|
||||||
|
|
||||||
|
// ─── 디테일 데이터 조회 ─────────────────────────────────
|
||||||
|
|
||||||
|
const fetchDetail = useCallback(async (masterId: string) => {
|
||||||
|
setDetailLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
|
||||||
|
page: 1,
|
||||||
|
size: 500,
|
||||||
|
autoFilter: true,
|
||||||
|
search: { master_id: masterId },
|
||||||
|
});
|
||||||
|
const rows: InspectionDetail[] = res.data?.data?.data ?? res.data?.data?.rows ?? [];
|
||||||
|
setDetailData(rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("검사상세 조회 실패:", err);
|
||||||
|
setDetailData([]);
|
||||||
|
} finally {
|
||||||
|
setDetailLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedId) fetchDetail(selectedId);
|
||||||
|
else setDetailData([]);
|
||||||
|
}, [selectedId, fetchDetail]);
|
||||||
|
|
||||||
|
// ─── 선택된 마스터 ──────────────────────────────────────
|
||||||
|
|
||||||
|
const selectedMaster = useMemo(
|
||||||
|
() => masterData.find((m) => m.id === selectedId),
|
||||||
|
[masterData, selectedId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── 엑셀 다운로드 ─────────────────────────────────────
|
||||||
|
|
||||||
|
const handleExcel = async () => {
|
||||||
|
await exportToExcel(masterData, "검사결과관리.xlsx", "검사결과");
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 판정 배지 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
const judgmentBadge = (v: string) => {
|
||||||
|
if (!v) return null;
|
||||||
|
const lower = v.toLowerCase();
|
||||||
|
if (["합격", "pass", "적합"].includes(lower))
|
||||||
|
return <Badge className="bg-emerald-500/10 text-emerald-600 border-emerald-200">{v}</Badge>;
|
||||||
|
if (["불합격", "fail", "부적합"].includes(lower))
|
||||||
|
return <Badge className="bg-red-500/10 text-red-600 border-red-200">{v}</Badge>;
|
||||||
|
return <Badge variant="outline">{v}</Badge>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 날짜 포맷 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
const fmtDate = (d: string) => {
|
||||||
|
if (!d) return "-";
|
||||||
|
try {
|
||||||
|
return new Date(d).toLocaleDateString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit" });
|
||||||
|
} catch {
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 렌더링 ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
{/* 검색 필터 */}
|
||||||
|
<div className="shrink-0">
|
||||||
|
<DynamicSearchFilter
|
||||||
|
tableName={MASTER_TABLE}
|
||||||
|
filterId="inspection-result-mng"
|
||||||
|
onFilterChange={(filters) => {
|
||||||
|
setFilterValues(filters);
|
||||||
|
}}
|
||||||
|
dataCount={masterData.length}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between border-b px-4 py-2.5 shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ClipboardCheck className="h-5 w-5 text-primary" />
|
||||||
|
<h2 className="text-base font-bold">검사관리</h2>
|
||||||
|
<Badge variant="secondary">{masterData.length}건</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleExcel}>
|
||||||
|
<Download className="h-4 w-4 mr-1" />
|
||||||
|
엑셀 다운로드
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={fetchMaster} disabled={loading}>
|
||||||
|
<RefreshCw className={cn("h-4 w-4 mr-1", loading && "animate-spin")} />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 분할 패널 */}
|
||||||
|
<ResizablePanelGroup direction="horizontal" className="flex-1 min-h-0">
|
||||||
|
{/* 좌측: 마스터 테이블 */}
|
||||||
|
<ResizablePanel defaultSize={55} minSize={30}>
|
||||||
|
<div className="h-full overflow-auto">
|
||||||
|
{loading && masterData.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin mb-3" />
|
||||||
|
<span className="text-sm">데이터를 불러오는 중...</span>
|
||||||
|
</div>
|
||||||
|
) : masterData.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||||
|
<Inbox className="h-10 w-10 mb-3" />
|
||||||
|
<span className="text-sm">검사 데이터가 없습니다</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table noWrapper className="min-w-max">
|
||||||
|
<TableHeader className="sticky top-0 z-10 bg-muted shadow-[0_1px_0_0_hsl(var(--border))]">
|
||||||
|
<TableRow className="hover:bg-muted">
|
||||||
|
<TableHead className="w-10 text-center">#</TableHead>
|
||||||
|
<TableHead className="min-w-[120px]">검사번호</TableHead>
|
||||||
|
<TableHead className="min-w-[80px]">검사유형</TableHead>
|
||||||
|
<TableHead className="min-w-[120px]">품목코드</TableHead>
|
||||||
|
<TableHead className="min-w-[140px]">품목명</TableHead>
|
||||||
|
<TableHead className="min-w-[80px] text-right">검사수량</TableHead>
|
||||||
|
<TableHead className="min-w-[80px] text-right">양품</TableHead>
|
||||||
|
<TableHead className="min-w-[80px] text-right">불량</TableHead>
|
||||||
|
<TableHead className="min-w-[70px] text-center">판정</TableHead>
|
||||||
|
<TableHead className="min-w-[80px]">검사자</TableHead>
|
||||||
|
<TableHead className="min-w-[100px]">검사일자</TableHead>
|
||||||
|
<TableHead className="min-w-[80px] text-center">완료</TableHead>
|
||||||
|
<TableHead className="min-w-[120px]">거래처</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{masterData.map((row, idx) => (
|
||||||
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer",
|
||||||
|
selectedId === row.id && "bg-primary/5",
|
||||||
|
)}
|
||||||
|
onClick={() => setSelectedId(row.id)}
|
||||||
|
>
|
||||||
|
<TableCell className="text-center text-muted-foreground text-xs">{idx + 1}</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm">{row.inspection_number || "-"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline" className="text-xs">{row.inspection_type || "-"}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm">{row.item_code || "-"}</TableCell>
|
||||||
|
<TableCell className="text-sm">{row.item_name || "-"}</TableCell>
|
||||||
|
<TableCell className="text-right text-sm">{row.total_qty ?? "-"}</TableCell>
|
||||||
|
<TableCell className="text-right text-sm text-emerald-600">{row.good_qty ?? "-"}</TableCell>
|
||||||
|
<TableCell className="text-right text-sm text-red-600">{row.bad_qty ?? "-"}</TableCell>
|
||||||
|
<TableCell className="text-center">{judgmentBadge(row.overall_judgment)}</TableCell>
|
||||||
|
<TableCell className="text-sm">{row.inspector || "-"}</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{fmtDate(row.inspection_date)}</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
{row.is_completed === "Y" ? (
|
||||||
|
<Badge className="bg-emerald-500/10 text-emerald-600 border-emerald-200 text-xs">완료</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="text-xs">진행중</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">{row.supplier_name || "-"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
|
||||||
|
<ResizableHandle withHandle />
|
||||||
|
|
||||||
|
{/* 우측: 디테일 */}
|
||||||
|
<ResizablePanel defaultSize={45} minSize={25}>
|
||||||
|
{!selectedId ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
|
||||||
|
<Inbox className="w-10 h-10 text-muted-foreground/40" />
|
||||||
|
<p className="text-sm">좌측에서 검사를 선택해주세요</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* 상세 헤더 */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/50 shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="text-[13px] font-bold">검사 상세</h3>
|
||||||
|
{selectedMaster && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{selectedMaster.inspection_number}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{selectedMaster && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>{selectedMaster.item_name}</span>
|
||||||
|
<span>·</span>
|
||||||
|
{judgmentBadge(selectedMaster.overall_judgment)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 상세 테이블 */}
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{detailLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-16 text-muted-foreground">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : detailData.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||||
|
<Inbox className="h-8 w-8 mb-2 opacity-40" />
|
||||||
|
<p className="text-sm">검사 항목이 없습니다</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table noWrapper>
|
||||||
|
<TableHeader className="sticky top-0 z-10 bg-muted shadow-[0_1px_0_0_hsl(var(--border))]">
|
||||||
|
<TableRow className="hover:bg-muted">
|
||||||
|
<TableHead className="w-10 text-center">#</TableHead>
|
||||||
|
<TableHead className="min-w-[140px]">검사항목</TableHead>
|
||||||
|
<TableHead className="min-w-[120px]">검사기준</TableHead>
|
||||||
|
<TableHead className="min-w-[100px]">합격기준</TableHead>
|
||||||
|
<TableHead className="min-w-[90px]">측정값</TableHead>
|
||||||
|
<TableHead className="min-w-[70px] text-center">판정</TableHead>
|
||||||
|
<TableHead className="min-w-[60px] text-center">필수</TableHead>
|
||||||
|
<TableHead className="min-w-[120px]">비고</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{detailData.map((d, idx) => (
|
||||||
|
<TableRow key={d.id}>
|
||||||
|
<TableCell className="text-center text-muted-foreground text-xs">{idx + 1}</TableCell>
|
||||||
|
<TableCell className="text-sm font-medium">{d.inspection_item_name || "-"}</TableCell>
|
||||||
|
<TableCell className="text-sm">{d.inspection_standard || "-"}</TableCell>
|
||||||
|
<TableCell className="text-sm">{d.pass_criteria || "-"}</TableCell>
|
||||||
|
<TableCell className="text-sm font-mono">{d.measured_value || "-"}</TableCell>
|
||||||
|
<TableCell className="text-center">{judgmentBadge(d.judgment)}</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
{d.is_required === "Y" ? (
|
||||||
|
<Badge className="bg-amber-500/10 text-amber-600 border-amber-200 text-xs">필수</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{d.memo || "-"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
ResizableHandle,
|
||||||
|
ResizablePanel,
|
||||||
|
ResizablePanelGroup,
|
||||||
|
} from "@/components/ui/resizable";
|
||||||
|
import { RefreshCw, Loader2, Inbox, Wrench, Download } from "lucide-react";
|
||||||
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||||
|
|
||||||
|
// ─── 타입 ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface InspectionRecord {
|
||||||
|
id: string;
|
||||||
|
equipment_code: string;
|
||||||
|
inspection_item_objid: string;
|
||||||
|
inspection_date: string;
|
||||||
|
status: string;
|
||||||
|
inspector: string;
|
||||||
|
remark: string;
|
||||||
|
created_date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InspectionItem {
|
||||||
|
id: string;
|
||||||
|
equipment_code: string;
|
||||||
|
inspection_item: string;
|
||||||
|
inspection_cycle: string;
|
||||||
|
inspection_content: string;
|
||||||
|
inspection_method: string;
|
||||||
|
lower_limit: string;
|
||||||
|
upper_limit: string;
|
||||||
|
unit: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EquipmentInfo {
|
||||||
|
id: string;
|
||||||
|
equipment_code: string;
|
||||||
|
equipment_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RECORD_TABLE = "equipment_inspection_record";
|
||||||
|
|
||||||
|
// ─── 메인 컴포넌트 ───────────────────────────────────────
|
||||||
|
|
||||||
|
export default function EquipmentInspectionRecordPage() {
|
||||||
|
const [records, setRecords] = useState<InspectionRecord[]>([]);
|
||||||
|
const [inspectionItems, setInspectionItems] = useState<Map<string, InspectionItem>>(new Map());
|
||||||
|
const [equipments, setEquipments] = useState<Map<string, EquipmentInfo>>(new Map());
|
||||||
|
const [categoryMap, setCategoryMap] = useState<Record<string, Record<string, string>>>({});
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [filterValues, setFilterValues] = useState<FilterValue[]>([]);
|
||||||
|
|
||||||
|
// ─── 데이터 조회 ────────────────────────────────────────
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const search: Record<string, any> = {};
|
||||||
|
filterValues.forEach((f) => {
|
||||||
|
if (f.value) search[f.column] = f.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const [recordRes, itemRes, equipRes] = await Promise.all([
|
||||||
|
apiClient.post(`/table-management/tables/${RECORD_TABLE}/data`, {
|
||||||
|
page: 1,
|
||||||
|
size: 1000,
|
||||||
|
autoFilter: true,
|
||||||
|
search,
|
||||||
|
}),
|
||||||
|
apiClient.post(`/table-management/tables/equipment_inspection_item/data`, {
|
||||||
|
page: 1,
|
||||||
|
size: 1000,
|
||||||
|
autoFilter: true,
|
||||||
|
}).catch(() => ({ data: { data: { data: [] } } })),
|
||||||
|
apiClient.post(`/table-management/tables/equipment_mng/data`, {
|
||||||
|
page: 1,
|
||||||
|
size: 500,
|
||||||
|
autoFilter: true,
|
||||||
|
}).catch(() => ({ data: { data: { data: [] } } })),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const rRows: InspectionRecord[] = recordRes.data?.data?.data ?? recordRes.data?.data?.rows ?? [];
|
||||||
|
setRecords(rRows);
|
||||||
|
|
||||||
|
const iRows: InspectionItem[] = itemRes.data?.data?.data ?? itemRes.data?.data?.rows ?? [];
|
||||||
|
const iMap = new Map<string, InspectionItem>();
|
||||||
|
iRows.forEach((i) => iMap.set(i.id, i));
|
||||||
|
setInspectionItems(iMap);
|
||||||
|
|
||||||
|
const eRows: EquipmentInfo[] = equipRes.data?.data?.data ?? equipRes.data?.data?.rows ?? [];
|
||||||
|
const eMap = new Map<string, EquipmentInfo>();
|
||||||
|
eRows.forEach((e) => {
|
||||||
|
eMap.set(e.equipment_code, e);
|
||||||
|
eMap.set(e.id, e);
|
||||||
|
});
|
||||||
|
setEquipments(eMap);
|
||||||
|
|
||||||
|
// 카테고리 코드→라벨 매핑 (점검주기, 점검방법)
|
||||||
|
try {
|
||||||
|
const catCols = ["inspection_cycle", "inspection_method"];
|
||||||
|
const catResults = await Promise.all(
|
||||||
|
catCols.map((col) =>
|
||||||
|
apiClient.get(`/table-categories/equipment_inspection_item/${col}/values`).catch(() => ({ data: [] })),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const cMap: Record<string, Record<string, string>> = {};
|
||||||
|
catCols.forEach((col, idx) => {
|
||||||
|
const vals: { code: string; label: string }[] = catResults[idx].data?.data ?? catResults[idx].data ?? [];
|
||||||
|
cMap[col] = {};
|
||||||
|
vals.forEach((v: any) => { cMap[col][v.valueCode || v.code] = v.valueLabel || v.label; });
|
||||||
|
});
|
||||||
|
setCategoryMap(cMap);
|
||||||
|
} catch {
|
||||||
|
// 카테고리 조회 실패 무시
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("점검기록 조회 실패:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [filterValues]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
// ─── 선택된 레코드 ─────────────────────────────────────
|
||||||
|
|
||||||
|
const selectedRecord = useMemo(() => records.find((r) => r.id === selectedId), [records, selectedId]);
|
||||||
|
const selectedItem = useMemo(
|
||||||
|
() => (selectedRecord ? inspectionItems.get(selectedRecord.inspection_item_objid) : undefined),
|
||||||
|
[selectedRecord, inspectionItems],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── 설비명 조회 ───────────────────────────────────────
|
||||||
|
|
||||||
|
const getEquipName = (code: string) => equipments.get(code)?.equipment_name || code || "-";
|
||||||
|
|
||||||
|
// ─── 카테고리 코드→라벨 변환 ──────────────────────────
|
||||||
|
|
||||||
|
const resolveCategory = (col: string, code: string) => {
|
||||||
|
if (!code) return "-";
|
||||||
|
return categoryMap[col]?.[code] || code;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 엑셀 다운로드 ─────────────────────────────────────
|
||||||
|
|
||||||
|
const handleExcel = async () => {
|
||||||
|
const data = records.map((r) => ({
|
||||||
|
설비코드: r.equipment_code,
|
||||||
|
설비명: getEquipName(r.equipment_code),
|
||||||
|
점검항목: inspectionItems.get(r.inspection_item_objid)?.inspection_item || "-",
|
||||||
|
점검일자: fmtDate(r.inspection_date),
|
||||||
|
상태: r.status,
|
||||||
|
점검자: r.inspector,
|
||||||
|
비고: r.remark,
|
||||||
|
}));
|
||||||
|
await exportToExcel(data, "설비점검기록.xlsx", "점검기록");
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 날짜/상태 포맷 ────────────────────────────────────
|
||||||
|
|
||||||
|
const fmtDate = (d: string) => {
|
||||||
|
if (!d) return "-";
|
||||||
|
try {
|
||||||
|
return new Date(d).toLocaleDateString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit" });
|
||||||
|
} catch {
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusBadge = (v: string) => {
|
||||||
|
if (!v) return <Badge variant="outline" className="text-xs">-</Badge>;
|
||||||
|
const lower = v.toLowerCase();
|
||||||
|
if (["완료", "pass", "정상", "합격", "completed"].includes(lower))
|
||||||
|
return <Badge className="bg-emerald-500/10 text-emerald-600 border-emerald-200 text-xs">{v}</Badge>;
|
||||||
|
if (["이상", "fail", "불합격", "비정상"].includes(lower))
|
||||||
|
return <Badge className="bg-red-500/10 text-red-600 border-red-200 text-xs">{v}</Badge>;
|
||||||
|
if (["점검중", "진행", "in_progress"].includes(lower))
|
||||||
|
return <Badge className="bg-blue-500/10 text-blue-600 border-blue-200 text-xs">{v}</Badge>;
|
||||||
|
return <Badge variant="outline" className="text-xs">{v}</Badge>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 렌더링 ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
{/* 검색 필터 */}
|
||||||
|
<div className="shrink-0">
|
||||||
|
<DynamicSearchFilter
|
||||||
|
tableName={RECORD_TABLE}
|
||||||
|
filterId="equip-inspection-record"
|
||||||
|
onFilterChange={(filters) => setFilterValues(filters)}
|
||||||
|
dataCount={records.length}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between border-b px-4 py-2.5 shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Wrench className="h-5 w-5 text-primary" />
|
||||||
|
<h2 className="text-base font-bold">점검관리</h2>
|
||||||
|
<Badge variant="secondary">{records.length}건</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleExcel}>
|
||||||
|
<Download className="h-4 w-4 mr-1" />
|
||||||
|
엑셀 다운로드
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={fetchData} disabled={loading}>
|
||||||
|
<RefreshCw className={cn("h-4 w-4 mr-1", loading && "animate-spin")} />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 분할 패널 */}
|
||||||
|
<ResizablePanelGroup direction="horizontal" className="flex-1 min-h-0">
|
||||||
|
{/* 좌측: 점검기록 테이블 */}
|
||||||
|
<ResizablePanel defaultSize={60} minSize={35}>
|
||||||
|
<div className="h-full overflow-auto">
|
||||||
|
{loading && records.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin mb-3" />
|
||||||
|
<span className="text-sm">데이터를 불러오는 중...</span>
|
||||||
|
</div>
|
||||||
|
) : records.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||||
|
<Inbox className="h-10 w-10 mb-3" />
|
||||||
|
<span className="text-sm">점검 기록이 없습니다</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table noWrapper className="min-w-max">
|
||||||
|
<TableHeader className="sticky top-0 z-10 bg-muted shadow-[0_1px_0_0_hsl(var(--border))]">
|
||||||
|
<TableRow className="hover:bg-muted">
|
||||||
|
<TableHead className="w-10 text-center">#</TableHead>
|
||||||
|
<TableHead className="min-w-[100px]">설비코드</TableHead>
|
||||||
|
<TableHead className="min-w-[120px]">설비명</TableHead>
|
||||||
|
<TableHead className="min-w-[140px]">점검항목</TableHead>
|
||||||
|
<TableHead className="min-w-[100px]">점검일자</TableHead>
|
||||||
|
<TableHead className="min-w-[70px] text-center">상태</TableHead>
|
||||||
|
<TableHead className="min-w-[80px]">점검자</TableHead>
|
||||||
|
<TableHead className="min-w-[150px]">비고</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{records.map((row, idx) => {
|
||||||
|
const item = inspectionItems.get(row.inspection_item_objid);
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
className={cn("cursor-pointer", selectedId === row.id && "bg-primary/5")}
|
||||||
|
onClick={() => setSelectedId(row.id)}
|
||||||
|
>
|
||||||
|
<TableCell className="text-center text-muted-foreground text-xs">{idx + 1}</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm">{row.equipment_code || "-"}</TableCell>
|
||||||
|
<TableCell className="text-sm">{getEquipName(row.equipment_code)}</TableCell>
|
||||||
|
<TableCell className="text-sm">{item?.inspection_item || "-"}</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{fmtDate(row.inspection_date)}</TableCell>
|
||||||
|
<TableCell className="text-center">{statusBadge(row.status)}</TableCell>
|
||||||
|
<TableCell className="text-sm">{row.inspector || "-"}</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{row.remark || "-"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
|
||||||
|
<ResizableHandle withHandle />
|
||||||
|
|
||||||
|
{/* 우측: 점검항목 상세 */}
|
||||||
|
<ResizablePanel defaultSize={40} minSize={25}>
|
||||||
|
{!selectedId || !selectedRecord ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
|
||||||
|
<Inbox className="w-10 h-10 text-muted-foreground/40" />
|
||||||
|
<p className="text-sm">좌측에서 점검 기록을 선택해주세요</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col h-full overflow-auto">
|
||||||
|
{/* 점검 기록 요약 */}
|
||||||
|
<div className="px-4 py-3 border-b bg-muted/50 shrink-0">
|
||||||
|
<h3 className="text-[13px] font-bold mb-2">점검 기록 정보</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<DetailRow label="설비" value={getEquipName(selectedRecord.equipment_code)} />
|
||||||
|
<DetailRow label="설비코드" value={selectedRecord.equipment_code} />
|
||||||
|
<DetailRow label="점검일자" value={fmtDate(selectedRecord.inspection_date)} />
|
||||||
|
<DetailRow label="점검자" value={selectedRecord.inspector || "-"} />
|
||||||
|
<DetailRow label="상태" value={selectedRecord.status || "-"} badge={statusBadge(selectedRecord.status)} />
|
||||||
|
<DetailRow label="비고" value={selectedRecord.remark || "-"} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 점검 항목 상세 */}
|
||||||
|
{selectedItem ? (
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<h3 className="text-[13px] font-bold mb-2">점검 항목 상세</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<DetailRow label="점검항목" value={selectedItem.inspection_item || "-"} />
|
||||||
|
<DetailRow label="점검주기" value={resolveCategory("inspection_cycle", selectedItem.inspection_cycle)} />
|
||||||
|
<DetailRow label="점검내용" value={selectedItem.inspection_content || "-"} span2 />
|
||||||
|
<DetailRow label="점검방법" value={resolveCategory("inspection_method", selectedItem.inspection_method)} span2 />
|
||||||
|
<DetailRow label="하한치" value={selectedItem.lower_limit || "-"} />
|
||||||
|
<DetailRow label="상한치" value={selectedItem.upper_limit || "-"} />
|
||||||
|
<DetailRow label="단위" value={selectedItem.unit || "-"} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||||
|
<p className="text-sm">점검 항목 정보가 없습니다</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 상세 행 ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
function DetailRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
badge,
|
||||||
|
span2,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
badge?: React.ReactNode;
|
||||||
|
span2?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-col gap-0.5", span2 && "col-span-2")}>
|
||||||
|
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">{label}</span>
|
||||||
|
{badge ? <div className="w-fit">{badge}</div> : <span className="text-xs">{value}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
ResizableHandle,
|
||||||
|
ResizablePanel,
|
||||||
|
ResizablePanelGroup,
|
||||||
|
} from "@/components/ui/resizable";
|
||||||
|
import { RefreshCw, Loader2, Inbox, ClipboardCheck, Download } from "lucide-react";
|
||||||
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||||
|
|
||||||
|
// ─── 타입 ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface InspectionMng {
|
||||||
|
id: string;
|
||||||
|
inspection_number: string;
|
||||||
|
item_code: string;
|
||||||
|
item_name: string;
|
||||||
|
inspection_type: string;
|
||||||
|
total_qty: number;
|
||||||
|
good_qty: number;
|
||||||
|
bad_qty: number;
|
||||||
|
overall_judgment: string;
|
||||||
|
inspector: string;
|
||||||
|
inspection_date: string;
|
||||||
|
is_completed: string;
|
||||||
|
defect_description: string;
|
||||||
|
memo: string;
|
||||||
|
supplier_name: string;
|
||||||
|
supplier_code: string;
|
||||||
|
created_date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InspectionDetail {
|
||||||
|
id: string;
|
||||||
|
master_id: string;
|
||||||
|
inspection_item_name: string;
|
||||||
|
inspection_standard: string;
|
||||||
|
pass_criteria: string;
|
||||||
|
measured_value: string;
|
||||||
|
judgment: string;
|
||||||
|
is_required: string;
|
||||||
|
memo: string;
|
||||||
|
item_code: string;
|
||||||
|
item_name: string;
|
||||||
|
inspection_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MASTER_TABLE = "inspection_result_mng";
|
||||||
|
const DETAIL_TABLE = "inspection_result";
|
||||||
|
|
||||||
|
// ─── 메인 컴포넌트 ───────────────────────────────────────
|
||||||
|
|
||||||
|
export default function InspectionResultPage() {
|
||||||
|
const [masterData, setMasterData] = useState<InspectionMng[]>([]);
|
||||||
|
const [detailData, setDetailData] = useState<InspectionDetail[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [filterValues, setFilterValues] = useState<FilterValue[]>([]);
|
||||||
|
|
||||||
|
// ─── 마스터 데이터 조회 ─────────────────────────────────
|
||||||
|
|
||||||
|
const fetchMaster = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const search: Record<string, any> = {};
|
||||||
|
filterValues.forEach((f) => {
|
||||||
|
if (f.value) search[f.column] = f.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
|
||||||
|
page: 1,
|
||||||
|
size: 1000,
|
||||||
|
autoFilter: true,
|
||||||
|
search,
|
||||||
|
});
|
||||||
|
const rows: InspectionMng[] = res.data?.data?.data ?? res.data?.data?.rows ?? [];
|
||||||
|
setMasterData(rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("검사결과 조회 실패:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [filterValues]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMaster();
|
||||||
|
}, [fetchMaster]);
|
||||||
|
|
||||||
|
// ─── 디테일 데이터 조회 ─────────────────────────────────
|
||||||
|
|
||||||
|
const fetchDetail = useCallback(async (masterId: string) => {
|
||||||
|
setDetailLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
|
||||||
|
page: 1,
|
||||||
|
size: 500,
|
||||||
|
autoFilter: true,
|
||||||
|
search: { master_id: masterId },
|
||||||
|
});
|
||||||
|
const rows: InspectionDetail[] = res.data?.data?.data ?? res.data?.data?.rows ?? [];
|
||||||
|
setDetailData(rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("검사상세 조회 실패:", err);
|
||||||
|
setDetailData([]);
|
||||||
|
} finally {
|
||||||
|
setDetailLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedId) fetchDetail(selectedId);
|
||||||
|
else setDetailData([]);
|
||||||
|
}, [selectedId, fetchDetail]);
|
||||||
|
|
||||||
|
// ─── 선택된 마스터 ──────────────────────────────────────
|
||||||
|
|
||||||
|
const selectedMaster = useMemo(
|
||||||
|
() => masterData.find((m) => m.id === selectedId),
|
||||||
|
[masterData, selectedId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── 엑셀 다운로드 ─────────────────────────────────────
|
||||||
|
|
||||||
|
const handleExcel = async () => {
|
||||||
|
await exportToExcel(masterData, "검사결과관리.xlsx", "검사결과");
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 판정 배지 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
const judgmentBadge = (v: string) => {
|
||||||
|
if (!v) return null;
|
||||||
|
const lower = v.toLowerCase();
|
||||||
|
if (["합격", "pass", "적합"].includes(lower))
|
||||||
|
return <Badge className="bg-emerald-500/10 text-emerald-600 border-emerald-200">{v}</Badge>;
|
||||||
|
if (["불합격", "fail", "부적합"].includes(lower))
|
||||||
|
return <Badge className="bg-red-500/10 text-red-600 border-red-200">{v}</Badge>;
|
||||||
|
return <Badge variant="outline">{v}</Badge>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 날짜 포맷 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
const fmtDate = (d: string) => {
|
||||||
|
if (!d) return "-";
|
||||||
|
try {
|
||||||
|
return new Date(d).toLocaleDateString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit" });
|
||||||
|
} catch {
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 렌더링 ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
{/* 검색 필터 */}
|
||||||
|
<div className="shrink-0">
|
||||||
|
<DynamicSearchFilter
|
||||||
|
tableName={MASTER_TABLE}
|
||||||
|
filterId="inspection-result-mng"
|
||||||
|
onFilterChange={(filters) => {
|
||||||
|
setFilterValues(filters);
|
||||||
|
}}
|
||||||
|
dataCount={masterData.length}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between border-b px-4 py-2.5 shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ClipboardCheck className="h-5 w-5 text-primary" />
|
||||||
|
<h2 className="text-base font-bold">검사관리</h2>
|
||||||
|
<Badge variant="secondary">{masterData.length}건</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleExcel}>
|
||||||
|
<Download className="h-4 w-4 mr-1" />
|
||||||
|
엑셀 다운로드
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={fetchMaster} disabled={loading}>
|
||||||
|
<RefreshCw className={cn("h-4 w-4 mr-1", loading && "animate-spin")} />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 분할 패널 */}
|
||||||
|
<ResizablePanelGroup direction="horizontal" className="flex-1 min-h-0">
|
||||||
|
{/* 좌측: 마스터 테이블 */}
|
||||||
|
<ResizablePanel defaultSize={55} minSize={30}>
|
||||||
|
<div className="h-full overflow-auto">
|
||||||
|
{loading && masterData.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin mb-3" />
|
||||||
|
<span className="text-sm">데이터를 불러오는 중...</span>
|
||||||
|
</div>
|
||||||
|
) : masterData.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||||
|
<Inbox className="h-10 w-10 mb-3" />
|
||||||
|
<span className="text-sm">검사 데이터가 없습니다</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table noWrapper className="min-w-max">
|
||||||
|
<TableHeader className="sticky top-0 z-10 bg-muted shadow-[0_1px_0_0_hsl(var(--border))]">
|
||||||
|
<TableRow className="hover:bg-muted">
|
||||||
|
<TableHead className="w-10 text-center">#</TableHead>
|
||||||
|
<TableHead className="min-w-[120px]">검사번호</TableHead>
|
||||||
|
<TableHead className="min-w-[80px]">검사유형</TableHead>
|
||||||
|
<TableHead className="min-w-[120px]">품목코드</TableHead>
|
||||||
|
<TableHead className="min-w-[140px]">품목명</TableHead>
|
||||||
|
<TableHead className="min-w-[80px] text-right">검사수량</TableHead>
|
||||||
|
<TableHead className="min-w-[80px] text-right">양품</TableHead>
|
||||||
|
<TableHead className="min-w-[80px] text-right">불량</TableHead>
|
||||||
|
<TableHead className="min-w-[70px] text-center">판정</TableHead>
|
||||||
|
<TableHead className="min-w-[80px]">검사자</TableHead>
|
||||||
|
<TableHead className="min-w-[100px]">검사일자</TableHead>
|
||||||
|
<TableHead className="min-w-[80px] text-center">완료</TableHead>
|
||||||
|
<TableHead className="min-w-[120px]">거래처</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{masterData.map((row, idx) => (
|
||||||
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer",
|
||||||
|
selectedId === row.id && "bg-primary/5",
|
||||||
|
)}
|
||||||
|
onClick={() => setSelectedId(row.id)}
|
||||||
|
>
|
||||||
|
<TableCell className="text-center text-muted-foreground text-xs">{idx + 1}</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm">{row.inspection_number || "-"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline" className="text-xs">{row.inspection_type || "-"}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm">{row.item_code || "-"}</TableCell>
|
||||||
|
<TableCell className="text-sm">{row.item_name || "-"}</TableCell>
|
||||||
|
<TableCell className="text-right text-sm">{row.total_qty ?? "-"}</TableCell>
|
||||||
|
<TableCell className="text-right text-sm text-emerald-600">{row.good_qty ?? "-"}</TableCell>
|
||||||
|
<TableCell className="text-right text-sm text-red-600">{row.bad_qty ?? "-"}</TableCell>
|
||||||
|
<TableCell className="text-center">{judgmentBadge(row.overall_judgment)}</TableCell>
|
||||||
|
<TableCell className="text-sm">{row.inspector || "-"}</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{fmtDate(row.inspection_date)}</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
{row.is_completed === "Y" ? (
|
||||||
|
<Badge className="bg-emerald-500/10 text-emerald-600 border-emerald-200 text-xs">완료</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="text-xs">진행중</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">{row.supplier_name || "-"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
|
||||||
|
<ResizableHandle withHandle />
|
||||||
|
|
||||||
|
{/* 우측: 디테일 */}
|
||||||
|
<ResizablePanel defaultSize={45} minSize={25}>
|
||||||
|
{!selectedId ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
|
||||||
|
<Inbox className="w-10 h-10 text-muted-foreground/40" />
|
||||||
|
<p className="text-sm">좌측에서 검사를 선택해주세요</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* 상세 헤더 */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/50 shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="text-[13px] font-bold">검사 상세</h3>
|
||||||
|
{selectedMaster && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{selectedMaster.inspection_number}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{selectedMaster && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>{selectedMaster.item_name}</span>
|
||||||
|
<span>·</span>
|
||||||
|
{judgmentBadge(selectedMaster.overall_judgment)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 상세 테이블 */}
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{detailLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-16 text-muted-foreground">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : detailData.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||||
|
<Inbox className="h-8 w-8 mb-2 opacity-40" />
|
||||||
|
<p className="text-sm">검사 항목이 없습니다</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table noWrapper>
|
||||||
|
<TableHeader className="sticky top-0 z-10 bg-muted shadow-[0_1px_0_0_hsl(var(--border))]">
|
||||||
|
<TableRow className="hover:bg-muted">
|
||||||
|
<TableHead className="w-10 text-center">#</TableHead>
|
||||||
|
<TableHead className="min-w-[140px]">검사항목</TableHead>
|
||||||
|
<TableHead className="min-w-[120px]">검사기준</TableHead>
|
||||||
|
<TableHead className="min-w-[100px]">합격기준</TableHead>
|
||||||
|
<TableHead className="min-w-[90px]">측정값</TableHead>
|
||||||
|
<TableHead className="min-w-[70px] text-center">판정</TableHead>
|
||||||
|
<TableHead className="min-w-[60px] text-center">필수</TableHead>
|
||||||
|
<TableHead className="min-w-[120px]">비고</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{detailData.map((d, idx) => (
|
||||||
|
<TableRow key={d.id}>
|
||||||
|
<TableCell className="text-center text-muted-foreground text-xs">{idx + 1}</TableCell>
|
||||||
|
<TableCell className="text-sm font-medium">{d.inspection_item_name || "-"}</TableCell>
|
||||||
|
<TableCell className="text-sm">{d.inspection_standard || "-"}</TableCell>
|
||||||
|
<TableCell className="text-sm">{d.pass_criteria || "-"}</TableCell>
|
||||||
|
<TableCell className="text-sm font-mono">{d.measured_value || "-"}</TableCell>
|
||||||
|
<TableCell className="text-center">{judgmentBadge(d.judgment)}</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
{d.is_required === "Y" ? (
|
||||||
|
<Badge className="bg-amber-500/10 text-amber-600 border-amber-200 text-xs">필수</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{d.memo || "-"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
ResizableHandle,
|
||||||
|
ResizablePanel,
|
||||||
|
ResizablePanelGroup,
|
||||||
|
} from "@/components/ui/resizable";
|
||||||
|
import { RefreshCw, Loader2, Inbox, Wrench, Download } from "lucide-react";
|
||||||
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||||
|
|
||||||
|
// ─── 타입 ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface InspectionRecord {
|
||||||
|
id: string;
|
||||||
|
equipment_code: string;
|
||||||
|
inspection_item_objid: string;
|
||||||
|
inspection_date: string;
|
||||||
|
status: string;
|
||||||
|
inspector: string;
|
||||||
|
remark: string;
|
||||||
|
created_date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InspectionItem {
|
||||||
|
id: string;
|
||||||
|
equipment_code: string;
|
||||||
|
inspection_item: string;
|
||||||
|
inspection_cycle: string;
|
||||||
|
inspection_content: string;
|
||||||
|
inspection_method: string;
|
||||||
|
lower_limit: string;
|
||||||
|
upper_limit: string;
|
||||||
|
unit: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EquipmentInfo {
|
||||||
|
id: string;
|
||||||
|
equipment_code: string;
|
||||||
|
equipment_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RECORD_TABLE = "equipment_inspection_record";
|
||||||
|
|
||||||
|
// ─── 메인 컴포넌트 ───────────────────────────────────────
|
||||||
|
|
||||||
|
export default function EquipmentInspectionRecordPage() {
|
||||||
|
const [records, setRecords] = useState<InspectionRecord[]>([]);
|
||||||
|
const [inspectionItems, setInspectionItems] = useState<Map<string, InspectionItem>>(new Map());
|
||||||
|
const [equipments, setEquipments] = useState<Map<string, EquipmentInfo>>(new Map());
|
||||||
|
const [categoryMap, setCategoryMap] = useState<Record<string, Record<string, string>>>({});
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [filterValues, setFilterValues] = useState<FilterValue[]>([]);
|
||||||
|
|
||||||
|
// ─── 데이터 조회 ────────────────────────────────────────
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const search: Record<string, any> = {};
|
||||||
|
filterValues.forEach((f) => {
|
||||||
|
if (f.value) search[f.column] = f.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const [recordRes, itemRes, equipRes] = await Promise.all([
|
||||||
|
apiClient.post(`/table-management/tables/${RECORD_TABLE}/data`, {
|
||||||
|
page: 1,
|
||||||
|
size: 1000,
|
||||||
|
autoFilter: true,
|
||||||
|
search,
|
||||||
|
}),
|
||||||
|
apiClient.post(`/table-management/tables/equipment_inspection_item/data`, {
|
||||||
|
page: 1,
|
||||||
|
size: 1000,
|
||||||
|
autoFilter: true,
|
||||||
|
}).catch(() => ({ data: { data: { data: [] } } })),
|
||||||
|
apiClient.post(`/table-management/tables/equipment_mng/data`, {
|
||||||
|
page: 1,
|
||||||
|
size: 500,
|
||||||
|
autoFilter: true,
|
||||||
|
}).catch(() => ({ data: { data: { data: [] } } })),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const rRows: InspectionRecord[] = recordRes.data?.data?.data ?? recordRes.data?.data?.rows ?? [];
|
||||||
|
setRecords(rRows);
|
||||||
|
|
||||||
|
const iRows: InspectionItem[] = itemRes.data?.data?.data ?? itemRes.data?.data?.rows ?? [];
|
||||||
|
const iMap = new Map<string, InspectionItem>();
|
||||||
|
iRows.forEach((i) => iMap.set(i.id, i));
|
||||||
|
setInspectionItems(iMap);
|
||||||
|
|
||||||
|
const eRows: EquipmentInfo[] = equipRes.data?.data?.data ?? equipRes.data?.data?.rows ?? [];
|
||||||
|
const eMap = new Map<string, EquipmentInfo>();
|
||||||
|
eRows.forEach((e) => {
|
||||||
|
eMap.set(e.equipment_code, e);
|
||||||
|
eMap.set(e.id, e);
|
||||||
|
});
|
||||||
|
setEquipments(eMap);
|
||||||
|
|
||||||
|
// 카테고리 코드→라벨 매핑 (점검주기, 점검방법)
|
||||||
|
try {
|
||||||
|
const catCols = ["inspection_cycle", "inspection_method"];
|
||||||
|
const catResults = await Promise.all(
|
||||||
|
catCols.map((col) =>
|
||||||
|
apiClient.get(`/table-categories/equipment_inspection_item/${col}/values`).catch(() => ({ data: [] })),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const cMap: Record<string, Record<string, string>> = {};
|
||||||
|
catCols.forEach((col, idx) => {
|
||||||
|
const vals: { code: string; label: string }[] = catResults[idx].data?.data ?? catResults[idx].data ?? [];
|
||||||
|
cMap[col] = {};
|
||||||
|
vals.forEach((v: any) => { cMap[col][v.valueCode || v.code] = v.valueLabel || v.label; });
|
||||||
|
});
|
||||||
|
setCategoryMap(cMap);
|
||||||
|
} catch {
|
||||||
|
// 카테고리 조회 실패 무시
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("점검기록 조회 실패:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [filterValues]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
// ─── 선택된 레코드 ─────────────────────────────────────
|
||||||
|
|
||||||
|
const selectedRecord = useMemo(() => records.find((r) => r.id === selectedId), [records, selectedId]);
|
||||||
|
const selectedItem = useMemo(
|
||||||
|
() => (selectedRecord ? inspectionItems.get(selectedRecord.inspection_item_objid) : undefined),
|
||||||
|
[selectedRecord, inspectionItems],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── 설비명 조회 ───────────────────────────────────────
|
||||||
|
|
||||||
|
const getEquipName = (code: string) => equipments.get(code)?.equipment_name || code || "-";
|
||||||
|
|
||||||
|
// ─── 카테고리 코드→라벨 변환 ──────────────────────────
|
||||||
|
|
||||||
|
const resolveCategory = (col: string, code: string) => {
|
||||||
|
if (!code) return "-";
|
||||||
|
return categoryMap[col]?.[code] || code;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 엑셀 다운로드 ─────────────────────────────────────
|
||||||
|
|
||||||
|
const handleExcel = async () => {
|
||||||
|
const data = records.map((r) => ({
|
||||||
|
설비코드: r.equipment_code,
|
||||||
|
설비명: getEquipName(r.equipment_code),
|
||||||
|
점검항목: inspectionItems.get(r.inspection_item_objid)?.inspection_item || "-",
|
||||||
|
점검일자: fmtDate(r.inspection_date),
|
||||||
|
상태: r.status,
|
||||||
|
점검자: r.inspector,
|
||||||
|
비고: r.remark,
|
||||||
|
}));
|
||||||
|
await exportToExcel(data, "설비점검기록.xlsx", "점검기록");
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 날짜/상태 포맷 ────────────────────────────────────
|
||||||
|
|
||||||
|
const fmtDate = (d: string) => {
|
||||||
|
if (!d) return "-";
|
||||||
|
try {
|
||||||
|
return new Date(d).toLocaleDateString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit" });
|
||||||
|
} catch {
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusBadge = (v: string) => {
|
||||||
|
if (!v) return <Badge variant="outline" className="text-xs">-</Badge>;
|
||||||
|
const lower = v.toLowerCase();
|
||||||
|
if (["완료", "pass", "정상", "합격", "completed"].includes(lower))
|
||||||
|
return <Badge className="bg-emerald-500/10 text-emerald-600 border-emerald-200 text-xs">{v}</Badge>;
|
||||||
|
if (["이상", "fail", "불합격", "비정상"].includes(lower))
|
||||||
|
return <Badge className="bg-red-500/10 text-red-600 border-red-200 text-xs">{v}</Badge>;
|
||||||
|
if (["점검중", "진행", "in_progress"].includes(lower))
|
||||||
|
return <Badge className="bg-blue-500/10 text-blue-600 border-blue-200 text-xs">{v}</Badge>;
|
||||||
|
return <Badge variant="outline" className="text-xs">{v}</Badge>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 렌더링 ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
{/* 검색 필터 */}
|
||||||
|
<div className="shrink-0">
|
||||||
|
<DynamicSearchFilter
|
||||||
|
tableName={RECORD_TABLE}
|
||||||
|
filterId="equip-inspection-record"
|
||||||
|
onFilterChange={(filters) => setFilterValues(filters)}
|
||||||
|
dataCount={records.length}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between border-b px-4 py-2.5 shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Wrench className="h-5 w-5 text-primary" />
|
||||||
|
<h2 className="text-base font-bold">점검관리</h2>
|
||||||
|
<Badge variant="secondary">{records.length}건</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleExcel}>
|
||||||
|
<Download className="h-4 w-4 mr-1" />
|
||||||
|
엑셀 다운로드
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={fetchData} disabled={loading}>
|
||||||
|
<RefreshCw className={cn("h-4 w-4 mr-1", loading && "animate-spin")} />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 분할 패널 */}
|
||||||
|
<ResizablePanelGroup direction="horizontal" className="flex-1 min-h-0">
|
||||||
|
{/* 좌측: 점검기록 테이블 */}
|
||||||
|
<ResizablePanel defaultSize={60} minSize={35}>
|
||||||
|
<div className="h-full overflow-auto">
|
||||||
|
{loading && records.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin mb-3" />
|
||||||
|
<span className="text-sm">데이터를 불러오는 중...</span>
|
||||||
|
</div>
|
||||||
|
) : records.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||||
|
<Inbox className="h-10 w-10 mb-3" />
|
||||||
|
<span className="text-sm">점검 기록이 없습니다</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table noWrapper className="min-w-max">
|
||||||
|
<TableHeader className="sticky top-0 z-10 bg-muted shadow-[0_1px_0_0_hsl(var(--border))]">
|
||||||
|
<TableRow className="hover:bg-muted">
|
||||||
|
<TableHead className="w-10 text-center">#</TableHead>
|
||||||
|
<TableHead className="min-w-[100px]">설비코드</TableHead>
|
||||||
|
<TableHead className="min-w-[120px]">설비명</TableHead>
|
||||||
|
<TableHead className="min-w-[140px]">점검항목</TableHead>
|
||||||
|
<TableHead className="min-w-[100px]">점검일자</TableHead>
|
||||||
|
<TableHead className="min-w-[70px] text-center">상태</TableHead>
|
||||||
|
<TableHead className="min-w-[80px]">점검자</TableHead>
|
||||||
|
<TableHead className="min-w-[150px]">비고</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{records.map((row, idx) => {
|
||||||
|
const item = inspectionItems.get(row.inspection_item_objid);
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
className={cn("cursor-pointer", selectedId === row.id && "bg-primary/5")}
|
||||||
|
onClick={() => setSelectedId(row.id)}
|
||||||
|
>
|
||||||
|
<TableCell className="text-center text-muted-foreground text-xs">{idx + 1}</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm">{row.equipment_code || "-"}</TableCell>
|
||||||
|
<TableCell className="text-sm">{getEquipName(row.equipment_code)}</TableCell>
|
||||||
|
<TableCell className="text-sm">{item?.inspection_item || "-"}</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{fmtDate(row.inspection_date)}</TableCell>
|
||||||
|
<TableCell className="text-center">{statusBadge(row.status)}</TableCell>
|
||||||
|
<TableCell className="text-sm">{row.inspector || "-"}</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{row.remark || "-"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
|
||||||
|
<ResizableHandle withHandle />
|
||||||
|
|
||||||
|
{/* 우측: 점검항목 상세 */}
|
||||||
|
<ResizablePanel defaultSize={40} minSize={25}>
|
||||||
|
{!selectedId || !selectedRecord ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
|
||||||
|
<Inbox className="w-10 h-10 text-muted-foreground/40" />
|
||||||
|
<p className="text-sm">좌측에서 점검 기록을 선택해주세요</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col h-full overflow-auto">
|
||||||
|
{/* 점검 기록 요약 */}
|
||||||
|
<div className="px-4 py-3 border-b bg-muted/50 shrink-0">
|
||||||
|
<h3 className="text-[13px] font-bold mb-2">점검 기록 정보</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<DetailRow label="설비" value={getEquipName(selectedRecord.equipment_code)} />
|
||||||
|
<DetailRow label="설비코드" value={selectedRecord.equipment_code} />
|
||||||
|
<DetailRow label="점검일자" value={fmtDate(selectedRecord.inspection_date)} />
|
||||||
|
<DetailRow label="점검자" value={selectedRecord.inspector || "-"} />
|
||||||
|
<DetailRow label="상태" value={selectedRecord.status || "-"} badge={statusBadge(selectedRecord.status)} />
|
||||||
|
<DetailRow label="비고" value={selectedRecord.remark || "-"} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 점검 항목 상세 */}
|
||||||
|
{selectedItem ? (
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<h3 className="text-[13px] font-bold mb-2">점검 항목 상세</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<DetailRow label="점검항목" value={selectedItem.inspection_item || "-"} />
|
||||||
|
<DetailRow label="점검주기" value={resolveCategory("inspection_cycle", selectedItem.inspection_cycle)} />
|
||||||
|
<DetailRow label="점검내용" value={selectedItem.inspection_content || "-"} span2 />
|
||||||
|
<DetailRow label="점검방법" value={resolveCategory("inspection_method", selectedItem.inspection_method)} span2 />
|
||||||
|
<DetailRow label="하한치" value={selectedItem.lower_limit || "-"} />
|
||||||
|
<DetailRow label="상한치" value={selectedItem.upper_limit || "-"} />
|
||||||
|
<DetailRow label="단위" value={selectedItem.unit || "-"} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||||
|
<p className="text-sm">점검 항목 정보가 없습니다</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 상세 행 ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
function DetailRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
badge,
|
||||||
|
span2,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
badge?: React.ReactNode;
|
||||||
|
span2?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-col gap-0.5", span2 && "col-span-2")}>
|
||||||
|
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">{label}</span>
|
||||||
|
{badge ? <div className="w-fit">{badge}</div> : <span className="text-xs">{value}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
ResizableHandle,
|
||||||
|
ResizablePanel,
|
||||||
|
ResizablePanelGroup,
|
||||||
|
} from "@/components/ui/resizable";
|
||||||
|
import { RefreshCw, Loader2, Inbox, ClipboardCheck, Download } from "lucide-react";
|
||||||
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||||
|
|
||||||
|
// ─── 타입 ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface InspectionMng {
|
||||||
|
id: string;
|
||||||
|
inspection_number: string;
|
||||||
|
item_code: string;
|
||||||
|
item_name: string;
|
||||||
|
inspection_type: string;
|
||||||
|
total_qty: number;
|
||||||
|
good_qty: number;
|
||||||
|
bad_qty: number;
|
||||||
|
overall_judgment: string;
|
||||||
|
inspector: string;
|
||||||
|
inspection_date: string;
|
||||||
|
is_completed: string;
|
||||||
|
defect_description: string;
|
||||||
|
memo: string;
|
||||||
|
supplier_name: string;
|
||||||
|
supplier_code: string;
|
||||||
|
created_date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InspectionDetail {
|
||||||
|
id: string;
|
||||||
|
master_id: string;
|
||||||
|
inspection_item_name: string;
|
||||||
|
inspection_standard: string;
|
||||||
|
pass_criteria: string;
|
||||||
|
measured_value: string;
|
||||||
|
judgment: string;
|
||||||
|
is_required: string;
|
||||||
|
memo: string;
|
||||||
|
item_code: string;
|
||||||
|
item_name: string;
|
||||||
|
inspection_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MASTER_TABLE = "inspection_result_mng";
|
||||||
|
const DETAIL_TABLE = "inspection_result";
|
||||||
|
|
||||||
|
// ─── 메인 컴포넌트 ───────────────────────────────────────
|
||||||
|
|
||||||
|
export default function InspectionResultPage() {
|
||||||
|
const [masterData, setMasterData] = useState<InspectionMng[]>([]);
|
||||||
|
const [detailData, setDetailData] = useState<InspectionDetail[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [filterValues, setFilterValues] = useState<FilterValue[]>([]);
|
||||||
|
|
||||||
|
// ─── 마스터 데이터 조회 ─────────────────────────────────
|
||||||
|
|
||||||
|
const fetchMaster = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const search: Record<string, any> = {};
|
||||||
|
filterValues.forEach((f) => {
|
||||||
|
if (f.value) search[f.column] = f.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
|
||||||
|
page: 1,
|
||||||
|
size: 1000,
|
||||||
|
autoFilter: true,
|
||||||
|
search,
|
||||||
|
});
|
||||||
|
const rows: InspectionMng[] = res.data?.data?.data ?? res.data?.data?.rows ?? [];
|
||||||
|
setMasterData(rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("검사결과 조회 실패:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [filterValues]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMaster();
|
||||||
|
}, [fetchMaster]);
|
||||||
|
|
||||||
|
// ─── 디테일 데이터 조회 ─────────────────────────────────
|
||||||
|
|
||||||
|
const fetchDetail = useCallback(async (masterId: string) => {
|
||||||
|
setDetailLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
|
||||||
|
page: 1,
|
||||||
|
size: 500,
|
||||||
|
autoFilter: true,
|
||||||
|
search: { master_id: masterId },
|
||||||
|
});
|
||||||
|
const rows: InspectionDetail[] = res.data?.data?.data ?? res.data?.data?.rows ?? [];
|
||||||
|
setDetailData(rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("검사상세 조회 실패:", err);
|
||||||
|
setDetailData([]);
|
||||||
|
} finally {
|
||||||
|
setDetailLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedId) fetchDetail(selectedId);
|
||||||
|
else setDetailData([]);
|
||||||
|
}, [selectedId, fetchDetail]);
|
||||||
|
|
||||||
|
// ─── 선택된 마스터 ──────────────────────────────────────
|
||||||
|
|
||||||
|
const selectedMaster = useMemo(
|
||||||
|
() => masterData.find((m) => m.id === selectedId),
|
||||||
|
[masterData, selectedId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── 엑셀 다운로드 ─────────────────────────────────────
|
||||||
|
|
||||||
|
const handleExcel = async () => {
|
||||||
|
await exportToExcel(masterData, "검사결과관리.xlsx", "검사결과");
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 판정 배지 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
const judgmentBadge = (v: string) => {
|
||||||
|
if (!v) return null;
|
||||||
|
const lower = v.toLowerCase();
|
||||||
|
if (["합격", "pass", "적합"].includes(lower))
|
||||||
|
return <Badge className="bg-emerald-500/10 text-emerald-600 border-emerald-200">{v}</Badge>;
|
||||||
|
if (["불합격", "fail", "부적합"].includes(lower))
|
||||||
|
return <Badge className="bg-red-500/10 text-red-600 border-red-200">{v}</Badge>;
|
||||||
|
return <Badge variant="outline">{v}</Badge>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 날짜 포맷 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
const fmtDate = (d: string) => {
|
||||||
|
if (!d) return "-";
|
||||||
|
try {
|
||||||
|
return new Date(d).toLocaleDateString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit" });
|
||||||
|
} catch {
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 렌더링 ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
{/* 검색 필터 */}
|
||||||
|
<div className="shrink-0">
|
||||||
|
<DynamicSearchFilter
|
||||||
|
tableName={MASTER_TABLE}
|
||||||
|
filterId="inspection-result-mng"
|
||||||
|
onFilterChange={(filters) => {
|
||||||
|
setFilterValues(filters);
|
||||||
|
}}
|
||||||
|
dataCount={masterData.length}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between border-b px-4 py-2.5 shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ClipboardCheck className="h-5 w-5 text-primary" />
|
||||||
|
<h2 className="text-base font-bold">검사관리</h2>
|
||||||
|
<Badge variant="secondary">{masterData.length}건</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleExcel}>
|
||||||
|
<Download className="h-4 w-4 mr-1" />
|
||||||
|
엑셀 다운로드
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={fetchMaster} disabled={loading}>
|
||||||
|
<RefreshCw className={cn("h-4 w-4 mr-1", loading && "animate-spin")} />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 분할 패널 */}
|
||||||
|
<ResizablePanelGroup direction="horizontal" className="flex-1 min-h-0">
|
||||||
|
{/* 좌측: 마스터 테이블 */}
|
||||||
|
<ResizablePanel defaultSize={55} minSize={30}>
|
||||||
|
<div className="h-full overflow-auto">
|
||||||
|
{loading && masterData.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin mb-3" />
|
||||||
|
<span className="text-sm">데이터를 불러오는 중...</span>
|
||||||
|
</div>
|
||||||
|
) : masterData.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||||
|
<Inbox className="h-10 w-10 mb-3" />
|
||||||
|
<span className="text-sm">검사 데이터가 없습니다</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table noWrapper className="min-w-max">
|
||||||
|
<TableHeader className="sticky top-0 z-10 bg-muted shadow-[0_1px_0_0_hsl(var(--border))]">
|
||||||
|
<TableRow className="hover:bg-muted">
|
||||||
|
<TableHead className="w-10 text-center">#</TableHead>
|
||||||
|
<TableHead className="min-w-[120px]">검사번호</TableHead>
|
||||||
|
<TableHead className="min-w-[80px]">검사유형</TableHead>
|
||||||
|
<TableHead className="min-w-[120px]">품목코드</TableHead>
|
||||||
|
<TableHead className="min-w-[140px]">품목명</TableHead>
|
||||||
|
<TableHead className="min-w-[80px] text-right">검사수량</TableHead>
|
||||||
|
<TableHead className="min-w-[80px] text-right">양품</TableHead>
|
||||||
|
<TableHead className="min-w-[80px] text-right">불량</TableHead>
|
||||||
|
<TableHead className="min-w-[70px] text-center">판정</TableHead>
|
||||||
|
<TableHead className="min-w-[80px]">검사자</TableHead>
|
||||||
|
<TableHead className="min-w-[100px]">검사일자</TableHead>
|
||||||
|
<TableHead className="min-w-[80px] text-center">완료</TableHead>
|
||||||
|
<TableHead className="min-w-[120px]">거래처</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{masterData.map((row, idx) => (
|
||||||
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer",
|
||||||
|
selectedId === row.id && "bg-primary/5",
|
||||||
|
)}
|
||||||
|
onClick={() => setSelectedId(row.id)}
|
||||||
|
>
|
||||||
|
<TableCell className="text-center text-muted-foreground text-xs">{idx + 1}</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm">{row.inspection_number || "-"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline" className="text-xs">{row.inspection_type || "-"}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm">{row.item_code || "-"}</TableCell>
|
||||||
|
<TableCell className="text-sm">{row.item_name || "-"}</TableCell>
|
||||||
|
<TableCell className="text-right text-sm">{row.total_qty ?? "-"}</TableCell>
|
||||||
|
<TableCell className="text-right text-sm text-emerald-600">{row.good_qty ?? "-"}</TableCell>
|
||||||
|
<TableCell className="text-right text-sm text-red-600">{row.bad_qty ?? "-"}</TableCell>
|
||||||
|
<TableCell className="text-center">{judgmentBadge(row.overall_judgment)}</TableCell>
|
||||||
|
<TableCell className="text-sm">{row.inspector || "-"}</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{fmtDate(row.inspection_date)}</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
{row.is_completed === "Y" ? (
|
||||||
|
<Badge className="bg-emerald-500/10 text-emerald-600 border-emerald-200 text-xs">완료</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="text-xs">진행중</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">{row.supplier_name || "-"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
|
||||||
|
<ResizableHandle withHandle />
|
||||||
|
|
||||||
|
{/* 우측: 디테일 */}
|
||||||
|
<ResizablePanel defaultSize={45} minSize={25}>
|
||||||
|
{!selectedId ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
|
||||||
|
<Inbox className="w-10 h-10 text-muted-foreground/40" />
|
||||||
|
<p className="text-sm">좌측에서 검사를 선택해주세요</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* 상세 헤더 */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/50 shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="text-[13px] font-bold">검사 상세</h3>
|
||||||
|
{selectedMaster && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{selectedMaster.inspection_number}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{selectedMaster && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>{selectedMaster.item_name}</span>
|
||||||
|
<span>·</span>
|
||||||
|
{judgmentBadge(selectedMaster.overall_judgment)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 상세 테이블 */}
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{detailLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-16 text-muted-foreground">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : detailData.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||||
|
<Inbox className="h-8 w-8 mb-2 opacity-40" />
|
||||||
|
<p className="text-sm">검사 항목이 없습니다</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table noWrapper>
|
||||||
|
<TableHeader className="sticky top-0 z-10 bg-muted shadow-[0_1px_0_0_hsl(var(--border))]">
|
||||||
|
<TableRow className="hover:bg-muted">
|
||||||
|
<TableHead className="w-10 text-center">#</TableHead>
|
||||||
|
<TableHead className="min-w-[140px]">검사항목</TableHead>
|
||||||
|
<TableHead className="min-w-[120px]">검사기준</TableHead>
|
||||||
|
<TableHead className="min-w-[100px]">합격기준</TableHead>
|
||||||
|
<TableHead className="min-w-[90px]">측정값</TableHead>
|
||||||
|
<TableHead className="min-w-[70px] text-center">판정</TableHead>
|
||||||
|
<TableHead className="min-w-[60px] text-center">필수</TableHead>
|
||||||
|
<TableHead className="min-w-[120px]">비고</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{detailData.map((d, idx) => (
|
||||||
|
<TableRow key={d.id}>
|
||||||
|
<TableCell className="text-center text-muted-foreground text-xs">{idx + 1}</TableCell>
|
||||||
|
<TableCell className="text-sm font-medium">{d.inspection_item_name || "-"}</TableCell>
|
||||||
|
<TableCell className="text-sm">{d.inspection_standard || "-"}</TableCell>
|
||||||
|
<TableCell className="text-sm">{d.pass_criteria || "-"}</TableCell>
|
||||||
|
<TableCell className="text-sm font-mono">{d.measured_value || "-"}</TableCell>
|
||||||
|
<TableCell className="text-center">{judgmentBadge(d.judgment)}</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
{d.is_required === "Y" ? (
|
||||||
|
<Badge className="bg-amber-500/10 text-amber-600 border-amber-200 text-xs">필수</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{d.memo || "-"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
ResizableHandle,
|
||||||
|
ResizablePanel,
|
||||||
|
ResizablePanelGroup,
|
||||||
|
} from "@/components/ui/resizable";
|
||||||
|
import { RefreshCw, Loader2, Inbox, Wrench, Download } from "lucide-react";
|
||||||
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||||
|
|
||||||
|
// ─── 타입 ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface InspectionRecord {
|
||||||
|
id: string;
|
||||||
|
equipment_code: string;
|
||||||
|
inspection_item_objid: string;
|
||||||
|
inspection_date: string;
|
||||||
|
status: string;
|
||||||
|
inspector: string;
|
||||||
|
remark: string;
|
||||||
|
created_date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InspectionItem {
|
||||||
|
id: string;
|
||||||
|
equipment_code: string;
|
||||||
|
inspection_item: string;
|
||||||
|
inspection_cycle: string;
|
||||||
|
inspection_content: string;
|
||||||
|
inspection_method: string;
|
||||||
|
lower_limit: string;
|
||||||
|
upper_limit: string;
|
||||||
|
unit: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EquipmentInfo {
|
||||||
|
id: string;
|
||||||
|
equipment_code: string;
|
||||||
|
equipment_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RECORD_TABLE = "equipment_inspection_record";
|
||||||
|
|
||||||
|
// ─── 메인 컴포넌트 ───────────────────────────────────────
|
||||||
|
|
||||||
|
export default function EquipmentInspectionRecordPage() {
|
||||||
|
const [records, setRecords] = useState<InspectionRecord[]>([]);
|
||||||
|
const [inspectionItems, setInspectionItems] = useState<Map<string, InspectionItem>>(new Map());
|
||||||
|
const [equipments, setEquipments] = useState<Map<string, EquipmentInfo>>(new Map());
|
||||||
|
const [categoryMap, setCategoryMap] = useState<Record<string, Record<string, string>>>({});
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [filterValues, setFilterValues] = useState<FilterValue[]>([]);
|
||||||
|
|
||||||
|
// ─── 데이터 조회 ────────────────────────────────────────
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const search: Record<string, any> = {};
|
||||||
|
filterValues.forEach((f) => {
|
||||||
|
if (f.value) search[f.column] = f.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const [recordRes, itemRes, equipRes] = await Promise.all([
|
||||||
|
apiClient.post(`/table-management/tables/${RECORD_TABLE}/data`, {
|
||||||
|
page: 1,
|
||||||
|
size: 1000,
|
||||||
|
autoFilter: true,
|
||||||
|
search,
|
||||||
|
}),
|
||||||
|
apiClient.post(`/table-management/tables/equipment_inspection_item/data`, {
|
||||||
|
page: 1,
|
||||||
|
size: 1000,
|
||||||
|
autoFilter: true,
|
||||||
|
}).catch(() => ({ data: { data: { data: [] } } })),
|
||||||
|
apiClient.post(`/table-management/tables/equipment_mng/data`, {
|
||||||
|
page: 1,
|
||||||
|
size: 500,
|
||||||
|
autoFilter: true,
|
||||||
|
}).catch(() => ({ data: { data: { data: [] } } })),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const rRows: InspectionRecord[] = recordRes.data?.data?.data ?? recordRes.data?.data?.rows ?? [];
|
||||||
|
setRecords(rRows);
|
||||||
|
|
||||||
|
const iRows: InspectionItem[] = itemRes.data?.data?.data ?? itemRes.data?.data?.rows ?? [];
|
||||||
|
const iMap = new Map<string, InspectionItem>();
|
||||||
|
iRows.forEach((i) => iMap.set(i.id, i));
|
||||||
|
setInspectionItems(iMap);
|
||||||
|
|
||||||
|
const eRows: EquipmentInfo[] = equipRes.data?.data?.data ?? equipRes.data?.data?.rows ?? [];
|
||||||
|
const eMap = new Map<string, EquipmentInfo>();
|
||||||
|
eRows.forEach((e) => {
|
||||||
|
eMap.set(e.equipment_code, e);
|
||||||
|
eMap.set(e.id, e);
|
||||||
|
});
|
||||||
|
setEquipments(eMap);
|
||||||
|
|
||||||
|
// 카테고리 코드→라벨 매핑 (점검주기, 점검방법)
|
||||||
|
try {
|
||||||
|
const catCols = ["inspection_cycle", "inspection_method"];
|
||||||
|
const catResults = await Promise.all(
|
||||||
|
catCols.map((col) =>
|
||||||
|
apiClient.get(`/table-categories/equipment_inspection_item/${col}/values`).catch(() => ({ data: [] })),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const cMap: Record<string, Record<string, string>> = {};
|
||||||
|
catCols.forEach((col, idx) => {
|
||||||
|
const vals: { code: string; label: string }[] = catResults[idx].data?.data ?? catResults[idx].data ?? [];
|
||||||
|
cMap[col] = {};
|
||||||
|
vals.forEach((v: any) => { cMap[col][v.valueCode || v.code] = v.valueLabel || v.label; });
|
||||||
|
});
|
||||||
|
setCategoryMap(cMap);
|
||||||
|
} catch {
|
||||||
|
// 카테고리 조회 실패 무시
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("점검기록 조회 실패:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [filterValues]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
// ─── 선택된 레코드 ─────────────────────────────────────
|
||||||
|
|
||||||
|
const selectedRecord = useMemo(() => records.find((r) => r.id === selectedId), [records, selectedId]);
|
||||||
|
const selectedItem = useMemo(
|
||||||
|
() => (selectedRecord ? inspectionItems.get(selectedRecord.inspection_item_objid) : undefined),
|
||||||
|
[selectedRecord, inspectionItems],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── 설비명 조회 ───────────────────────────────────────
|
||||||
|
|
||||||
|
const getEquipName = (code: string) => equipments.get(code)?.equipment_name || code || "-";
|
||||||
|
|
||||||
|
// ─── 카테고리 코드→라벨 변환 ──────────────────────────
|
||||||
|
|
||||||
|
const resolveCategory = (col: string, code: string) => {
|
||||||
|
if (!code) return "-";
|
||||||
|
return categoryMap[col]?.[code] || code;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 엑셀 다운로드 ─────────────────────────────────────
|
||||||
|
|
||||||
|
const handleExcel = async () => {
|
||||||
|
const data = records.map((r) => ({
|
||||||
|
설비코드: r.equipment_code,
|
||||||
|
설비명: getEquipName(r.equipment_code),
|
||||||
|
점검항목: inspectionItems.get(r.inspection_item_objid)?.inspection_item || "-",
|
||||||
|
점검일자: fmtDate(r.inspection_date),
|
||||||
|
상태: r.status,
|
||||||
|
점검자: r.inspector,
|
||||||
|
비고: r.remark,
|
||||||
|
}));
|
||||||
|
await exportToExcel(data, "설비점검기록.xlsx", "점검기록");
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 날짜/상태 포맷 ────────────────────────────────────
|
||||||
|
|
||||||
|
const fmtDate = (d: string) => {
|
||||||
|
if (!d) return "-";
|
||||||
|
try {
|
||||||
|
return new Date(d).toLocaleDateString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit" });
|
||||||
|
} catch {
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusBadge = (v: string) => {
|
||||||
|
if (!v) return <Badge variant="outline" className="text-xs">-</Badge>;
|
||||||
|
const lower = v.toLowerCase();
|
||||||
|
if (["완료", "pass", "정상", "합격", "completed"].includes(lower))
|
||||||
|
return <Badge className="bg-emerald-500/10 text-emerald-600 border-emerald-200 text-xs">{v}</Badge>;
|
||||||
|
if (["이상", "fail", "불합격", "비정상"].includes(lower))
|
||||||
|
return <Badge className="bg-red-500/10 text-red-600 border-red-200 text-xs">{v}</Badge>;
|
||||||
|
if (["점검중", "진행", "in_progress"].includes(lower))
|
||||||
|
return <Badge className="bg-blue-500/10 text-blue-600 border-blue-200 text-xs">{v}</Badge>;
|
||||||
|
return <Badge variant="outline" className="text-xs">{v}</Badge>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 렌더링 ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
{/* 검색 필터 */}
|
||||||
|
<div className="shrink-0">
|
||||||
|
<DynamicSearchFilter
|
||||||
|
tableName={RECORD_TABLE}
|
||||||
|
filterId="equip-inspection-record"
|
||||||
|
onFilterChange={(filters) => setFilterValues(filters)}
|
||||||
|
dataCount={records.length}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between border-b px-4 py-2.5 shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Wrench className="h-5 w-5 text-primary" />
|
||||||
|
<h2 className="text-base font-bold">점검관리</h2>
|
||||||
|
<Badge variant="secondary">{records.length}건</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleExcel}>
|
||||||
|
<Download className="h-4 w-4 mr-1" />
|
||||||
|
엑셀 다운로드
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={fetchData} disabled={loading}>
|
||||||
|
<RefreshCw className={cn("h-4 w-4 mr-1", loading && "animate-spin")} />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 분할 패널 */}
|
||||||
|
<ResizablePanelGroup direction="horizontal" className="flex-1 min-h-0">
|
||||||
|
{/* 좌측: 점검기록 테이블 */}
|
||||||
|
<ResizablePanel defaultSize={60} minSize={35}>
|
||||||
|
<div className="h-full overflow-auto">
|
||||||
|
{loading && records.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin mb-3" />
|
||||||
|
<span className="text-sm">데이터를 불러오는 중...</span>
|
||||||
|
</div>
|
||||||
|
) : records.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||||
|
<Inbox className="h-10 w-10 mb-3" />
|
||||||
|
<span className="text-sm">점검 기록이 없습니다</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table noWrapper className="min-w-max">
|
||||||
|
<TableHeader className="sticky top-0 z-10 bg-muted shadow-[0_1px_0_0_hsl(var(--border))]">
|
||||||
|
<TableRow className="hover:bg-muted">
|
||||||
|
<TableHead className="w-10 text-center">#</TableHead>
|
||||||
|
<TableHead className="min-w-[100px]">설비코드</TableHead>
|
||||||
|
<TableHead className="min-w-[120px]">설비명</TableHead>
|
||||||
|
<TableHead className="min-w-[140px]">점검항목</TableHead>
|
||||||
|
<TableHead className="min-w-[100px]">점검일자</TableHead>
|
||||||
|
<TableHead className="min-w-[70px] text-center">상태</TableHead>
|
||||||
|
<TableHead className="min-w-[80px]">점검자</TableHead>
|
||||||
|
<TableHead className="min-w-[150px]">비고</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{records.map((row, idx) => {
|
||||||
|
const item = inspectionItems.get(row.inspection_item_objid);
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
className={cn("cursor-pointer", selectedId === row.id && "bg-primary/5")}
|
||||||
|
onClick={() => setSelectedId(row.id)}
|
||||||
|
>
|
||||||
|
<TableCell className="text-center text-muted-foreground text-xs">{idx + 1}</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm">{row.equipment_code || "-"}</TableCell>
|
||||||
|
<TableCell className="text-sm">{getEquipName(row.equipment_code)}</TableCell>
|
||||||
|
<TableCell className="text-sm">{item?.inspection_item || "-"}</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{fmtDate(row.inspection_date)}</TableCell>
|
||||||
|
<TableCell className="text-center">{statusBadge(row.status)}</TableCell>
|
||||||
|
<TableCell className="text-sm">{row.inspector || "-"}</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{row.remark || "-"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
|
||||||
|
<ResizableHandle withHandle />
|
||||||
|
|
||||||
|
{/* 우측: 점검항목 상세 */}
|
||||||
|
<ResizablePanel defaultSize={40} minSize={25}>
|
||||||
|
{!selectedId || !selectedRecord ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
|
||||||
|
<Inbox className="w-10 h-10 text-muted-foreground/40" />
|
||||||
|
<p className="text-sm">좌측에서 점검 기록을 선택해주세요</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col h-full overflow-auto">
|
||||||
|
{/* 점검 기록 요약 */}
|
||||||
|
<div className="px-4 py-3 border-b bg-muted/50 shrink-0">
|
||||||
|
<h3 className="text-[13px] font-bold mb-2">점검 기록 정보</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<DetailRow label="설비" value={getEquipName(selectedRecord.equipment_code)} />
|
||||||
|
<DetailRow label="설비코드" value={selectedRecord.equipment_code} />
|
||||||
|
<DetailRow label="점검일자" value={fmtDate(selectedRecord.inspection_date)} />
|
||||||
|
<DetailRow label="점검자" value={selectedRecord.inspector || "-"} />
|
||||||
|
<DetailRow label="상태" value={selectedRecord.status || "-"} badge={statusBadge(selectedRecord.status)} />
|
||||||
|
<DetailRow label="비고" value={selectedRecord.remark || "-"} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 점검 항목 상세 */}
|
||||||
|
{selectedItem ? (
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<h3 className="text-[13px] font-bold mb-2">점검 항목 상세</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<DetailRow label="점검항목" value={selectedItem.inspection_item || "-"} />
|
||||||
|
<DetailRow label="점검주기" value={resolveCategory("inspection_cycle", selectedItem.inspection_cycle)} />
|
||||||
|
<DetailRow label="점검내용" value={selectedItem.inspection_content || "-"} span2 />
|
||||||
|
<DetailRow label="점검방법" value={resolveCategory("inspection_method", selectedItem.inspection_method)} span2 />
|
||||||
|
<DetailRow label="하한치" value={selectedItem.lower_limit || "-"} />
|
||||||
|
<DetailRow label="상한치" value={selectedItem.upper_limit || "-"} />
|
||||||
|
<DetailRow label="단위" value={selectedItem.unit || "-"} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||||
|
<p className="text-sm">점검 항목 정보가 없습니다</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 상세 행 ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
function DetailRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
badge,
|
||||||
|
span2,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
badge?: React.ReactNode;
|
||||||
|
span2?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-col gap-0.5", span2 && "col-span-2")}>
|
||||||
|
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">{label}</span>
|
||||||
|
{badge ? <div className="w-fit">{badge}</div> : <span className="text-xs">{value}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
ResizableHandle,
|
||||||
|
ResizablePanel,
|
||||||
|
ResizablePanelGroup,
|
||||||
|
} from "@/components/ui/resizable";
|
||||||
|
import { RefreshCw, Loader2, Inbox, ClipboardCheck, Download } from "lucide-react";
|
||||||
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||||
|
|
||||||
|
// ─── 타입 ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface InspectionMng {
|
||||||
|
id: string;
|
||||||
|
inspection_number: string;
|
||||||
|
item_code: string;
|
||||||
|
item_name: string;
|
||||||
|
inspection_type: string;
|
||||||
|
total_qty: number;
|
||||||
|
good_qty: number;
|
||||||
|
bad_qty: number;
|
||||||
|
overall_judgment: string;
|
||||||
|
inspector: string;
|
||||||
|
inspection_date: string;
|
||||||
|
is_completed: string;
|
||||||
|
defect_description: string;
|
||||||
|
memo: string;
|
||||||
|
supplier_name: string;
|
||||||
|
supplier_code: string;
|
||||||
|
created_date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InspectionDetail {
|
||||||
|
id: string;
|
||||||
|
master_id: string;
|
||||||
|
inspection_item_name: string;
|
||||||
|
inspection_standard: string;
|
||||||
|
pass_criteria: string;
|
||||||
|
measured_value: string;
|
||||||
|
judgment: string;
|
||||||
|
is_required: string;
|
||||||
|
memo: string;
|
||||||
|
item_code: string;
|
||||||
|
item_name: string;
|
||||||
|
inspection_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MASTER_TABLE = "inspection_result_mng";
|
||||||
|
const DETAIL_TABLE = "inspection_result";
|
||||||
|
|
||||||
|
// ─── 메인 컴포넌트 ───────────────────────────────────────
|
||||||
|
|
||||||
|
export default function InspectionResultPage() {
|
||||||
|
const [masterData, setMasterData] = useState<InspectionMng[]>([]);
|
||||||
|
const [detailData, setDetailData] = useState<InspectionDetail[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [filterValues, setFilterValues] = useState<FilterValue[]>([]);
|
||||||
|
|
||||||
|
// ─── 마스터 데이터 조회 ─────────────────────────────────
|
||||||
|
|
||||||
|
const fetchMaster = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const search: Record<string, any> = {};
|
||||||
|
filterValues.forEach((f) => {
|
||||||
|
if (f.value) search[f.column] = f.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
|
||||||
|
page: 1,
|
||||||
|
size: 1000,
|
||||||
|
autoFilter: true,
|
||||||
|
search,
|
||||||
|
});
|
||||||
|
const rows: InspectionMng[] = res.data?.data?.data ?? res.data?.data?.rows ?? [];
|
||||||
|
setMasterData(rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("검사결과 조회 실패:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [filterValues]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMaster();
|
||||||
|
}, [fetchMaster]);
|
||||||
|
|
||||||
|
// ─── 디테일 데이터 조회 ─────────────────────────────────
|
||||||
|
|
||||||
|
const fetchDetail = useCallback(async (masterId: string) => {
|
||||||
|
setDetailLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
|
||||||
|
page: 1,
|
||||||
|
size: 500,
|
||||||
|
autoFilter: true,
|
||||||
|
search: { master_id: masterId },
|
||||||
|
});
|
||||||
|
const rows: InspectionDetail[] = res.data?.data?.data ?? res.data?.data?.rows ?? [];
|
||||||
|
setDetailData(rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("검사상세 조회 실패:", err);
|
||||||
|
setDetailData([]);
|
||||||
|
} finally {
|
||||||
|
setDetailLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedId) fetchDetail(selectedId);
|
||||||
|
else setDetailData([]);
|
||||||
|
}, [selectedId, fetchDetail]);
|
||||||
|
|
||||||
|
// ─── 선택된 마스터 ──────────────────────────────────────
|
||||||
|
|
||||||
|
const selectedMaster = useMemo(
|
||||||
|
() => masterData.find((m) => m.id === selectedId),
|
||||||
|
[masterData, selectedId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── 엑셀 다운로드 ─────────────────────────────────────
|
||||||
|
|
||||||
|
const handleExcel = async () => {
|
||||||
|
await exportToExcel(masterData, "검사결과관리.xlsx", "검사결과");
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 판정 배지 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
const judgmentBadge = (v: string) => {
|
||||||
|
if (!v) return null;
|
||||||
|
const lower = v.toLowerCase();
|
||||||
|
if (["합격", "pass", "적합"].includes(lower))
|
||||||
|
return <Badge className="bg-emerald-500/10 text-emerald-600 border-emerald-200">{v}</Badge>;
|
||||||
|
if (["불합격", "fail", "부적합"].includes(lower))
|
||||||
|
return <Badge className="bg-red-500/10 text-red-600 border-red-200">{v}</Badge>;
|
||||||
|
return <Badge variant="outline">{v}</Badge>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 날짜 포맷 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
const fmtDate = (d: string) => {
|
||||||
|
if (!d) return "-";
|
||||||
|
try {
|
||||||
|
return new Date(d).toLocaleDateString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit" });
|
||||||
|
} catch {
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 렌더링 ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
{/* 검색 필터 */}
|
||||||
|
<div className="shrink-0">
|
||||||
|
<DynamicSearchFilter
|
||||||
|
tableName={MASTER_TABLE}
|
||||||
|
filterId="inspection-result-mng"
|
||||||
|
onFilterChange={(filters) => {
|
||||||
|
setFilterValues(filters);
|
||||||
|
}}
|
||||||
|
dataCount={masterData.length}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between border-b px-4 py-2.5 shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ClipboardCheck className="h-5 w-5 text-primary" />
|
||||||
|
<h2 className="text-base font-bold">검사관리</h2>
|
||||||
|
<Badge variant="secondary">{masterData.length}건</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleExcel}>
|
||||||
|
<Download className="h-4 w-4 mr-1" />
|
||||||
|
엑셀 다운로드
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={fetchMaster} disabled={loading}>
|
||||||
|
<RefreshCw className={cn("h-4 w-4 mr-1", loading && "animate-spin")} />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 분할 패널 */}
|
||||||
|
<ResizablePanelGroup direction="horizontal" className="flex-1 min-h-0">
|
||||||
|
{/* 좌측: 마스터 테이블 */}
|
||||||
|
<ResizablePanel defaultSize={55} minSize={30}>
|
||||||
|
<div className="h-full overflow-auto">
|
||||||
|
{loading && masterData.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin mb-3" />
|
||||||
|
<span className="text-sm">데이터를 불러오는 중...</span>
|
||||||
|
</div>
|
||||||
|
) : masterData.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||||
|
<Inbox className="h-10 w-10 mb-3" />
|
||||||
|
<span className="text-sm">검사 데이터가 없습니다</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table noWrapper className="min-w-max">
|
||||||
|
<TableHeader className="sticky top-0 z-10 bg-muted shadow-[0_1px_0_0_hsl(var(--border))]">
|
||||||
|
<TableRow className="hover:bg-muted">
|
||||||
|
<TableHead className="w-10 text-center">#</TableHead>
|
||||||
|
<TableHead className="min-w-[120px]">검사번호</TableHead>
|
||||||
|
<TableHead className="min-w-[80px]">검사유형</TableHead>
|
||||||
|
<TableHead className="min-w-[120px]">품목코드</TableHead>
|
||||||
|
<TableHead className="min-w-[140px]">품목명</TableHead>
|
||||||
|
<TableHead className="min-w-[80px] text-right">검사수량</TableHead>
|
||||||
|
<TableHead className="min-w-[80px] text-right">양품</TableHead>
|
||||||
|
<TableHead className="min-w-[80px] text-right">불량</TableHead>
|
||||||
|
<TableHead className="min-w-[70px] text-center">판정</TableHead>
|
||||||
|
<TableHead className="min-w-[80px]">검사자</TableHead>
|
||||||
|
<TableHead className="min-w-[100px]">검사일자</TableHead>
|
||||||
|
<TableHead className="min-w-[80px] text-center">완료</TableHead>
|
||||||
|
<TableHead className="min-w-[120px]">거래처</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{masterData.map((row, idx) => (
|
||||||
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer",
|
||||||
|
selectedId === row.id && "bg-primary/5",
|
||||||
|
)}
|
||||||
|
onClick={() => setSelectedId(row.id)}
|
||||||
|
>
|
||||||
|
<TableCell className="text-center text-muted-foreground text-xs">{idx + 1}</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm">{row.inspection_number || "-"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline" className="text-xs">{row.inspection_type || "-"}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm">{row.item_code || "-"}</TableCell>
|
||||||
|
<TableCell className="text-sm">{row.item_name || "-"}</TableCell>
|
||||||
|
<TableCell className="text-right text-sm">{row.total_qty ?? "-"}</TableCell>
|
||||||
|
<TableCell className="text-right text-sm text-emerald-600">{row.good_qty ?? "-"}</TableCell>
|
||||||
|
<TableCell className="text-right text-sm text-red-600">{row.bad_qty ?? "-"}</TableCell>
|
||||||
|
<TableCell className="text-center">{judgmentBadge(row.overall_judgment)}</TableCell>
|
||||||
|
<TableCell className="text-sm">{row.inspector || "-"}</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{fmtDate(row.inspection_date)}</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
{row.is_completed === "Y" ? (
|
||||||
|
<Badge className="bg-emerald-500/10 text-emerald-600 border-emerald-200 text-xs">완료</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="text-xs">진행중</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">{row.supplier_name || "-"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
|
||||||
|
<ResizableHandle withHandle />
|
||||||
|
|
||||||
|
{/* 우측: 디테일 */}
|
||||||
|
<ResizablePanel defaultSize={45} minSize={25}>
|
||||||
|
{!selectedId ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
|
||||||
|
<Inbox className="w-10 h-10 text-muted-foreground/40" />
|
||||||
|
<p className="text-sm">좌측에서 검사를 선택해주세요</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* 상세 헤더 */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/50 shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="text-[13px] font-bold">검사 상세</h3>
|
||||||
|
{selectedMaster && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{selectedMaster.inspection_number}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{selectedMaster && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>{selectedMaster.item_name}</span>
|
||||||
|
<span>·</span>
|
||||||
|
{judgmentBadge(selectedMaster.overall_judgment)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 상세 테이블 */}
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{detailLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-16 text-muted-foreground">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : detailData.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||||
|
<Inbox className="h-8 w-8 mb-2 opacity-40" />
|
||||||
|
<p className="text-sm">검사 항목이 없습니다</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table noWrapper>
|
||||||
|
<TableHeader className="sticky top-0 z-10 bg-muted shadow-[0_1px_0_0_hsl(var(--border))]">
|
||||||
|
<TableRow className="hover:bg-muted">
|
||||||
|
<TableHead className="w-10 text-center">#</TableHead>
|
||||||
|
<TableHead className="min-w-[140px]">검사항목</TableHead>
|
||||||
|
<TableHead className="min-w-[120px]">검사기준</TableHead>
|
||||||
|
<TableHead className="min-w-[100px]">합격기준</TableHead>
|
||||||
|
<TableHead className="min-w-[90px]">측정값</TableHead>
|
||||||
|
<TableHead className="min-w-[70px] text-center">판정</TableHead>
|
||||||
|
<TableHead className="min-w-[60px] text-center">필수</TableHead>
|
||||||
|
<TableHead className="min-w-[120px]">비고</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{detailData.map((d, idx) => (
|
||||||
|
<TableRow key={d.id}>
|
||||||
|
<TableCell className="text-center text-muted-foreground text-xs">{idx + 1}</TableCell>
|
||||||
|
<TableCell className="text-sm font-medium">{d.inspection_item_name || "-"}</TableCell>
|
||||||
|
<TableCell className="text-sm">{d.inspection_standard || "-"}</TableCell>
|
||||||
|
<TableCell className="text-sm">{d.pass_criteria || "-"}</TableCell>
|
||||||
|
<TableCell className="text-sm font-mono">{d.measured_value || "-"}</TableCell>
|
||||||
|
<TableCell className="text-center">{judgmentBadge(d.judgment)}</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
{d.is_required === "Y" ? (
|
||||||
|
<Badge className="bg-amber-500/10 text-amber-600 border-amber-200 text-xs">필수</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{d.memo || "-"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
ResizableHandle,
|
||||||
|
ResizablePanel,
|
||||||
|
ResizablePanelGroup,
|
||||||
|
} from "@/components/ui/resizable";
|
||||||
|
import { RefreshCw, Loader2, Inbox, Wrench, Download } from "lucide-react";
|
||||||
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||||
|
|
||||||
|
// ─── 타입 ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface InspectionRecord {
|
||||||
|
id: string;
|
||||||
|
equipment_code: string;
|
||||||
|
inspection_item_objid: string;
|
||||||
|
inspection_date: string;
|
||||||
|
status: string;
|
||||||
|
inspector: string;
|
||||||
|
remark: string;
|
||||||
|
created_date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InspectionItem {
|
||||||
|
id: string;
|
||||||
|
equipment_code: string;
|
||||||
|
inspection_item: string;
|
||||||
|
inspection_cycle: string;
|
||||||
|
inspection_content: string;
|
||||||
|
inspection_method: string;
|
||||||
|
lower_limit: string;
|
||||||
|
upper_limit: string;
|
||||||
|
unit: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EquipmentInfo {
|
||||||
|
id: string;
|
||||||
|
equipment_code: string;
|
||||||
|
equipment_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RECORD_TABLE = "equipment_inspection_record";
|
||||||
|
|
||||||
|
// ─── 메인 컴포넌트 ───────────────────────────────────────
|
||||||
|
|
||||||
|
export default function EquipmentInspectionRecordPage() {
|
||||||
|
const [records, setRecords] = useState<InspectionRecord[]>([]);
|
||||||
|
const [inspectionItems, setInspectionItems] = useState<Map<string, InspectionItem>>(new Map());
|
||||||
|
const [equipments, setEquipments] = useState<Map<string, EquipmentInfo>>(new Map());
|
||||||
|
const [categoryMap, setCategoryMap] = useState<Record<string, Record<string, string>>>({});
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [filterValues, setFilterValues] = useState<FilterValue[]>([]);
|
||||||
|
|
||||||
|
// ─── 데이터 조회 ────────────────────────────────────────
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const search: Record<string, any> = {};
|
||||||
|
filterValues.forEach((f) => {
|
||||||
|
if (f.value) search[f.column] = f.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const [recordRes, itemRes, equipRes] = await Promise.all([
|
||||||
|
apiClient.post(`/table-management/tables/${RECORD_TABLE}/data`, {
|
||||||
|
page: 1,
|
||||||
|
size: 1000,
|
||||||
|
autoFilter: true,
|
||||||
|
search,
|
||||||
|
}),
|
||||||
|
apiClient.post(`/table-management/tables/equipment_inspection_item/data`, {
|
||||||
|
page: 1,
|
||||||
|
size: 1000,
|
||||||
|
autoFilter: true,
|
||||||
|
}).catch(() => ({ data: { data: { data: [] } } })),
|
||||||
|
apiClient.post(`/table-management/tables/equipment_mng/data`, {
|
||||||
|
page: 1,
|
||||||
|
size: 500,
|
||||||
|
autoFilter: true,
|
||||||
|
}).catch(() => ({ data: { data: { data: [] } } })),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const rRows: InspectionRecord[] = recordRes.data?.data?.data ?? recordRes.data?.data?.rows ?? [];
|
||||||
|
setRecords(rRows);
|
||||||
|
|
||||||
|
const iRows: InspectionItem[] = itemRes.data?.data?.data ?? itemRes.data?.data?.rows ?? [];
|
||||||
|
const iMap = new Map<string, InspectionItem>();
|
||||||
|
iRows.forEach((i) => iMap.set(i.id, i));
|
||||||
|
setInspectionItems(iMap);
|
||||||
|
|
||||||
|
const eRows: EquipmentInfo[] = equipRes.data?.data?.data ?? equipRes.data?.data?.rows ?? [];
|
||||||
|
const eMap = new Map<string, EquipmentInfo>();
|
||||||
|
eRows.forEach((e) => {
|
||||||
|
eMap.set(e.equipment_code, e);
|
||||||
|
eMap.set(e.id, e);
|
||||||
|
});
|
||||||
|
setEquipments(eMap);
|
||||||
|
|
||||||
|
// 카테고리 코드→라벨 매핑 (점검주기, 점검방법)
|
||||||
|
try {
|
||||||
|
const catCols = ["inspection_cycle", "inspection_method"];
|
||||||
|
const catResults = await Promise.all(
|
||||||
|
catCols.map((col) =>
|
||||||
|
apiClient.get(`/table-categories/equipment_inspection_item/${col}/values`).catch(() => ({ data: [] })),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const cMap: Record<string, Record<string, string>> = {};
|
||||||
|
catCols.forEach((col, idx) => {
|
||||||
|
const vals: { code: string; label: string }[] = catResults[idx].data?.data ?? catResults[idx].data ?? [];
|
||||||
|
cMap[col] = {};
|
||||||
|
vals.forEach((v: any) => { cMap[col][v.valueCode || v.code] = v.valueLabel || v.label; });
|
||||||
|
});
|
||||||
|
setCategoryMap(cMap);
|
||||||
|
} catch {
|
||||||
|
// 카테고리 조회 실패 무시
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("점검기록 조회 실패:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [filterValues]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
// ─── 선택된 레코드 ─────────────────────────────────────
|
||||||
|
|
||||||
|
const selectedRecord = useMemo(() => records.find((r) => r.id === selectedId), [records, selectedId]);
|
||||||
|
const selectedItem = useMemo(
|
||||||
|
() => (selectedRecord ? inspectionItems.get(selectedRecord.inspection_item_objid) : undefined),
|
||||||
|
[selectedRecord, inspectionItems],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── 설비명 조회 ───────────────────────────────────────
|
||||||
|
|
||||||
|
const getEquipName = (code: string) => equipments.get(code)?.equipment_name || code || "-";
|
||||||
|
|
||||||
|
// ─── 카테고리 코드→라벨 변환 ──────────────────────────
|
||||||
|
|
||||||
|
const resolveCategory = (col: string, code: string) => {
|
||||||
|
if (!code) return "-";
|
||||||
|
return categoryMap[col]?.[code] || code;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 엑셀 다운로드 ─────────────────────────────────────
|
||||||
|
|
||||||
|
const handleExcel = async () => {
|
||||||
|
const data = records.map((r) => ({
|
||||||
|
설비코드: r.equipment_code,
|
||||||
|
설비명: getEquipName(r.equipment_code),
|
||||||
|
점검항목: inspectionItems.get(r.inspection_item_objid)?.inspection_item || "-",
|
||||||
|
점검일자: fmtDate(r.inspection_date),
|
||||||
|
상태: r.status,
|
||||||
|
점검자: r.inspector,
|
||||||
|
비고: r.remark,
|
||||||
|
}));
|
||||||
|
await exportToExcel(data, "설비점검기록.xlsx", "점검기록");
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 날짜/상태 포맷 ────────────────────────────────────
|
||||||
|
|
||||||
|
const fmtDate = (d: string) => {
|
||||||
|
if (!d) return "-";
|
||||||
|
try {
|
||||||
|
return new Date(d).toLocaleDateString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit" });
|
||||||
|
} catch {
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusBadge = (v: string) => {
|
||||||
|
if (!v) return <Badge variant="outline" className="text-xs">-</Badge>;
|
||||||
|
const lower = v.toLowerCase();
|
||||||
|
if (["완료", "pass", "정상", "합격", "completed"].includes(lower))
|
||||||
|
return <Badge className="bg-emerald-500/10 text-emerald-600 border-emerald-200 text-xs">{v}</Badge>;
|
||||||
|
if (["이상", "fail", "불합격", "비정상"].includes(lower))
|
||||||
|
return <Badge className="bg-red-500/10 text-red-600 border-red-200 text-xs">{v}</Badge>;
|
||||||
|
if (["점검중", "진행", "in_progress"].includes(lower))
|
||||||
|
return <Badge className="bg-blue-500/10 text-blue-600 border-blue-200 text-xs">{v}</Badge>;
|
||||||
|
return <Badge variant="outline" className="text-xs">{v}</Badge>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 렌더링 ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
{/* 검색 필터 */}
|
||||||
|
<div className="shrink-0">
|
||||||
|
<DynamicSearchFilter
|
||||||
|
tableName={RECORD_TABLE}
|
||||||
|
filterId="equip-inspection-record"
|
||||||
|
onFilterChange={(filters) => setFilterValues(filters)}
|
||||||
|
dataCount={records.length}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between border-b px-4 py-2.5 shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Wrench className="h-5 w-5 text-primary" />
|
||||||
|
<h2 className="text-base font-bold">점검관리</h2>
|
||||||
|
<Badge variant="secondary">{records.length}건</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleExcel}>
|
||||||
|
<Download className="h-4 w-4 mr-1" />
|
||||||
|
엑셀 다운로드
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={fetchData} disabled={loading}>
|
||||||
|
<RefreshCw className={cn("h-4 w-4 mr-1", loading && "animate-spin")} />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 분할 패널 */}
|
||||||
|
<ResizablePanelGroup direction="horizontal" className="flex-1 min-h-0">
|
||||||
|
{/* 좌측: 점검기록 테이블 */}
|
||||||
|
<ResizablePanel defaultSize={60} minSize={35}>
|
||||||
|
<div className="h-full overflow-auto">
|
||||||
|
{loading && records.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin mb-3" />
|
||||||
|
<span className="text-sm">데이터를 불러오는 중...</span>
|
||||||
|
</div>
|
||||||
|
) : records.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||||
|
<Inbox className="h-10 w-10 mb-3" />
|
||||||
|
<span className="text-sm">점검 기록이 없습니다</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table noWrapper className="min-w-max">
|
||||||
|
<TableHeader className="sticky top-0 z-10 bg-muted shadow-[0_1px_0_0_hsl(var(--border))]">
|
||||||
|
<TableRow className="hover:bg-muted">
|
||||||
|
<TableHead className="w-10 text-center">#</TableHead>
|
||||||
|
<TableHead className="min-w-[100px]">설비코드</TableHead>
|
||||||
|
<TableHead className="min-w-[120px]">설비명</TableHead>
|
||||||
|
<TableHead className="min-w-[140px]">점검항목</TableHead>
|
||||||
|
<TableHead className="min-w-[100px]">점검일자</TableHead>
|
||||||
|
<TableHead className="min-w-[70px] text-center">상태</TableHead>
|
||||||
|
<TableHead className="min-w-[80px]">점검자</TableHead>
|
||||||
|
<TableHead className="min-w-[150px]">비고</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{records.map((row, idx) => {
|
||||||
|
const item = inspectionItems.get(row.inspection_item_objid);
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
className={cn("cursor-pointer", selectedId === row.id && "bg-primary/5")}
|
||||||
|
onClick={() => setSelectedId(row.id)}
|
||||||
|
>
|
||||||
|
<TableCell className="text-center text-muted-foreground text-xs">{idx + 1}</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm">{row.equipment_code || "-"}</TableCell>
|
||||||
|
<TableCell className="text-sm">{getEquipName(row.equipment_code)}</TableCell>
|
||||||
|
<TableCell className="text-sm">{item?.inspection_item || "-"}</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{fmtDate(row.inspection_date)}</TableCell>
|
||||||
|
<TableCell className="text-center">{statusBadge(row.status)}</TableCell>
|
||||||
|
<TableCell className="text-sm">{row.inspector || "-"}</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{row.remark || "-"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
|
||||||
|
<ResizableHandle withHandle />
|
||||||
|
|
||||||
|
{/* 우측: 점검항목 상세 */}
|
||||||
|
<ResizablePanel defaultSize={40} minSize={25}>
|
||||||
|
{!selectedId || !selectedRecord ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
|
||||||
|
<Inbox className="w-10 h-10 text-muted-foreground/40" />
|
||||||
|
<p className="text-sm">좌측에서 점검 기록을 선택해주세요</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col h-full overflow-auto">
|
||||||
|
{/* 점검 기록 요약 */}
|
||||||
|
<div className="px-4 py-3 border-b bg-muted/50 shrink-0">
|
||||||
|
<h3 className="text-[13px] font-bold mb-2">점검 기록 정보</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<DetailRow label="설비" value={getEquipName(selectedRecord.equipment_code)} />
|
||||||
|
<DetailRow label="설비코드" value={selectedRecord.equipment_code} />
|
||||||
|
<DetailRow label="점검일자" value={fmtDate(selectedRecord.inspection_date)} />
|
||||||
|
<DetailRow label="점검자" value={selectedRecord.inspector || "-"} />
|
||||||
|
<DetailRow label="상태" value={selectedRecord.status || "-"} badge={statusBadge(selectedRecord.status)} />
|
||||||
|
<DetailRow label="비고" value={selectedRecord.remark || "-"} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 점검 항목 상세 */}
|
||||||
|
{selectedItem ? (
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<h3 className="text-[13px] font-bold mb-2">점검 항목 상세</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<DetailRow label="점검항목" value={selectedItem.inspection_item || "-"} />
|
||||||
|
<DetailRow label="점검주기" value={resolveCategory("inspection_cycle", selectedItem.inspection_cycle)} />
|
||||||
|
<DetailRow label="점검내용" value={selectedItem.inspection_content || "-"} span2 />
|
||||||
|
<DetailRow label="점검방법" value={resolveCategory("inspection_method", selectedItem.inspection_method)} span2 />
|
||||||
|
<DetailRow label="하한치" value={selectedItem.lower_limit || "-"} />
|
||||||
|
<DetailRow label="상한치" value={selectedItem.upper_limit || "-"} />
|
||||||
|
<DetailRow label="단위" value={selectedItem.unit || "-"} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||||
|
<p className="text-sm">점검 항목 정보가 없습니다</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 상세 행 ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
function DetailRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
badge,
|
||||||
|
span2,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
badge?: React.ReactNode;
|
||||||
|
span2?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-col gap-0.5", span2 && "col-span-2")}>
|
||||||
|
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">{label}</span>
|
||||||
|
{badge ? <div className="w-fit">{badge}</div> : <span className="text-xs">{value}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
ResizableHandle,
|
||||||
|
ResizablePanel,
|
||||||
|
ResizablePanelGroup,
|
||||||
|
} from "@/components/ui/resizable";
|
||||||
|
import { RefreshCw, Loader2, Inbox, ClipboardCheck, Download } from "lucide-react";
|
||||||
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||||
|
|
||||||
|
// ─── 타입 ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface InspectionMng {
|
||||||
|
id: string;
|
||||||
|
inspection_number: string;
|
||||||
|
item_code: string;
|
||||||
|
item_name: string;
|
||||||
|
inspection_type: string;
|
||||||
|
total_qty: number;
|
||||||
|
good_qty: number;
|
||||||
|
bad_qty: number;
|
||||||
|
overall_judgment: string;
|
||||||
|
inspector: string;
|
||||||
|
inspection_date: string;
|
||||||
|
is_completed: string;
|
||||||
|
defect_description: string;
|
||||||
|
memo: string;
|
||||||
|
supplier_name: string;
|
||||||
|
supplier_code: string;
|
||||||
|
created_date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InspectionDetail {
|
||||||
|
id: string;
|
||||||
|
master_id: string;
|
||||||
|
inspection_item_name: string;
|
||||||
|
inspection_standard: string;
|
||||||
|
pass_criteria: string;
|
||||||
|
measured_value: string;
|
||||||
|
judgment: string;
|
||||||
|
is_required: string;
|
||||||
|
memo: string;
|
||||||
|
item_code: string;
|
||||||
|
item_name: string;
|
||||||
|
inspection_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MASTER_TABLE = "inspection_result_mng";
|
||||||
|
const DETAIL_TABLE = "inspection_result";
|
||||||
|
|
||||||
|
// ─── 메인 컴포넌트 ───────────────────────────────────────
|
||||||
|
|
||||||
|
export default function InspectionResultPage() {
|
||||||
|
const [masterData, setMasterData] = useState<InspectionMng[]>([]);
|
||||||
|
const [detailData, setDetailData] = useState<InspectionDetail[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [filterValues, setFilterValues] = useState<FilterValue[]>([]);
|
||||||
|
|
||||||
|
// ─── 마스터 데이터 조회 ─────────────────────────────────
|
||||||
|
|
||||||
|
const fetchMaster = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const search: Record<string, any> = {};
|
||||||
|
filterValues.forEach((f) => {
|
||||||
|
if (f.value) search[f.column] = f.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
|
||||||
|
page: 1,
|
||||||
|
size: 1000,
|
||||||
|
autoFilter: true,
|
||||||
|
search,
|
||||||
|
});
|
||||||
|
const rows: InspectionMng[] = res.data?.data?.data ?? res.data?.data?.rows ?? [];
|
||||||
|
setMasterData(rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("검사결과 조회 실패:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [filterValues]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMaster();
|
||||||
|
}, [fetchMaster]);
|
||||||
|
|
||||||
|
// ─── 디테일 데이터 조회 ─────────────────────────────────
|
||||||
|
|
||||||
|
const fetchDetail = useCallback(async (masterId: string) => {
|
||||||
|
setDetailLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
|
||||||
|
page: 1,
|
||||||
|
size: 500,
|
||||||
|
autoFilter: true,
|
||||||
|
search: { master_id: masterId },
|
||||||
|
});
|
||||||
|
const rows: InspectionDetail[] = res.data?.data?.data ?? res.data?.data?.rows ?? [];
|
||||||
|
setDetailData(rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("검사상세 조회 실패:", err);
|
||||||
|
setDetailData([]);
|
||||||
|
} finally {
|
||||||
|
setDetailLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedId) fetchDetail(selectedId);
|
||||||
|
else setDetailData([]);
|
||||||
|
}, [selectedId, fetchDetail]);
|
||||||
|
|
||||||
|
// ─── 선택된 마스터 ──────────────────────────────────────
|
||||||
|
|
||||||
|
const selectedMaster = useMemo(
|
||||||
|
() => masterData.find((m) => m.id === selectedId),
|
||||||
|
[masterData, selectedId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── 엑셀 다운로드 ─────────────────────────────────────
|
||||||
|
|
||||||
|
const handleExcel = async () => {
|
||||||
|
await exportToExcel(masterData, "검사결과관리.xlsx", "검사결과");
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 판정 배지 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
const judgmentBadge = (v: string) => {
|
||||||
|
if (!v) return null;
|
||||||
|
const lower = v.toLowerCase();
|
||||||
|
if (["합격", "pass", "적합"].includes(lower))
|
||||||
|
return <Badge className="bg-emerald-500/10 text-emerald-600 border-emerald-200">{v}</Badge>;
|
||||||
|
if (["불합격", "fail", "부적합"].includes(lower))
|
||||||
|
return <Badge className="bg-red-500/10 text-red-600 border-red-200">{v}</Badge>;
|
||||||
|
return <Badge variant="outline">{v}</Badge>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 날짜 포맷 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
const fmtDate = (d: string) => {
|
||||||
|
if (!d) return "-";
|
||||||
|
try {
|
||||||
|
return new Date(d).toLocaleDateString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit" });
|
||||||
|
} catch {
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 렌더링 ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
{/* 검색 필터 */}
|
||||||
|
<div className="shrink-0">
|
||||||
|
<DynamicSearchFilter
|
||||||
|
tableName={MASTER_TABLE}
|
||||||
|
filterId="inspection-result-mng"
|
||||||
|
onFilterChange={(filters) => {
|
||||||
|
setFilterValues(filters);
|
||||||
|
}}
|
||||||
|
dataCount={masterData.length}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between border-b px-4 py-2.5 shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ClipboardCheck className="h-5 w-5 text-primary" />
|
||||||
|
<h2 className="text-base font-bold">검사관리</h2>
|
||||||
|
<Badge variant="secondary">{masterData.length}건</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleExcel}>
|
||||||
|
<Download className="h-4 w-4 mr-1" />
|
||||||
|
엑셀 다운로드
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={fetchMaster} disabled={loading}>
|
||||||
|
<RefreshCw className={cn("h-4 w-4 mr-1", loading && "animate-spin")} />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 분할 패널 */}
|
||||||
|
<ResizablePanelGroup direction="horizontal" className="flex-1 min-h-0">
|
||||||
|
{/* 좌측: 마스터 테이블 */}
|
||||||
|
<ResizablePanel defaultSize={55} minSize={30}>
|
||||||
|
<div className="h-full overflow-auto">
|
||||||
|
{loading && masterData.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin mb-3" />
|
||||||
|
<span className="text-sm">데이터를 불러오는 중...</span>
|
||||||
|
</div>
|
||||||
|
) : masterData.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||||
|
<Inbox className="h-10 w-10 mb-3" />
|
||||||
|
<span className="text-sm">검사 데이터가 없습니다</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table noWrapper className="min-w-max">
|
||||||
|
<TableHeader className="sticky top-0 z-10 bg-muted shadow-[0_1px_0_0_hsl(var(--border))]">
|
||||||
|
<TableRow className="hover:bg-muted">
|
||||||
|
<TableHead className="w-10 text-center">#</TableHead>
|
||||||
|
<TableHead className="min-w-[120px]">검사번호</TableHead>
|
||||||
|
<TableHead className="min-w-[80px]">검사유형</TableHead>
|
||||||
|
<TableHead className="min-w-[120px]">품목코드</TableHead>
|
||||||
|
<TableHead className="min-w-[140px]">품목명</TableHead>
|
||||||
|
<TableHead className="min-w-[80px] text-right">검사수량</TableHead>
|
||||||
|
<TableHead className="min-w-[80px] text-right">양품</TableHead>
|
||||||
|
<TableHead className="min-w-[80px] text-right">불량</TableHead>
|
||||||
|
<TableHead className="min-w-[70px] text-center">판정</TableHead>
|
||||||
|
<TableHead className="min-w-[80px]">검사자</TableHead>
|
||||||
|
<TableHead className="min-w-[100px]">검사일자</TableHead>
|
||||||
|
<TableHead className="min-w-[80px] text-center">완료</TableHead>
|
||||||
|
<TableHead className="min-w-[120px]">거래처</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{masterData.map((row, idx) => (
|
||||||
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer",
|
||||||
|
selectedId === row.id && "bg-primary/5",
|
||||||
|
)}
|
||||||
|
onClick={() => setSelectedId(row.id)}
|
||||||
|
>
|
||||||
|
<TableCell className="text-center text-muted-foreground text-xs">{idx + 1}</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm">{row.inspection_number || "-"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline" className="text-xs">{row.inspection_type || "-"}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm">{row.item_code || "-"}</TableCell>
|
||||||
|
<TableCell className="text-sm">{row.item_name || "-"}</TableCell>
|
||||||
|
<TableCell className="text-right text-sm">{row.total_qty ?? "-"}</TableCell>
|
||||||
|
<TableCell className="text-right text-sm text-emerald-600">{row.good_qty ?? "-"}</TableCell>
|
||||||
|
<TableCell className="text-right text-sm text-red-600">{row.bad_qty ?? "-"}</TableCell>
|
||||||
|
<TableCell className="text-center">{judgmentBadge(row.overall_judgment)}</TableCell>
|
||||||
|
<TableCell className="text-sm">{row.inspector || "-"}</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{fmtDate(row.inspection_date)}</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
{row.is_completed === "Y" ? (
|
||||||
|
<Badge className="bg-emerald-500/10 text-emerald-600 border-emerald-200 text-xs">완료</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="text-xs">진행중</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">{row.supplier_name || "-"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
|
||||||
|
<ResizableHandle withHandle />
|
||||||
|
|
||||||
|
{/* 우측: 디테일 */}
|
||||||
|
<ResizablePanel defaultSize={45} minSize={25}>
|
||||||
|
{!selectedId ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
|
||||||
|
<Inbox className="w-10 h-10 text-muted-foreground/40" />
|
||||||
|
<p className="text-sm">좌측에서 검사를 선택해주세요</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* 상세 헤더 */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/50 shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="text-[13px] font-bold">검사 상세</h3>
|
||||||
|
{selectedMaster && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{selectedMaster.inspection_number}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{selectedMaster && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>{selectedMaster.item_name}</span>
|
||||||
|
<span>·</span>
|
||||||
|
{judgmentBadge(selectedMaster.overall_judgment)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 상세 테이블 */}
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{detailLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-16 text-muted-foreground">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : detailData.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||||
|
<Inbox className="h-8 w-8 mb-2 opacity-40" />
|
||||||
|
<p className="text-sm">검사 항목이 없습니다</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table noWrapper>
|
||||||
|
<TableHeader className="sticky top-0 z-10 bg-muted shadow-[0_1px_0_0_hsl(var(--border))]">
|
||||||
|
<TableRow className="hover:bg-muted">
|
||||||
|
<TableHead className="w-10 text-center">#</TableHead>
|
||||||
|
<TableHead className="min-w-[140px]">검사항목</TableHead>
|
||||||
|
<TableHead className="min-w-[120px]">검사기준</TableHead>
|
||||||
|
<TableHead className="min-w-[100px]">합격기준</TableHead>
|
||||||
|
<TableHead className="min-w-[90px]">측정값</TableHead>
|
||||||
|
<TableHead className="min-w-[70px] text-center">판정</TableHead>
|
||||||
|
<TableHead className="min-w-[60px] text-center">필수</TableHead>
|
||||||
|
<TableHead className="min-w-[120px]">비고</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{detailData.map((d, idx) => (
|
||||||
|
<TableRow key={d.id}>
|
||||||
|
<TableCell className="text-center text-muted-foreground text-xs">{idx + 1}</TableCell>
|
||||||
|
<TableCell className="text-sm font-medium">{d.inspection_item_name || "-"}</TableCell>
|
||||||
|
<TableCell className="text-sm">{d.inspection_standard || "-"}</TableCell>
|
||||||
|
<TableCell className="text-sm">{d.pass_criteria || "-"}</TableCell>
|
||||||
|
<TableCell className="text-sm font-mono">{d.measured_value || "-"}</TableCell>
|
||||||
|
<TableCell className="text-center">{judgmentBadge(d.judgment)}</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
{d.is_required === "Y" ? (
|
||||||
|
<Badge className="bg-amber-500/10 text-amber-600 border-amber-200 text-xs">필수</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{d.memo || "-"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
ResizableHandle,
|
||||||
|
ResizablePanel,
|
||||||
|
ResizablePanelGroup,
|
||||||
|
} from "@/components/ui/resizable";
|
||||||
|
import { RefreshCw, Loader2, Inbox, Wrench, Download } from "lucide-react";
|
||||||
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||||
|
|
||||||
|
// ─── 타입 ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface InspectionRecord {
|
||||||
|
id: string;
|
||||||
|
equipment_code: string;
|
||||||
|
inspection_item_objid: string;
|
||||||
|
inspection_date: string;
|
||||||
|
status: string;
|
||||||
|
inspector: string;
|
||||||
|
remark: string;
|
||||||
|
created_date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InspectionItem {
|
||||||
|
id: string;
|
||||||
|
equipment_code: string;
|
||||||
|
inspection_item: string;
|
||||||
|
inspection_cycle: string;
|
||||||
|
inspection_content: string;
|
||||||
|
inspection_method: string;
|
||||||
|
lower_limit: string;
|
||||||
|
upper_limit: string;
|
||||||
|
unit: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EquipmentInfo {
|
||||||
|
id: string;
|
||||||
|
equipment_code: string;
|
||||||
|
equipment_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RECORD_TABLE = "equipment_inspection_record";
|
||||||
|
|
||||||
|
// ─── 메인 컴포넌트 ───────────────────────────────────────
|
||||||
|
|
||||||
|
export default function EquipmentInspectionRecordPage() {
|
||||||
|
const [records, setRecords] = useState<InspectionRecord[]>([]);
|
||||||
|
const [inspectionItems, setInspectionItems] = useState<Map<string, InspectionItem>>(new Map());
|
||||||
|
const [equipments, setEquipments] = useState<Map<string, EquipmentInfo>>(new Map());
|
||||||
|
const [categoryMap, setCategoryMap] = useState<Record<string, Record<string, string>>>({});
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [filterValues, setFilterValues] = useState<FilterValue[]>([]);
|
||||||
|
|
||||||
|
// ─── 데이터 조회 ────────────────────────────────────────
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const search: Record<string, any> = {};
|
||||||
|
filterValues.forEach((f) => {
|
||||||
|
if (f.value) search[f.column] = f.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const [recordRes, itemRes, equipRes] = await Promise.all([
|
||||||
|
apiClient.post(`/table-management/tables/${RECORD_TABLE}/data`, {
|
||||||
|
page: 1,
|
||||||
|
size: 1000,
|
||||||
|
autoFilter: true,
|
||||||
|
search,
|
||||||
|
}),
|
||||||
|
apiClient.post(`/table-management/tables/equipment_inspection_item/data`, {
|
||||||
|
page: 1,
|
||||||
|
size: 1000,
|
||||||
|
autoFilter: true,
|
||||||
|
}).catch(() => ({ data: { data: { data: [] } } })),
|
||||||
|
apiClient.post(`/table-management/tables/equipment_mng/data`, {
|
||||||
|
page: 1,
|
||||||
|
size: 500,
|
||||||
|
autoFilter: true,
|
||||||
|
}).catch(() => ({ data: { data: { data: [] } } })),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const rRows: InspectionRecord[] = recordRes.data?.data?.data ?? recordRes.data?.data?.rows ?? [];
|
||||||
|
setRecords(rRows);
|
||||||
|
|
||||||
|
const iRows: InspectionItem[] = itemRes.data?.data?.data ?? itemRes.data?.data?.rows ?? [];
|
||||||
|
const iMap = new Map<string, InspectionItem>();
|
||||||
|
iRows.forEach((i) => iMap.set(i.id, i));
|
||||||
|
setInspectionItems(iMap);
|
||||||
|
|
||||||
|
const eRows: EquipmentInfo[] = equipRes.data?.data?.data ?? equipRes.data?.data?.rows ?? [];
|
||||||
|
const eMap = new Map<string, EquipmentInfo>();
|
||||||
|
eRows.forEach((e) => {
|
||||||
|
eMap.set(e.equipment_code, e);
|
||||||
|
eMap.set(e.id, e);
|
||||||
|
});
|
||||||
|
setEquipments(eMap);
|
||||||
|
|
||||||
|
// 카테고리 코드→라벨 매핑 (점검주기, 점검방법)
|
||||||
|
try {
|
||||||
|
const catCols = ["inspection_cycle", "inspection_method"];
|
||||||
|
const catResults = await Promise.all(
|
||||||
|
catCols.map((col) =>
|
||||||
|
apiClient.get(`/table-categories/equipment_inspection_item/${col}/values`).catch(() => ({ data: [] })),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const cMap: Record<string, Record<string, string>> = {};
|
||||||
|
catCols.forEach((col, idx) => {
|
||||||
|
const vals: { code: string; label: string }[] = catResults[idx].data?.data ?? catResults[idx].data ?? [];
|
||||||
|
cMap[col] = {};
|
||||||
|
vals.forEach((v: any) => { cMap[col][v.valueCode || v.code] = v.valueLabel || v.label; });
|
||||||
|
});
|
||||||
|
setCategoryMap(cMap);
|
||||||
|
} catch {
|
||||||
|
// 카테고리 조회 실패 무시
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("점검기록 조회 실패:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [filterValues]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
// ─── 선택된 레코드 ─────────────────────────────────────
|
||||||
|
|
||||||
|
const selectedRecord = useMemo(() => records.find((r) => r.id === selectedId), [records, selectedId]);
|
||||||
|
const selectedItem = useMemo(
|
||||||
|
() => (selectedRecord ? inspectionItems.get(selectedRecord.inspection_item_objid) : undefined),
|
||||||
|
[selectedRecord, inspectionItems],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── 설비명 조회 ───────────────────────────────────────
|
||||||
|
|
||||||
|
const getEquipName = (code: string) => equipments.get(code)?.equipment_name || code || "-";
|
||||||
|
|
||||||
|
// ─── 카테고리 코드→라벨 변환 ──────────────────────────
|
||||||
|
|
||||||
|
const resolveCategory = (col: string, code: string) => {
|
||||||
|
if (!code) return "-";
|
||||||
|
return categoryMap[col]?.[code] || code;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 엑셀 다운로드 ─────────────────────────────────────
|
||||||
|
|
||||||
|
const handleExcel = async () => {
|
||||||
|
const data = records.map((r) => ({
|
||||||
|
설비코드: r.equipment_code,
|
||||||
|
설비명: getEquipName(r.equipment_code),
|
||||||
|
점검항목: inspectionItems.get(r.inspection_item_objid)?.inspection_item || "-",
|
||||||
|
점검일자: fmtDate(r.inspection_date),
|
||||||
|
상태: r.status,
|
||||||
|
점검자: r.inspector,
|
||||||
|
비고: r.remark,
|
||||||
|
}));
|
||||||
|
await exportToExcel(data, "설비점검기록.xlsx", "점검기록");
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 날짜/상태 포맷 ────────────────────────────────────
|
||||||
|
|
||||||
|
const fmtDate = (d: string) => {
|
||||||
|
if (!d) return "-";
|
||||||
|
try {
|
||||||
|
return new Date(d).toLocaleDateString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit" });
|
||||||
|
} catch {
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusBadge = (v: string) => {
|
||||||
|
if (!v) return <Badge variant="outline" className="text-xs">-</Badge>;
|
||||||
|
const lower = v.toLowerCase();
|
||||||
|
if (["완료", "pass", "정상", "합격", "completed"].includes(lower))
|
||||||
|
return <Badge className="bg-emerald-500/10 text-emerald-600 border-emerald-200 text-xs">{v}</Badge>;
|
||||||
|
if (["이상", "fail", "불합격", "비정상"].includes(lower))
|
||||||
|
return <Badge className="bg-red-500/10 text-red-600 border-red-200 text-xs">{v}</Badge>;
|
||||||
|
if (["점검중", "진행", "in_progress"].includes(lower))
|
||||||
|
return <Badge className="bg-blue-500/10 text-blue-600 border-blue-200 text-xs">{v}</Badge>;
|
||||||
|
return <Badge variant="outline" className="text-xs">{v}</Badge>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 렌더링 ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
{/* 검색 필터 */}
|
||||||
|
<div className="shrink-0">
|
||||||
|
<DynamicSearchFilter
|
||||||
|
tableName={RECORD_TABLE}
|
||||||
|
filterId="equip-inspection-record"
|
||||||
|
onFilterChange={(filters) => setFilterValues(filters)}
|
||||||
|
dataCount={records.length}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between border-b px-4 py-2.5 shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Wrench className="h-5 w-5 text-primary" />
|
||||||
|
<h2 className="text-base font-bold">점검관리</h2>
|
||||||
|
<Badge variant="secondary">{records.length}건</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleExcel}>
|
||||||
|
<Download className="h-4 w-4 mr-1" />
|
||||||
|
엑셀 다운로드
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={fetchData} disabled={loading}>
|
||||||
|
<RefreshCw className={cn("h-4 w-4 mr-1", loading && "animate-spin")} />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 분할 패널 */}
|
||||||
|
<ResizablePanelGroup direction="horizontal" className="flex-1 min-h-0">
|
||||||
|
{/* 좌측: 점검기록 테이블 */}
|
||||||
|
<ResizablePanel defaultSize={60} minSize={35}>
|
||||||
|
<div className="h-full overflow-auto">
|
||||||
|
{loading && records.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin mb-3" />
|
||||||
|
<span className="text-sm">데이터를 불러오는 중...</span>
|
||||||
|
</div>
|
||||||
|
) : records.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||||
|
<Inbox className="h-10 w-10 mb-3" />
|
||||||
|
<span className="text-sm">점검 기록이 없습니다</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table noWrapper className="min-w-max">
|
||||||
|
<TableHeader className="sticky top-0 z-10 bg-muted shadow-[0_1px_0_0_hsl(var(--border))]">
|
||||||
|
<TableRow className="hover:bg-muted">
|
||||||
|
<TableHead className="w-10 text-center">#</TableHead>
|
||||||
|
<TableHead className="min-w-[100px]">설비코드</TableHead>
|
||||||
|
<TableHead className="min-w-[120px]">설비명</TableHead>
|
||||||
|
<TableHead className="min-w-[140px]">점검항목</TableHead>
|
||||||
|
<TableHead className="min-w-[100px]">점검일자</TableHead>
|
||||||
|
<TableHead className="min-w-[70px] text-center">상태</TableHead>
|
||||||
|
<TableHead className="min-w-[80px]">점검자</TableHead>
|
||||||
|
<TableHead className="min-w-[150px]">비고</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{records.map((row, idx) => {
|
||||||
|
const item = inspectionItems.get(row.inspection_item_objid);
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
className={cn("cursor-pointer", selectedId === row.id && "bg-primary/5")}
|
||||||
|
onClick={() => setSelectedId(row.id)}
|
||||||
|
>
|
||||||
|
<TableCell className="text-center text-muted-foreground text-xs">{idx + 1}</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm">{row.equipment_code || "-"}</TableCell>
|
||||||
|
<TableCell className="text-sm">{getEquipName(row.equipment_code)}</TableCell>
|
||||||
|
<TableCell className="text-sm">{item?.inspection_item || "-"}</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{fmtDate(row.inspection_date)}</TableCell>
|
||||||
|
<TableCell className="text-center">{statusBadge(row.status)}</TableCell>
|
||||||
|
<TableCell className="text-sm">{row.inspector || "-"}</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{row.remark || "-"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
|
||||||
|
<ResizableHandle withHandle />
|
||||||
|
|
||||||
|
{/* 우측: 점검항목 상세 */}
|
||||||
|
<ResizablePanel defaultSize={40} minSize={25}>
|
||||||
|
{!selectedId || !selectedRecord ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
|
||||||
|
<Inbox className="w-10 h-10 text-muted-foreground/40" />
|
||||||
|
<p className="text-sm">좌측에서 점검 기록을 선택해주세요</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col h-full overflow-auto">
|
||||||
|
{/* 점검 기록 요약 */}
|
||||||
|
<div className="px-4 py-3 border-b bg-muted/50 shrink-0">
|
||||||
|
<h3 className="text-[13px] font-bold mb-2">점검 기록 정보</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<DetailRow label="설비" value={getEquipName(selectedRecord.equipment_code)} />
|
||||||
|
<DetailRow label="설비코드" value={selectedRecord.equipment_code} />
|
||||||
|
<DetailRow label="점검일자" value={fmtDate(selectedRecord.inspection_date)} />
|
||||||
|
<DetailRow label="점검자" value={selectedRecord.inspector || "-"} />
|
||||||
|
<DetailRow label="상태" value={selectedRecord.status || "-"} badge={statusBadge(selectedRecord.status)} />
|
||||||
|
<DetailRow label="비고" value={selectedRecord.remark || "-"} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 점검 항목 상세 */}
|
||||||
|
{selectedItem ? (
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<h3 className="text-[13px] font-bold mb-2">점검 항목 상세</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<DetailRow label="점검항목" value={selectedItem.inspection_item || "-"} />
|
||||||
|
<DetailRow label="점검주기" value={resolveCategory("inspection_cycle", selectedItem.inspection_cycle)} />
|
||||||
|
<DetailRow label="점검내용" value={selectedItem.inspection_content || "-"} span2 />
|
||||||
|
<DetailRow label="점검방법" value={resolveCategory("inspection_method", selectedItem.inspection_method)} span2 />
|
||||||
|
<DetailRow label="하한치" value={selectedItem.lower_limit || "-"} />
|
||||||
|
<DetailRow label="상한치" value={selectedItem.upper_limit || "-"} />
|
||||||
|
<DetailRow label="단위" value={selectedItem.unit || "-"} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||||
|
<p className="text-sm">점검 항목 정보가 없습니다</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 상세 행 ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
function DetailRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
badge,
|
||||||
|
span2,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
badge?: React.ReactNode;
|
||||||
|
span2?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-col gap-0.5", span2 && "col-span-2")}>
|
||||||
|
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">{label}</span>
|
||||||
|
{badge ? <div className="w-fit">{badge}</div> : <span className="text-xs">{value}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
ResizableHandle,
|
||||||
|
ResizablePanel,
|
||||||
|
ResizablePanelGroup,
|
||||||
|
} from "@/components/ui/resizable";
|
||||||
|
import { RefreshCw, Loader2, Inbox, ClipboardCheck, Download } from "lucide-react";
|
||||||
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||||
|
|
||||||
|
// ─── 타입 ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface InspectionMng {
|
||||||
|
id: string;
|
||||||
|
inspection_number: string;
|
||||||
|
item_code: string;
|
||||||
|
item_name: string;
|
||||||
|
inspection_type: string;
|
||||||
|
total_qty: number;
|
||||||
|
good_qty: number;
|
||||||
|
bad_qty: number;
|
||||||
|
overall_judgment: string;
|
||||||
|
inspector: string;
|
||||||
|
inspection_date: string;
|
||||||
|
is_completed: string;
|
||||||
|
defect_description: string;
|
||||||
|
memo: string;
|
||||||
|
supplier_name: string;
|
||||||
|
supplier_code: string;
|
||||||
|
created_date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InspectionDetail {
|
||||||
|
id: string;
|
||||||
|
master_id: string;
|
||||||
|
inspection_item_name: string;
|
||||||
|
inspection_standard: string;
|
||||||
|
pass_criteria: string;
|
||||||
|
measured_value: string;
|
||||||
|
judgment: string;
|
||||||
|
is_required: string;
|
||||||
|
memo: string;
|
||||||
|
item_code: string;
|
||||||
|
item_name: string;
|
||||||
|
inspection_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MASTER_TABLE = "inspection_result_mng";
|
||||||
|
const DETAIL_TABLE = "inspection_result";
|
||||||
|
|
||||||
|
// ─── 메인 컴포넌트 ───────────────────────────────────────
|
||||||
|
|
||||||
|
export default function InspectionResultPage() {
|
||||||
|
const [masterData, setMasterData] = useState<InspectionMng[]>([]);
|
||||||
|
const [detailData, setDetailData] = useState<InspectionDetail[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [filterValues, setFilterValues] = useState<FilterValue[]>([]);
|
||||||
|
|
||||||
|
// ─── 마스터 데이터 조회 ─────────────────────────────────
|
||||||
|
|
||||||
|
const fetchMaster = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const search: Record<string, any> = {};
|
||||||
|
filterValues.forEach((f) => {
|
||||||
|
if (f.value) search[f.column] = f.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
|
||||||
|
page: 1,
|
||||||
|
size: 1000,
|
||||||
|
autoFilter: true,
|
||||||
|
search,
|
||||||
|
});
|
||||||
|
const rows: InspectionMng[] = res.data?.data?.data ?? res.data?.data?.rows ?? [];
|
||||||
|
setMasterData(rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("검사결과 조회 실패:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [filterValues]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMaster();
|
||||||
|
}, [fetchMaster]);
|
||||||
|
|
||||||
|
// ─── 디테일 데이터 조회 ─────────────────────────────────
|
||||||
|
|
||||||
|
const fetchDetail = useCallback(async (masterId: string) => {
|
||||||
|
setDetailLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
|
||||||
|
page: 1,
|
||||||
|
size: 500,
|
||||||
|
autoFilter: true,
|
||||||
|
search: { master_id: masterId },
|
||||||
|
});
|
||||||
|
const rows: InspectionDetail[] = res.data?.data?.data ?? res.data?.data?.rows ?? [];
|
||||||
|
setDetailData(rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("검사상세 조회 실패:", err);
|
||||||
|
setDetailData([]);
|
||||||
|
} finally {
|
||||||
|
setDetailLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedId) fetchDetail(selectedId);
|
||||||
|
else setDetailData([]);
|
||||||
|
}, [selectedId, fetchDetail]);
|
||||||
|
|
||||||
|
// ─── 선택된 마스터 ──────────────────────────────────────
|
||||||
|
|
||||||
|
const selectedMaster = useMemo(
|
||||||
|
() => masterData.find((m) => m.id === selectedId),
|
||||||
|
[masterData, selectedId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── 엑셀 다운로드 ─────────────────────────────────────
|
||||||
|
|
||||||
|
const handleExcel = async () => {
|
||||||
|
await exportToExcel(masterData, "검사결과관리.xlsx", "검사결과");
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 판정 배지 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
const judgmentBadge = (v: string) => {
|
||||||
|
if (!v) return null;
|
||||||
|
const lower = v.toLowerCase();
|
||||||
|
if (["합격", "pass", "적합"].includes(lower))
|
||||||
|
return <Badge className="bg-emerald-500/10 text-emerald-600 border-emerald-200">{v}</Badge>;
|
||||||
|
if (["불합격", "fail", "부적합"].includes(lower))
|
||||||
|
return <Badge className="bg-red-500/10 text-red-600 border-red-200">{v}</Badge>;
|
||||||
|
return <Badge variant="outline">{v}</Badge>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 날짜 포맷 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
const fmtDate = (d: string) => {
|
||||||
|
if (!d) return "-";
|
||||||
|
try {
|
||||||
|
return new Date(d).toLocaleDateString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit" });
|
||||||
|
} catch {
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 렌더링 ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
{/* 검색 필터 */}
|
||||||
|
<div className="shrink-0">
|
||||||
|
<DynamicSearchFilter
|
||||||
|
tableName={MASTER_TABLE}
|
||||||
|
filterId="inspection-result-mng"
|
||||||
|
onFilterChange={(filters) => {
|
||||||
|
setFilterValues(filters);
|
||||||
|
}}
|
||||||
|
dataCount={masterData.length}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between border-b px-4 py-2.5 shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ClipboardCheck className="h-5 w-5 text-primary" />
|
||||||
|
<h2 className="text-base font-bold">검사관리</h2>
|
||||||
|
<Badge variant="secondary">{masterData.length}건</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleExcel}>
|
||||||
|
<Download className="h-4 w-4 mr-1" />
|
||||||
|
엑셀 다운로드
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={fetchMaster} disabled={loading}>
|
||||||
|
<RefreshCw className={cn("h-4 w-4 mr-1", loading && "animate-spin")} />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 분할 패널 */}
|
||||||
|
<ResizablePanelGroup direction="horizontal" className="flex-1 min-h-0">
|
||||||
|
{/* 좌측: 마스터 테이블 */}
|
||||||
|
<ResizablePanel defaultSize={55} minSize={30}>
|
||||||
|
<div className="h-full overflow-auto">
|
||||||
|
{loading && masterData.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin mb-3" />
|
||||||
|
<span className="text-sm">데이터를 불러오는 중...</span>
|
||||||
|
</div>
|
||||||
|
) : masterData.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||||
|
<Inbox className="h-10 w-10 mb-3" />
|
||||||
|
<span className="text-sm">검사 데이터가 없습니다</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table noWrapper className="min-w-max">
|
||||||
|
<TableHeader className="sticky top-0 z-10 bg-muted shadow-[0_1px_0_0_hsl(var(--border))]">
|
||||||
|
<TableRow className="hover:bg-muted">
|
||||||
|
<TableHead className="w-10 text-center">#</TableHead>
|
||||||
|
<TableHead className="min-w-[120px]">검사번호</TableHead>
|
||||||
|
<TableHead className="min-w-[80px]">검사유형</TableHead>
|
||||||
|
<TableHead className="min-w-[120px]">품목코드</TableHead>
|
||||||
|
<TableHead className="min-w-[140px]">품목명</TableHead>
|
||||||
|
<TableHead className="min-w-[80px] text-right">검사수량</TableHead>
|
||||||
|
<TableHead className="min-w-[80px] text-right">양품</TableHead>
|
||||||
|
<TableHead className="min-w-[80px] text-right">불량</TableHead>
|
||||||
|
<TableHead className="min-w-[70px] text-center">판정</TableHead>
|
||||||
|
<TableHead className="min-w-[80px]">검사자</TableHead>
|
||||||
|
<TableHead className="min-w-[100px]">검사일자</TableHead>
|
||||||
|
<TableHead className="min-w-[80px] text-center">완료</TableHead>
|
||||||
|
<TableHead className="min-w-[120px]">거래처</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{masterData.map((row, idx) => (
|
||||||
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer",
|
||||||
|
selectedId === row.id && "bg-primary/5",
|
||||||
|
)}
|
||||||
|
onClick={() => setSelectedId(row.id)}
|
||||||
|
>
|
||||||
|
<TableCell className="text-center text-muted-foreground text-xs">{idx + 1}</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm">{row.inspection_number || "-"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline" className="text-xs">{row.inspection_type || "-"}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm">{row.item_code || "-"}</TableCell>
|
||||||
|
<TableCell className="text-sm">{row.item_name || "-"}</TableCell>
|
||||||
|
<TableCell className="text-right text-sm">{row.total_qty ?? "-"}</TableCell>
|
||||||
|
<TableCell className="text-right text-sm text-emerald-600">{row.good_qty ?? "-"}</TableCell>
|
||||||
|
<TableCell className="text-right text-sm text-red-600">{row.bad_qty ?? "-"}</TableCell>
|
||||||
|
<TableCell className="text-center">{judgmentBadge(row.overall_judgment)}</TableCell>
|
||||||
|
<TableCell className="text-sm">{row.inspector || "-"}</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{fmtDate(row.inspection_date)}</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
{row.is_completed === "Y" ? (
|
||||||
|
<Badge className="bg-emerald-500/10 text-emerald-600 border-emerald-200 text-xs">완료</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="text-xs">진행중</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">{row.supplier_name || "-"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
|
||||||
|
<ResizableHandle withHandle />
|
||||||
|
|
||||||
|
{/* 우측: 디테일 */}
|
||||||
|
<ResizablePanel defaultSize={45} minSize={25}>
|
||||||
|
{!selectedId ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
|
||||||
|
<Inbox className="w-10 h-10 text-muted-foreground/40" />
|
||||||
|
<p className="text-sm">좌측에서 검사를 선택해주세요</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* 상세 헤더 */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/50 shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="text-[13px] font-bold">검사 상세</h3>
|
||||||
|
{selectedMaster && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{selectedMaster.inspection_number}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{selectedMaster && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>{selectedMaster.item_name}</span>
|
||||||
|
<span>·</span>
|
||||||
|
{judgmentBadge(selectedMaster.overall_judgment)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 상세 테이블 */}
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{detailLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-16 text-muted-foreground">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : detailData.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||||
|
<Inbox className="h-8 w-8 mb-2 opacity-40" />
|
||||||
|
<p className="text-sm">검사 항목이 없습니다</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table noWrapper>
|
||||||
|
<TableHeader className="sticky top-0 z-10 bg-muted shadow-[0_1px_0_0_hsl(var(--border))]">
|
||||||
|
<TableRow className="hover:bg-muted">
|
||||||
|
<TableHead className="w-10 text-center">#</TableHead>
|
||||||
|
<TableHead className="min-w-[140px]">검사항목</TableHead>
|
||||||
|
<TableHead className="min-w-[120px]">검사기준</TableHead>
|
||||||
|
<TableHead className="min-w-[100px]">합격기준</TableHead>
|
||||||
|
<TableHead className="min-w-[90px]">측정값</TableHead>
|
||||||
|
<TableHead className="min-w-[70px] text-center">판정</TableHead>
|
||||||
|
<TableHead className="min-w-[60px] text-center">필수</TableHead>
|
||||||
|
<TableHead className="min-w-[120px]">비고</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{detailData.map((d, idx) => (
|
||||||
|
<TableRow key={d.id}>
|
||||||
|
<TableCell className="text-center text-muted-foreground text-xs">{idx + 1}</TableCell>
|
||||||
|
<TableCell className="text-sm font-medium">{d.inspection_item_name || "-"}</TableCell>
|
||||||
|
<TableCell className="text-sm">{d.inspection_standard || "-"}</TableCell>
|
||||||
|
<TableCell className="text-sm">{d.pass_criteria || "-"}</TableCell>
|
||||||
|
<TableCell className="text-sm font-mono">{d.measured_value || "-"}</TableCell>
|
||||||
|
<TableCell className="text-center">{judgmentBadge(d.judgment)}</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
{d.is_required === "Y" ? (
|
||||||
|
<Badge className="bg-amber-500/10 text-amber-600 border-amber-200 text-xs">필수</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{d.memo || "-"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
ResizableHandle,
|
||||||
|
ResizablePanel,
|
||||||
|
ResizablePanelGroup,
|
||||||
|
} from "@/components/ui/resizable";
|
||||||
|
import { RefreshCw, Loader2, Inbox, Wrench, Download } from "lucide-react";
|
||||||
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||||
|
|
||||||
|
// ─── 타입 ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface InspectionRecord {
|
||||||
|
id: string;
|
||||||
|
equipment_code: string;
|
||||||
|
inspection_item_objid: string;
|
||||||
|
inspection_date: string;
|
||||||
|
status: string;
|
||||||
|
inspector: string;
|
||||||
|
remark: string;
|
||||||
|
created_date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InspectionItem {
|
||||||
|
id: string;
|
||||||
|
equipment_code: string;
|
||||||
|
inspection_item: string;
|
||||||
|
inspection_cycle: string;
|
||||||
|
inspection_content: string;
|
||||||
|
inspection_method: string;
|
||||||
|
lower_limit: string;
|
||||||
|
upper_limit: string;
|
||||||
|
unit: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EquipmentInfo {
|
||||||
|
id: string;
|
||||||
|
equipment_code: string;
|
||||||
|
equipment_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RECORD_TABLE = "equipment_inspection_record";
|
||||||
|
|
||||||
|
// ─── 메인 컴포넌트 ───────────────────────────────────────
|
||||||
|
|
||||||
|
export default function EquipmentInspectionRecordPage() {
|
||||||
|
const [records, setRecords] = useState<InspectionRecord[]>([]);
|
||||||
|
const [inspectionItems, setInspectionItems] = useState<Map<string, InspectionItem>>(new Map());
|
||||||
|
const [equipments, setEquipments] = useState<Map<string, EquipmentInfo>>(new Map());
|
||||||
|
const [categoryMap, setCategoryMap] = useState<Record<string, Record<string, string>>>({});
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [filterValues, setFilterValues] = useState<FilterValue[]>([]);
|
||||||
|
|
||||||
|
// ─── 데이터 조회 ────────────────────────────────────────
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const search: Record<string, any> = {};
|
||||||
|
filterValues.forEach((f) => {
|
||||||
|
if (f.value) search[f.column] = f.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const [recordRes, itemRes, equipRes] = await Promise.all([
|
||||||
|
apiClient.post(`/table-management/tables/${RECORD_TABLE}/data`, {
|
||||||
|
page: 1,
|
||||||
|
size: 1000,
|
||||||
|
autoFilter: true,
|
||||||
|
search,
|
||||||
|
}),
|
||||||
|
apiClient.post(`/table-management/tables/equipment_inspection_item/data`, {
|
||||||
|
page: 1,
|
||||||
|
size: 1000,
|
||||||
|
autoFilter: true,
|
||||||
|
}).catch(() => ({ data: { data: { data: [] } } })),
|
||||||
|
apiClient.post(`/table-management/tables/equipment_mng/data`, {
|
||||||
|
page: 1,
|
||||||
|
size: 500,
|
||||||
|
autoFilter: true,
|
||||||
|
}).catch(() => ({ data: { data: { data: [] } } })),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const rRows: InspectionRecord[] = recordRes.data?.data?.data ?? recordRes.data?.data?.rows ?? [];
|
||||||
|
setRecords(rRows);
|
||||||
|
|
||||||
|
const iRows: InspectionItem[] = itemRes.data?.data?.data ?? itemRes.data?.data?.rows ?? [];
|
||||||
|
const iMap = new Map<string, InspectionItem>();
|
||||||
|
iRows.forEach((i) => iMap.set(i.id, i));
|
||||||
|
setInspectionItems(iMap);
|
||||||
|
|
||||||
|
const eRows: EquipmentInfo[] = equipRes.data?.data?.data ?? equipRes.data?.data?.rows ?? [];
|
||||||
|
const eMap = new Map<string, EquipmentInfo>();
|
||||||
|
eRows.forEach((e) => {
|
||||||
|
eMap.set(e.equipment_code, e);
|
||||||
|
eMap.set(e.id, e);
|
||||||
|
});
|
||||||
|
setEquipments(eMap);
|
||||||
|
|
||||||
|
// 카테고리 코드→라벨 매핑 (점검주기, 점검방법)
|
||||||
|
try {
|
||||||
|
const catCols = ["inspection_cycle", "inspection_method"];
|
||||||
|
const catResults = await Promise.all(
|
||||||
|
catCols.map((col) =>
|
||||||
|
apiClient.get(`/table-categories/equipment_inspection_item/${col}/values`).catch(() => ({ data: [] })),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const cMap: Record<string, Record<string, string>> = {};
|
||||||
|
catCols.forEach((col, idx) => {
|
||||||
|
const vals: { code: string; label: string }[] = catResults[idx].data?.data ?? catResults[idx].data ?? [];
|
||||||
|
cMap[col] = {};
|
||||||
|
vals.forEach((v: any) => { cMap[col][v.valueCode || v.code] = v.valueLabel || v.label; });
|
||||||
|
});
|
||||||
|
setCategoryMap(cMap);
|
||||||
|
} catch {
|
||||||
|
// 카테고리 조회 실패 무시
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("점검기록 조회 실패:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [filterValues]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
// ─── 선택된 레코드 ─────────────────────────────────────
|
||||||
|
|
||||||
|
const selectedRecord = useMemo(() => records.find((r) => r.id === selectedId), [records, selectedId]);
|
||||||
|
const selectedItem = useMemo(
|
||||||
|
() => (selectedRecord ? inspectionItems.get(selectedRecord.inspection_item_objid) : undefined),
|
||||||
|
[selectedRecord, inspectionItems],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── 설비명 조회 ───────────────────────────────────────
|
||||||
|
|
||||||
|
const getEquipName = (code: string) => equipments.get(code)?.equipment_name || code || "-";
|
||||||
|
|
||||||
|
// ─── 카테고리 코드→라벨 변환 ──────────────────────────
|
||||||
|
|
||||||
|
const resolveCategory = (col: string, code: string) => {
|
||||||
|
if (!code) return "-";
|
||||||
|
return categoryMap[col]?.[code] || code;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 엑셀 다운로드 ─────────────────────────────────────
|
||||||
|
|
||||||
|
const handleExcel = async () => {
|
||||||
|
const data = records.map((r) => ({
|
||||||
|
설비코드: r.equipment_code,
|
||||||
|
설비명: getEquipName(r.equipment_code),
|
||||||
|
점검항목: inspectionItems.get(r.inspection_item_objid)?.inspection_item || "-",
|
||||||
|
점검일자: fmtDate(r.inspection_date),
|
||||||
|
상태: r.status,
|
||||||
|
점검자: r.inspector,
|
||||||
|
비고: r.remark,
|
||||||
|
}));
|
||||||
|
await exportToExcel(data, "설비점검기록.xlsx", "점검기록");
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 날짜/상태 포맷 ────────────────────────────────────
|
||||||
|
|
||||||
|
const fmtDate = (d: string) => {
|
||||||
|
if (!d) return "-";
|
||||||
|
try {
|
||||||
|
return new Date(d).toLocaleDateString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit" });
|
||||||
|
} catch {
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusBadge = (v: string) => {
|
||||||
|
if (!v) return <Badge variant="outline" className="text-xs">-</Badge>;
|
||||||
|
const lower = v.toLowerCase();
|
||||||
|
if (["완료", "pass", "정상", "합격", "completed"].includes(lower))
|
||||||
|
return <Badge className="bg-emerald-500/10 text-emerald-600 border-emerald-200 text-xs">{v}</Badge>;
|
||||||
|
if (["이상", "fail", "불합격", "비정상"].includes(lower))
|
||||||
|
return <Badge className="bg-red-500/10 text-red-600 border-red-200 text-xs">{v}</Badge>;
|
||||||
|
if (["점검중", "진행", "in_progress"].includes(lower))
|
||||||
|
return <Badge className="bg-blue-500/10 text-blue-600 border-blue-200 text-xs">{v}</Badge>;
|
||||||
|
return <Badge variant="outline" className="text-xs">{v}</Badge>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 렌더링 ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
{/* 검색 필터 */}
|
||||||
|
<div className="shrink-0">
|
||||||
|
<DynamicSearchFilter
|
||||||
|
tableName={RECORD_TABLE}
|
||||||
|
filterId="equip-inspection-record"
|
||||||
|
onFilterChange={(filters) => setFilterValues(filters)}
|
||||||
|
dataCount={records.length}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between border-b px-4 py-2.5 shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Wrench className="h-5 w-5 text-primary" />
|
||||||
|
<h2 className="text-base font-bold">점검관리</h2>
|
||||||
|
<Badge variant="secondary">{records.length}건</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleExcel}>
|
||||||
|
<Download className="h-4 w-4 mr-1" />
|
||||||
|
엑셀 다운로드
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={fetchData} disabled={loading}>
|
||||||
|
<RefreshCw className={cn("h-4 w-4 mr-1", loading && "animate-spin")} />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 분할 패널 */}
|
||||||
|
<ResizablePanelGroup direction="horizontal" className="flex-1 min-h-0">
|
||||||
|
{/* 좌측: 점검기록 테이블 */}
|
||||||
|
<ResizablePanel defaultSize={60} minSize={35}>
|
||||||
|
<div className="h-full overflow-auto">
|
||||||
|
{loading && records.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin mb-3" />
|
||||||
|
<span className="text-sm">데이터를 불러오는 중...</span>
|
||||||
|
</div>
|
||||||
|
) : records.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||||
|
<Inbox className="h-10 w-10 mb-3" />
|
||||||
|
<span className="text-sm">점검 기록이 없습니다</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table noWrapper className="min-w-max">
|
||||||
|
<TableHeader className="sticky top-0 z-10 bg-muted shadow-[0_1px_0_0_hsl(var(--border))]">
|
||||||
|
<TableRow className="hover:bg-muted">
|
||||||
|
<TableHead className="w-10 text-center">#</TableHead>
|
||||||
|
<TableHead className="min-w-[100px]">설비코드</TableHead>
|
||||||
|
<TableHead className="min-w-[120px]">설비명</TableHead>
|
||||||
|
<TableHead className="min-w-[140px]">점검항목</TableHead>
|
||||||
|
<TableHead className="min-w-[100px]">점검일자</TableHead>
|
||||||
|
<TableHead className="min-w-[70px] text-center">상태</TableHead>
|
||||||
|
<TableHead className="min-w-[80px]">점검자</TableHead>
|
||||||
|
<TableHead className="min-w-[150px]">비고</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{records.map((row, idx) => {
|
||||||
|
const item = inspectionItems.get(row.inspection_item_objid);
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
className={cn("cursor-pointer", selectedId === row.id && "bg-primary/5")}
|
||||||
|
onClick={() => setSelectedId(row.id)}
|
||||||
|
>
|
||||||
|
<TableCell className="text-center text-muted-foreground text-xs">{idx + 1}</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm">{row.equipment_code || "-"}</TableCell>
|
||||||
|
<TableCell className="text-sm">{getEquipName(row.equipment_code)}</TableCell>
|
||||||
|
<TableCell className="text-sm">{item?.inspection_item || "-"}</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{fmtDate(row.inspection_date)}</TableCell>
|
||||||
|
<TableCell className="text-center">{statusBadge(row.status)}</TableCell>
|
||||||
|
<TableCell className="text-sm">{row.inspector || "-"}</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{row.remark || "-"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
|
||||||
|
<ResizableHandle withHandle />
|
||||||
|
|
||||||
|
{/* 우측: 점검항목 상세 */}
|
||||||
|
<ResizablePanel defaultSize={40} minSize={25}>
|
||||||
|
{!selectedId || !selectedRecord ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
|
||||||
|
<Inbox className="w-10 h-10 text-muted-foreground/40" />
|
||||||
|
<p className="text-sm">좌측에서 점검 기록을 선택해주세요</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col h-full overflow-auto">
|
||||||
|
{/* 점검 기록 요약 */}
|
||||||
|
<div className="px-4 py-3 border-b bg-muted/50 shrink-0">
|
||||||
|
<h3 className="text-[13px] font-bold mb-2">점검 기록 정보</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<DetailRow label="설비" value={getEquipName(selectedRecord.equipment_code)} />
|
||||||
|
<DetailRow label="설비코드" value={selectedRecord.equipment_code} />
|
||||||
|
<DetailRow label="점검일자" value={fmtDate(selectedRecord.inspection_date)} />
|
||||||
|
<DetailRow label="점검자" value={selectedRecord.inspector || "-"} />
|
||||||
|
<DetailRow label="상태" value={selectedRecord.status || "-"} badge={statusBadge(selectedRecord.status)} />
|
||||||
|
<DetailRow label="비고" value={selectedRecord.remark || "-"} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 점검 항목 상세 */}
|
||||||
|
{selectedItem ? (
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<h3 className="text-[13px] font-bold mb-2">점검 항목 상세</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<DetailRow label="점검항목" value={selectedItem.inspection_item || "-"} />
|
||||||
|
<DetailRow label="점검주기" value={resolveCategory("inspection_cycle", selectedItem.inspection_cycle)} />
|
||||||
|
<DetailRow label="점검내용" value={selectedItem.inspection_content || "-"} span2 />
|
||||||
|
<DetailRow label="점검방법" value={resolveCategory("inspection_method", selectedItem.inspection_method)} span2 />
|
||||||
|
<DetailRow label="하한치" value={selectedItem.lower_limit || "-"} />
|
||||||
|
<DetailRow label="상한치" value={selectedItem.upper_limit || "-"} />
|
||||||
|
<DetailRow label="단위" value={selectedItem.unit || "-"} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||||
|
<p className="text-sm">점검 항목 정보가 없습니다</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 상세 행 ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
function DetailRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
badge,
|
||||||
|
span2,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
badge?: React.ReactNode;
|
||||||
|
span2?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-col gap-0.5", span2 && "col-span-2")}>
|
||||||
|
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">{label}</span>
|
||||||
|
{badge ? <div className="w-fit">{badge}</div> : <span className="text-xs">{value}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
ResizableHandle,
|
||||||
|
ResizablePanel,
|
||||||
|
ResizablePanelGroup,
|
||||||
|
} from "@/components/ui/resizable";
|
||||||
|
import { RefreshCw, Loader2, Inbox, ClipboardCheck, Download } from "lucide-react";
|
||||||
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||||
|
|
||||||
|
// ─── 타입 ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface InspectionMng {
|
||||||
|
id: string;
|
||||||
|
inspection_number: string;
|
||||||
|
item_code: string;
|
||||||
|
item_name: string;
|
||||||
|
inspection_type: string;
|
||||||
|
total_qty: number;
|
||||||
|
good_qty: number;
|
||||||
|
bad_qty: number;
|
||||||
|
overall_judgment: string;
|
||||||
|
inspector: string;
|
||||||
|
inspection_date: string;
|
||||||
|
is_completed: string;
|
||||||
|
defect_description: string;
|
||||||
|
memo: string;
|
||||||
|
supplier_name: string;
|
||||||
|
supplier_code: string;
|
||||||
|
created_date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InspectionDetail {
|
||||||
|
id: string;
|
||||||
|
master_id: string;
|
||||||
|
inspection_item_name: string;
|
||||||
|
inspection_standard: string;
|
||||||
|
pass_criteria: string;
|
||||||
|
measured_value: string;
|
||||||
|
judgment: string;
|
||||||
|
is_required: string;
|
||||||
|
memo: string;
|
||||||
|
item_code: string;
|
||||||
|
item_name: string;
|
||||||
|
inspection_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MASTER_TABLE = "inspection_result_mng";
|
||||||
|
const DETAIL_TABLE = "inspection_result";
|
||||||
|
|
||||||
|
// ─── 메인 컴포넌트 ───────────────────────────────────────
|
||||||
|
|
||||||
|
export default function InspectionResultPage() {
|
||||||
|
const [masterData, setMasterData] = useState<InspectionMng[]>([]);
|
||||||
|
const [detailData, setDetailData] = useState<InspectionDetail[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [filterValues, setFilterValues] = useState<FilterValue[]>([]);
|
||||||
|
|
||||||
|
// ─── 마스터 데이터 조회 ─────────────────────────────────
|
||||||
|
|
||||||
|
const fetchMaster = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const search: Record<string, any> = {};
|
||||||
|
filterValues.forEach((f) => {
|
||||||
|
if (f.value) search[f.column] = f.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
|
||||||
|
page: 1,
|
||||||
|
size: 1000,
|
||||||
|
autoFilter: true,
|
||||||
|
search,
|
||||||
|
});
|
||||||
|
const rows: InspectionMng[] = res.data?.data?.data ?? res.data?.data?.rows ?? [];
|
||||||
|
setMasterData(rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("검사결과 조회 실패:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [filterValues]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMaster();
|
||||||
|
}, [fetchMaster]);
|
||||||
|
|
||||||
|
// ─── 디테일 데이터 조회 ─────────────────────────────────
|
||||||
|
|
||||||
|
const fetchDetail = useCallback(async (masterId: string) => {
|
||||||
|
setDetailLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
|
||||||
|
page: 1,
|
||||||
|
size: 500,
|
||||||
|
autoFilter: true,
|
||||||
|
search: { master_id: masterId },
|
||||||
|
});
|
||||||
|
const rows: InspectionDetail[] = res.data?.data?.data ?? res.data?.data?.rows ?? [];
|
||||||
|
setDetailData(rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("검사상세 조회 실패:", err);
|
||||||
|
setDetailData([]);
|
||||||
|
} finally {
|
||||||
|
setDetailLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedId) fetchDetail(selectedId);
|
||||||
|
else setDetailData([]);
|
||||||
|
}, [selectedId, fetchDetail]);
|
||||||
|
|
||||||
|
// ─── 선택된 마스터 ──────────────────────────────────────
|
||||||
|
|
||||||
|
const selectedMaster = useMemo(
|
||||||
|
() => masterData.find((m) => m.id === selectedId),
|
||||||
|
[masterData, selectedId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── 엑셀 다운로드 ─────────────────────────────────────
|
||||||
|
|
||||||
|
const handleExcel = async () => {
|
||||||
|
await exportToExcel(masterData, "검사결과관리.xlsx", "검사결과");
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 판정 배지 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
const judgmentBadge = (v: string) => {
|
||||||
|
if (!v) return null;
|
||||||
|
const lower = v.toLowerCase();
|
||||||
|
if (["합격", "pass", "적합"].includes(lower))
|
||||||
|
return <Badge className="bg-emerald-500/10 text-emerald-600 border-emerald-200">{v}</Badge>;
|
||||||
|
if (["불합격", "fail", "부적합"].includes(lower))
|
||||||
|
return <Badge className="bg-red-500/10 text-red-600 border-red-200">{v}</Badge>;
|
||||||
|
return <Badge variant="outline">{v}</Badge>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 날짜 포맷 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
const fmtDate = (d: string) => {
|
||||||
|
if (!d) return "-";
|
||||||
|
try {
|
||||||
|
return new Date(d).toLocaleDateString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit" });
|
||||||
|
} catch {
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 렌더링 ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
{/* 검색 필터 */}
|
||||||
|
<div className="shrink-0">
|
||||||
|
<DynamicSearchFilter
|
||||||
|
tableName={MASTER_TABLE}
|
||||||
|
filterId="inspection-result-mng"
|
||||||
|
onFilterChange={(filters) => {
|
||||||
|
setFilterValues(filters);
|
||||||
|
}}
|
||||||
|
dataCount={masterData.length}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between border-b px-4 py-2.5 shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ClipboardCheck className="h-5 w-5 text-primary" />
|
||||||
|
<h2 className="text-base font-bold">검사관리</h2>
|
||||||
|
<Badge variant="secondary">{masterData.length}건</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleExcel}>
|
||||||
|
<Download className="h-4 w-4 mr-1" />
|
||||||
|
엑셀 다운로드
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={fetchMaster} disabled={loading}>
|
||||||
|
<RefreshCw className={cn("h-4 w-4 mr-1", loading && "animate-spin")} />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 분할 패널 */}
|
||||||
|
<ResizablePanelGroup direction="horizontal" className="flex-1 min-h-0">
|
||||||
|
{/* 좌측: 마스터 테이블 */}
|
||||||
|
<ResizablePanel defaultSize={55} minSize={30}>
|
||||||
|
<div className="h-full overflow-auto">
|
||||||
|
{loading && masterData.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin mb-3" />
|
||||||
|
<span className="text-sm">데이터를 불러오는 중...</span>
|
||||||
|
</div>
|
||||||
|
) : masterData.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||||
|
<Inbox className="h-10 w-10 mb-3" />
|
||||||
|
<span className="text-sm">검사 데이터가 없습니다</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table noWrapper className="min-w-max">
|
||||||
|
<TableHeader className="sticky top-0 z-10 bg-muted shadow-[0_1px_0_0_hsl(var(--border))]">
|
||||||
|
<TableRow className="hover:bg-muted">
|
||||||
|
<TableHead className="w-10 text-center">#</TableHead>
|
||||||
|
<TableHead className="min-w-[120px]">검사번호</TableHead>
|
||||||
|
<TableHead className="min-w-[80px]">검사유형</TableHead>
|
||||||
|
<TableHead className="min-w-[120px]">품목코드</TableHead>
|
||||||
|
<TableHead className="min-w-[140px]">품목명</TableHead>
|
||||||
|
<TableHead className="min-w-[80px] text-right">검사수량</TableHead>
|
||||||
|
<TableHead className="min-w-[80px] text-right">양품</TableHead>
|
||||||
|
<TableHead className="min-w-[80px] text-right">불량</TableHead>
|
||||||
|
<TableHead className="min-w-[70px] text-center">판정</TableHead>
|
||||||
|
<TableHead className="min-w-[80px]">검사자</TableHead>
|
||||||
|
<TableHead className="min-w-[100px]">검사일자</TableHead>
|
||||||
|
<TableHead className="min-w-[80px] text-center">완료</TableHead>
|
||||||
|
<TableHead className="min-w-[120px]">거래처</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{masterData.map((row, idx) => (
|
||||||
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer",
|
||||||
|
selectedId === row.id && "bg-primary/5",
|
||||||
|
)}
|
||||||
|
onClick={() => setSelectedId(row.id)}
|
||||||
|
>
|
||||||
|
<TableCell className="text-center text-muted-foreground text-xs">{idx + 1}</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm">{row.inspection_number || "-"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline" className="text-xs">{row.inspection_type || "-"}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm">{row.item_code || "-"}</TableCell>
|
||||||
|
<TableCell className="text-sm">{row.item_name || "-"}</TableCell>
|
||||||
|
<TableCell className="text-right text-sm">{row.total_qty ?? "-"}</TableCell>
|
||||||
|
<TableCell className="text-right text-sm text-emerald-600">{row.good_qty ?? "-"}</TableCell>
|
||||||
|
<TableCell className="text-right text-sm text-red-600">{row.bad_qty ?? "-"}</TableCell>
|
||||||
|
<TableCell className="text-center">{judgmentBadge(row.overall_judgment)}</TableCell>
|
||||||
|
<TableCell className="text-sm">{row.inspector || "-"}</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{fmtDate(row.inspection_date)}</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
{row.is_completed === "Y" ? (
|
||||||
|
<Badge className="bg-emerald-500/10 text-emerald-600 border-emerald-200 text-xs">완료</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="text-xs">진행중</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">{row.supplier_name || "-"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
|
||||||
|
<ResizableHandle withHandle />
|
||||||
|
|
||||||
|
{/* 우측: 디테일 */}
|
||||||
|
<ResizablePanel defaultSize={45} minSize={25}>
|
||||||
|
{!selectedId ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
|
||||||
|
<Inbox className="w-10 h-10 text-muted-foreground/40" />
|
||||||
|
<p className="text-sm">좌측에서 검사를 선택해주세요</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* 상세 헤더 */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/50 shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="text-[13px] font-bold">검사 상세</h3>
|
||||||
|
{selectedMaster && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{selectedMaster.inspection_number}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{selectedMaster && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>{selectedMaster.item_name}</span>
|
||||||
|
<span>·</span>
|
||||||
|
{judgmentBadge(selectedMaster.overall_judgment)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 상세 테이블 */}
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{detailLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-16 text-muted-foreground">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : detailData.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||||
|
<Inbox className="h-8 w-8 mb-2 opacity-40" />
|
||||||
|
<p className="text-sm">검사 항목이 없습니다</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table noWrapper>
|
||||||
|
<TableHeader className="sticky top-0 z-10 bg-muted shadow-[0_1px_0_0_hsl(var(--border))]">
|
||||||
|
<TableRow className="hover:bg-muted">
|
||||||
|
<TableHead className="w-10 text-center">#</TableHead>
|
||||||
|
<TableHead className="min-w-[140px]">검사항목</TableHead>
|
||||||
|
<TableHead className="min-w-[120px]">검사기준</TableHead>
|
||||||
|
<TableHead className="min-w-[100px]">합격기준</TableHead>
|
||||||
|
<TableHead className="min-w-[90px]">측정값</TableHead>
|
||||||
|
<TableHead className="min-w-[70px] text-center">판정</TableHead>
|
||||||
|
<TableHead className="min-w-[60px] text-center">필수</TableHead>
|
||||||
|
<TableHead className="min-w-[120px]">비고</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{detailData.map((d, idx) => (
|
||||||
|
<TableRow key={d.id}>
|
||||||
|
<TableCell className="text-center text-muted-foreground text-xs">{idx + 1}</TableCell>
|
||||||
|
<TableCell className="text-sm font-medium">{d.inspection_item_name || "-"}</TableCell>
|
||||||
|
<TableCell className="text-sm">{d.inspection_standard || "-"}</TableCell>
|
||||||
|
<TableCell className="text-sm">{d.pass_criteria || "-"}</TableCell>
|
||||||
|
<TableCell className="text-sm font-mono">{d.measured_value || "-"}</TableCell>
|
||||||
|
<TableCell className="text-center">{judgmentBadge(d.judgment)}</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
{d.is_required === "Y" ? (
|
||||||
|
<Badge className="bg-amber-500/10 text-amber-600 border-amber-200 text-xs">필수</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{d.memo || "-"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"use client";
|
||||||
|
import { EquipmentInspection } from "@/components/pop/hardcoded/equipment/EquipmentInspection";
|
||||||
|
|
||||||
|
export default function EquipmentInspectionPage() {
|
||||||
|
return <EquipmentInspection />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"use client";
|
||||||
|
import { EquipmentList } from "@/components/pop/hardcoded/equipment/EquipmentList";
|
||||||
|
|
||||||
|
export default function EquipmentManagementPage() {
|
||||||
|
return <EquipmentList />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"use client";
|
||||||
|
import { EquipmentHome } from "@/components/pop/hardcoded/equipment/EquipmentHome";
|
||||||
|
|
||||||
|
export default function EquipmentPage() {
|
||||||
|
return <EquipmentHome />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"use client";
|
||||||
|
import { InventoryMove } from "@/components/pop/hardcoded/inventory/InventoryMove";
|
||||||
|
|
||||||
|
export default function InventoryMovePage() {
|
||||||
|
return <InventoryMove />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"use client";
|
||||||
|
import { InventoryTransfer } from "@/components/pop/hardcoded/inventory/InventoryTransfer";
|
||||||
|
|
||||||
|
export default function TransferPage() {
|
||||||
|
return <InventoryTransfer />;
|
||||||
|
}
|
||||||
@@ -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/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/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/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/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/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 }),
|
"/COMPANY_7/logistics/outbound": dynamic(() => import("@/app/(main)/COMPANY_7/logistics/outbound/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
@@ -144,6 +145,7 @@ 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/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/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/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/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/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/equipment": dynamic(() => import("@/app/(main)/COMPANY_16/monitoring/equipment/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
@@ -157,6 +159,7 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
|||||||
"/COMPANY_7/purchase/purchase-item": dynamic(() => import("@/app/(main)/COMPANY_7/purchase/purchase-item/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/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": 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/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_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 }),
|
"/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/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/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": 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/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/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 }),
|
"/COMPANY_16/design/project": dynamic(() => import("@/app/(main)/COMPANY_16/design/project/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
@@ -198,6 +202,7 @@ 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/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/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/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/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/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/equipment": dynamic(() => import("@/app/(main)/COMPANY_8/monitoring/equipment/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/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/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": 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/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/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 }),
|
"/COMPANY_8/design/project": dynamic(() => import("@/app/(main)/COMPANY_8/design/project/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
@@ -242,6 +248,7 @@ 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/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/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/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/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/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/equipment": dynamic(() => import("@/app/(main)/COMPANY_10/monitoring/equipment/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/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/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": 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/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/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 }),
|
"/COMPANY_10/design/project": dynamic(() => import("@/app/(main)/COMPANY_10/design/project/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
@@ -286,6 +294,7 @@ 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/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/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/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/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/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/equipment": dynamic(() => import("@/app/(main)/COMPANY_29/monitoring/equipment/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/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/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": 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/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/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 }),
|
"/COMPANY_29/design/project": dynamic(() => import("@/app/(main)/COMPANY_29/design/project/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
@@ -330,6 +340,7 @@ 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/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/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/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/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/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/equipment": dynamic(() => import("@/app/(main)/COMPANY_9/monitoring/equipment/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/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/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": 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/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/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 }),
|
"/COMPANY_9/design/project": dynamic(() => import("@/app/(main)/COMPANY_9/design/project/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
@@ -375,6 +387,7 @@ 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/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/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/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/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/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/equipment": dynamic(() => import("@/app/(main)/COMPANY_30/monitoring/equipment/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/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/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": 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/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/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 }),
|
"/COMPANY_30/design/project": dynamic(() => import("@/app/(main)/COMPANY_30/design/project/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ const MENU_ITEMS: MenuIconItem[] = [
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
href: "/pop/screens/equipment",
|
href: "/pop/equipment",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "inventory",
|
id: "inventory",
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Types */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
interface EquipmentItem {
|
||||||
|
id: string;
|
||||||
|
equipment_code: string;
|
||||||
|
equipment_name: string;
|
||||||
|
equipment_type?: string;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KpiData {
|
||||||
|
total: number;
|
||||||
|
active: number;
|
||||||
|
idle: number;
|
||||||
|
inspect: number;
|
||||||
|
rate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Menu */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const MENU_ITEMS = [
|
||||||
|
{
|
||||||
|
id: "management",
|
||||||
|
title: "설비관리",
|
||||||
|
gradient: "linear-gradient(135deg,#8b5cf6,#6d28d9)",
|
||||||
|
shadowColor: "rgba(139,92,246,.3)",
|
||||||
|
icon: (
|
||||||
|
<svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M11.42 15.17l-5.65-5.65a8 8 0 1111.31 0l-5.65 5.65z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
href: "/pop/equipment/management",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "inspection",
|
||||||
|
title: "설비점검",
|
||||||
|
gradient: "linear-gradient(135deg,#f59e0b,#d97706)",
|
||||||
|
shadowColor: "rgba(245,158,11,.3)",
|
||||||
|
icon: (
|
||||||
|
<svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75a4.5 4.5 0 01-4.884 4.484c-1.076-.091-2.264.071-2.95.904l-7.152 8.684a2.548 2.548 0 11-3.586-3.586l8.684-7.152c.833-.686.995-1.874.904-2.95a4.5 4.5 0 016.336-4.486l-3.276 3.276a3.004 3.004 0 002.25 2.25l3.276-3.276c.256.565.398 1.192.398 1.852z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
href: "/pop/equipment/inspection",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Component */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export function EquipmentHome() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [kpi, setKpi] = useState<KpiData>({
|
||||||
|
total: 0,
|
||||||
|
active: 0,
|
||||||
|
idle: 0,
|
||||||
|
inspect: 0,
|
||||||
|
rate: "0%",
|
||||||
|
});
|
||||||
|
const [recentItems, setRecentItems] = useState<EquipmentItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get("/data/equipment_mng", {
|
||||||
|
params: { pageSize: 500 },
|
||||||
|
});
|
||||||
|
const data = res.data?.data?.data ?? res.data?.data ?? [];
|
||||||
|
const items = Array.isArray(data) ? data : [];
|
||||||
|
const total = items.length;
|
||||||
|
const active = items.filter((i: EquipmentItem) => !i.status || i.status === "가동" || i.status === "정상").length;
|
||||||
|
const idle = items.filter((i: EquipmentItem) => i.status === "대기").length;
|
||||||
|
const inspect = items.filter((i: EquipmentItem) => i.status === "점검").length;
|
||||||
|
const rate = total > 0 ? `${Math.round((active / total) * 100)}%` : "0%";
|
||||||
|
setKpi({ total, active, idle, inspect, rate });
|
||||||
|
setRecentItems(items.slice(0, 5));
|
||||||
|
} catch { /* */ }
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-white px-5 pt-5 pb-4">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<button onClick={() => router.back()} className="w-9 h-9 rounded-xl bg-gray-100 flex items-center justify-center hover:bg-gray-200 transition-colors">
|
||||||
|
<svg className="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-gray-900">설비</h1>
|
||||||
|
<p className="text-sm text-gray-500">설비 현황 및 점검 관리</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KPI */}
|
||||||
|
<div className="bg-white rounded-2xl border border-gray-200 p-4">
|
||||||
|
<div className="grid grid-cols-5 text-center divide-x divide-gray-100">
|
||||||
|
{[
|
||||||
|
{ value: loading ? "-" : kpi.total, label: "전체", color: "text-gray-900" },
|
||||||
|
{ value: loading ? "-" : kpi.active, label: "가동", color: "text-green-600" },
|
||||||
|
{ value: loading ? "-" : kpi.idle, label: "대기", color: "text-blue-600" },
|
||||||
|
{ value: loading ? "-" : kpi.inspect, label: "점검", color: "text-red-600" },
|
||||||
|
{ value: loading ? "-" : kpi.rate, label: "가동률", color: "text-purple-600" },
|
||||||
|
].map((item) => (
|
||||||
|
<div key={item.label} className="px-2">
|
||||||
|
<p className={`text-2xl font-extrabold ${item.color}`}>{item.value}</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-0.5">{item.label}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Menu Icons */}
|
||||||
|
<div className="px-5 pt-5">
|
||||||
|
<h2 className="flex items-center gap-2 text-sm font-bold text-gray-700 mb-3">
|
||||||
|
<span className="w-1 h-4 bg-purple-500 rounded" />
|
||||||
|
설비 관리
|
||||||
|
</h2>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{MENU_ITEMS.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => router.push(item.href)}
|
||||||
|
className="flex flex-col items-center gap-2 active:scale-95 transition-transform"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-14 h-14 rounded-2xl flex items-center justify-center"
|
||||||
|
style={{
|
||||||
|
background: item.gradient,
|
||||||
|
boxShadow: `0 4px 12px ${item.shadowColor}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-semibold text-gray-700">{item.title}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Equipment */}
|
||||||
|
<div className="px-5 pt-6 pb-24">
|
||||||
|
<div className="bg-white rounded-2xl border border-gray-200 overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100">
|
||||||
|
<h3 className="font-bold text-gray-900">최근 설비</h3>
|
||||||
|
<span className="text-xs text-gray-400">최근 5건</span>
|
||||||
|
</div>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex justify-center py-12">
|
||||||
|
<div className="w-6 h-6 border-3 border-purple-500 border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : recentItems.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-gray-400 text-sm">등록된 설비가 없습니다</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-gray-50">
|
||||||
|
{recentItems.map((item) => (
|
||||||
|
<div key={item.id} className="flex items-center justify-between px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-gray-900">{item.equipment_name || "-"}</p>
|
||||||
|
<p className="text-xs text-gray-400">{item.equipment_code} {item.equipment_type ? `· ${item.equipment_type}` : ""}</p>
|
||||||
|
</div>
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-full font-semibold ${
|
||||||
|
item.status === "정지" || item.status === "비가동"
|
||||||
|
? "bg-red-50 text-red-600"
|
||||||
|
: item.status === "점검"
|
||||||
|
? "bg-amber-50 text-amber-600"
|
||||||
|
: "bg-green-50 text-green-600"
|
||||||
|
}`}>
|
||||||
|
{item.status || "정상"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { PopShell } from "../PopShell";
|
||||||
|
|
||||||
|
type TabType = "all" | "running" | "idle" | "inspect" | "stopped";
|
||||||
|
|
||||||
|
export function EquipmentInspection() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [activeTab, setActiveTab] = useState<TabType>("all");
|
||||||
|
const [searchKeyword, setSearchKeyword] = useState("");
|
||||||
|
|
||||||
|
// 시안 기준 KPI (점검 테이블 없으므로 0)
|
||||||
|
const kpi = { total: 0, running: 0, idle: 0, inspect: 0, rate: "0%" };
|
||||||
|
|
||||||
|
const tabs: { key: TabType; label: string; count: number }[] = [
|
||||||
|
{ key: "all", label: "전체", count: kpi.total },
|
||||||
|
{ key: "running", label: "가동", count: kpi.running },
|
||||||
|
{ key: "idle", label: "대기", count: kpi.idle },
|
||||||
|
{ key: "inspect", label: "점검", count: kpi.inspect },
|
||||||
|
{ key: "stopped", label: "비가동", count: 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PopShell title="점검관리" showBanner={false}>
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-white border-b border-gray-200 px-5 pt-5 pb-4">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<button onClick={() => router.back()} className="w-9 h-9 rounded-xl bg-gray-100 flex items-center justify-center">
|
||||||
|
<svg className="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-gray-900">🔧 점검관리</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="설비명 / 설비코드 검색"
|
||||||
|
value={searchKeyword}
|
||||||
|
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||||
|
className="flex-1 px-4 py-3 rounded-xl border border-gray-200 text-sm focus:outline-none focus:border-amber-400"
|
||||||
|
/>
|
||||||
|
<select className="px-3 py-3 rounded-xl border border-gray-200 text-sm bg-white">
|
||||||
|
<option>전체</option>
|
||||||
|
<option>가동</option>
|
||||||
|
<option>대기</option>
|
||||||
|
<option>점검</option>
|
||||||
|
</select>
|
||||||
|
<button className="px-5 py-3 rounded-xl bg-amber-500 text-white text-sm font-bold active:bg-amber-600">
|
||||||
|
🔍 조회
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KPI */}
|
||||||
|
<div className="px-5 py-3">
|
||||||
|
<div className="grid grid-cols-5 gap-2">
|
||||||
|
{[
|
||||||
|
{ label: "전체", value: kpi.total, color: "border-t-amber-500", icon: "🎬" },
|
||||||
|
{ label: "가동", value: kpi.running, color: "border-t-green-500", icon: "🟢" },
|
||||||
|
{ label: "대기", value: kpi.idle, color: "border-t-blue-500", icon: "🔵" },
|
||||||
|
{ label: "점검", value: kpi.inspect, color: "border-t-red-500", icon: "🔴" },
|
||||||
|
{ label: "가동률", value: kpi.rate, color: "border-t-purple-500", icon: "📊" },
|
||||||
|
].map((item) => (
|
||||||
|
<div key={item.label} className={`bg-white rounded-xl border border-gray-200 ${item.color} border-t-[3px] p-3 text-center`}>
|
||||||
|
<p className="text-lg mb-0.5">{item.icon}</p>
|
||||||
|
<p className="text-xl font-bold text-gray-900">{item.value}</p>
|
||||||
|
<p className="text-[10px] text-gray-500">{item.label}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="px-5">
|
||||||
|
<div className="flex bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
onClick={() => setActiveTab(tab.key)}
|
||||||
|
className={`flex-1 py-3 text-xs font-medium text-center relative transition-all ${
|
||||||
|
activeTab === tab.key
|
||||||
|
? "text-amber-600 font-bold bg-amber-50"
|
||||||
|
: "text-gray-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
<span className={`ml-1 text-[9px] px-1.5 py-0.5 rounded-full font-bold ${
|
||||||
|
activeTab === tab.key ? "bg-amber-500 text-white" : "bg-gray-200 text-gray-500"
|
||||||
|
}`}>
|
||||||
|
{tab.count}
|
||||||
|
</span>
|
||||||
|
{activeTab === tab.key && (
|
||||||
|
<span className="absolute bottom-0 left-[20%] right-[20%] h-[3px] bg-amber-500 rounded-t" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card List — 데이터 없음 */}
|
||||||
|
<div className="px-5 py-6">
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-gray-400">
|
||||||
|
<span className="text-5xl mb-4">🔧</span>
|
||||||
|
<p className="text-base font-semibold mb-1">등록된 설비 점검 정보가 없습니다</p>
|
||||||
|
<p className="text-sm">PC에서 설비 점검 데이터를 등록하면 여기에 표시됩니다</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { PopShell } from "../PopShell";
|
||||||
|
|
||||||
|
interface EquipmentItem {
|
||||||
|
id: string;
|
||||||
|
equipment_code: string;
|
||||||
|
equipment_name: string;
|
||||||
|
equipment_type?: string;
|
||||||
|
location?: string;
|
||||||
|
status?: string;
|
||||||
|
last_inspection_date?: string;
|
||||||
|
next_inspection_date?: string;
|
||||||
|
memo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EquipmentList() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [items, setItems] = useState<EquipmentItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchKeyword, setSearchKeyword] = useState("");
|
||||||
|
|
||||||
|
const fetchEquipments = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get("/data/equipment_mng", {
|
||||||
|
params: { pageSize: 500 },
|
||||||
|
});
|
||||||
|
const data = res.data?.data?.data ?? res.data?.data ?? [];
|
||||||
|
setItems(Array.isArray(data) ? data : []);
|
||||||
|
} catch {
|
||||||
|
setItems([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchEquipments();
|
||||||
|
}, [fetchEquipments]);
|
||||||
|
|
||||||
|
const filtered = items.filter((item) => {
|
||||||
|
if (!searchKeyword) return true;
|
||||||
|
const kw = searchKeyword.toLowerCase();
|
||||||
|
return (
|
||||||
|
(item.equipment_code || "").toLowerCase().includes(kw) ||
|
||||||
|
(item.equipment_name || "").toLowerCase().includes(kw) ||
|
||||||
|
(item.equipment_type || "").toLowerCase().includes(kw)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// KPI
|
||||||
|
const totalCount = items.length;
|
||||||
|
const activeCount = items.filter((i) => i.status === "가동" || i.status === "정상" || !i.status).length;
|
||||||
|
const stopCount = items.filter((i) => i.status === "정지" || i.status === "비가동").length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PopShell title="설비관리" showBanner={false}>
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-white border-b border-gray-200 px-5 pt-5 pb-4">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<button
|
||||||
|
onClick={() => router.back()}
|
||||||
|
className="w-9 h-9 rounded-xl bg-gray-100 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-gray-900">설비관리</h1>
|
||||||
|
<p className="text-sm text-gray-500">설비 현황을 조회합니다</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative">
|
||||||
|
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="설비명 또는 코드 검색..."
|
||||||
|
value={searchKeyword}
|
||||||
|
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-4 py-3 rounded-xl border border-gray-200 text-sm focus:outline-none focus:border-blue-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KPI */}
|
||||||
|
<div className="px-5 py-3">
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-3 text-center">
|
||||||
|
<p className="text-xs text-gray-400">전체</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{totalCount}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl border border-green-200 p-3 text-center">
|
||||||
|
<p className="text-xs text-green-500">가동</p>
|
||||||
|
<p className="text-2xl font-bold text-green-600">{activeCount}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl border border-red-200 p-3 text-center">
|
||||||
|
<p className="text-xs text-red-500">비가동</p>
|
||||||
|
<p className="text-2xl font-bold text-red-600">{stopCount}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
|
<div className="px-5 pb-24">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-500">설비 목록</h2>
|
||||||
|
<span className="text-xs text-gray-400">{filtered.length}건</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<div className="w-8 h-8 border-4 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-gray-400">
|
||||||
|
<svg className="w-16 h-16 mb-4 text-gray-200" fill="none" stroke="currentColor" strokeWidth={1} viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M11.42 15.17l-5.65-5.65a8 8 0 1111.31 0l-5.65 5.65z" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-sm">등록된 설비가 없습니다</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{filtered.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="bg-white rounded-2xl border border-gray-200 p-4 hover:shadow-md transition-shadow"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-gray-900">{item.equipment_name || "-"}</h3>
|
||||||
|
<p className="text-xs text-gray-400">{item.equipment_code}</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`text-xs px-2.5 py-1 rounded-full font-semibold ${
|
||||||
|
item.status === "정지" || item.status === "비가동"
|
||||||
|
? "bg-red-100 text-red-600"
|
||||||
|
: "bg-green-100 text-green-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.status || "정상"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs text-gray-500">
|
||||||
|
{item.equipment_type && (
|
||||||
|
<div>유형: <span className="text-gray-700 font-medium">{item.equipment_type}</span></div>
|
||||||
|
)}
|
||||||
|
{item.location && (
|
||||||
|
<div>위치: <span className="text-gray-700 font-medium">{item.location}</span></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -95,7 +95,29 @@ const MENU_ITEMS = [
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
href: "#",
|
href: "/pop/inventory/transfer",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "move",
|
||||||
|
title: "재고이동",
|
||||||
|
gradient: "linear-gradient(135deg,#10b981,#059669)",
|
||||||
|
shadowColor: "rgba(16,185,129,.3)",
|
||||||
|
icon: (
|
||||||
|
<svg
|
||||||
|
className="w-7 h-7 text-white"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
href: "/pop/inventory/move",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { PopShell } from "../PopShell";
|
||||||
|
|
||||||
|
interface Warehouse {
|
||||||
|
id: string;
|
||||||
|
warehouse_code: string;
|
||||||
|
warehouse_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StockItem {
|
||||||
|
id: string;
|
||||||
|
item_code: string;
|
||||||
|
item_name?: string;
|
||||||
|
warehouse_code: string;
|
||||||
|
location_code?: string;
|
||||||
|
current_qty: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingItem {
|
||||||
|
stock: StockItem;
|
||||||
|
moveQty: number;
|
||||||
|
toWarehouse: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InventoryMove() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
|
||||||
|
const [fromWarehouse, setFromWarehouse] = useState("");
|
||||||
|
const [toWarehouse, setToWarehouse] = useState("");
|
||||||
|
const [stockItems, setStockItems] = useState<StockItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [searchKeyword, setSearchKeyword] = useState("");
|
||||||
|
const [pendingItems, setPendingItems] = useState<PendingItem[]>([]);
|
||||||
|
|
||||||
|
const fetchWarehouses = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get("/outbound/warehouses");
|
||||||
|
setWarehouses(res.data?.data || []);
|
||||||
|
} catch { /* */ }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchStock = useCallback(async () => {
|
||||||
|
if (!fromWarehouse) { setStockItems([]); return; }
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get("/data/inventory_stock", {
|
||||||
|
params: { pageSize: "500", filters: JSON.stringify({ warehouse_code: fromWarehouse }) },
|
||||||
|
});
|
||||||
|
const data = res.data?.data?.data ?? res.data?.data ?? [];
|
||||||
|
setStockItems(Array.isArray(data) ? data.filter((d: StockItem) => parseFloat(d.current_qty || "0") > 0) : []);
|
||||||
|
} catch {
|
||||||
|
setStockItems([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [fromWarehouse]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchWarehouses(); }, [fetchWarehouses]);
|
||||||
|
useEffect(() => { fetchStock(); }, [fetchStock]);
|
||||||
|
|
||||||
|
const filtered = stockItems.filter((item) => {
|
||||||
|
if (!searchKeyword) return true;
|
||||||
|
const kw = searchKeyword.toLowerCase();
|
||||||
|
return (item.item_code || "").toLowerCase().includes(kw) || (item.item_name || "").toLowerCase().includes(kw);
|
||||||
|
});
|
||||||
|
|
||||||
|
const addToPending = (stock: StockItem) => {
|
||||||
|
if (!toWarehouse) { alert("도착 창고를 먼저 선택하세요."); return; }
|
||||||
|
if (pendingItems.find((p) => p.stock.id === stock.id)) return;
|
||||||
|
const qty = parseFloat(stock.current_qty || "0");
|
||||||
|
setPendingItems((prev) => [...prev, { stock, moveQty: qty, toWarehouse }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePending = (id: string) => {
|
||||||
|
setPendingItems((prev) => prev.filter((p) => p.stock.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const fromWh = warehouses.find((w) => w.warehouse_code === fromWarehouse);
|
||||||
|
const toWh = warehouses.find((w) => w.warehouse_code === toWarehouse);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PopShell title="재고이동" showBanner={false}>
|
||||||
|
<div className="flex flex-col h-screen bg-gray-100">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-white border-b border-gray-200 px-4 py-3 flex items-center gap-3 shrink-0">
|
||||||
|
<button onClick={() => router.back()} className="w-9 h-9 rounded-xl bg-gray-100 flex items-center justify-center">
|
||||||
|
<svg className="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h1 className="text-lg font-bold text-gray-900">📦 재고 이동</h1>
|
||||||
|
<p className="text-xs text-gray-500">창고 간 재고를 이동합니다</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 좌우 분할 */}
|
||||||
|
<div className="flex-1 flex overflow-hidden">
|
||||||
|
{/* ===== 왼쪽: 출발 창고 + 품목 선택 ===== */}
|
||||||
|
<div className="flex-1 flex flex-col bg-white border-r-2 border-gray-200">
|
||||||
|
{/* 출발 창고 헤더 */}
|
||||||
|
<div className="px-4 py-3 border-b border-gray-100 bg-blue-50">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm font-bold text-blue-800">📤 출발 창고</span>
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-100 text-blue-600 font-semibold">FROM</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||||
|
{warehouses.map((wh) => (
|
||||||
|
<button
|
||||||
|
key={wh.warehouse_code}
|
||||||
|
onClick={() => { setFromWarehouse(wh.warehouse_code); setPendingItems([]); }}
|
||||||
|
className={`px-4 py-2.5 rounded-xl text-sm font-semibold whitespace-nowrap transition-all ${
|
||||||
|
fromWarehouse === wh.warehouse_code
|
||||||
|
? "bg-blue-600 text-white shadow-md"
|
||||||
|
: "bg-white text-gray-600 border border-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{wh.warehouse_name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 검색 */}
|
||||||
|
{fromWarehouse && (
|
||||||
|
<div className="px-4 py-2 border-b border-gray-100">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="품목명 / 코드 검색"
|
||||||
|
value={searchKeyword}
|
||||||
|
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||||
|
className="flex-1 px-4 py-2.5 rounded-xl border border-gray-200 text-sm focus:outline-none focus:border-blue-400"
|
||||||
|
/>
|
||||||
|
<button className="px-4 py-2.5 rounded-xl bg-blue-500 text-white text-sm font-bold">🔍</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 품목 리스트 */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-3 py-2">
|
||||||
|
{!fromWarehouse ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-gray-400">
|
||||||
|
<span className="text-4xl mb-3">📦</span>
|
||||||
|
<p className="text-base font-semibold">출발 창고를 선택하세요</p>
|
||||||
|
</div>
|
||||||
|
) : loading ? (
|
||||||
|
<div className="flex justify-center py-16">
|
||||||
|
<div className="w-8 h-8 border-4 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
|
||||||
|
<p className="text-sm">해당 창고에 재고가 없습니다</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{filtered.map((item) => {
|
||||||
|
const isPending = pendingItems.some((p) => p.stock.id === item.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => addToPending(item)}
|
||||||
|
disabled={isPending}
|
||||||
|
className={`w-full text-left p-3.5 rounded-xl border transition-all active:scale-[0.98] ${
|
||||||
|
isPending
|
||||||
|
? "bg-green-50 border-green-300 opacity-60"
|
||||||
|
: "bg-white border-gray-200 hover:border-blue-300 hover:shadow-sm"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-base font-bold text-gray-900">{item.item_name || item.item_code}</p>
|
||||||
|
<p className="text-xs text-gray-400">{item.item_code} · {item.location_code || item.warehouse_code}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-xl font-bold text-blue-600">{parseFloat(item.current_qty || "0").toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ===== 오른쪽: 도착 창고 + 이동 대기열 ===== */}
|
||||||
|
<div className="flex-1 flex flex-col bg-gray-50">
|
||||||
|
{/* 도착 창고 헤더 */}
|
||||||
|
<div className="px-4 py-3 border-b border-gray-100 bg-green-50">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm font-bold text-green-800">📥 도착 창고</span>
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-full bg-green-100 text-green-600 font-semibold">TO</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||||
|
{warehouses
|
||||||
|
.filter((wh) => wh.warehouse_code !== fromWarehouse)
|
||||||
|
.map((wh) => (
|
||||||
|
<button
|
||||||
|
key={wh.warehouse_code}
|
||||||
|
onClick={() => setToWarehouse(wh.warehouse_code)}
|
||||||
|
className={`px-4 py-2.5 rounded-xl text-sm font-semibold whitespace-nowrap transition-all ${
|
||||||
|
toWarehouse === wh.warehouse_code
|
||||||
|
? "bg-green-600 text-white shadow-md"
|
||||||
|
: "bg-white text-gray-600 border border-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{wh.warehouse_name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 이동 방향 표시 */}
|
||||||
|
{fromWh && toWh && (
|
||||||
|
<div className="px-4 py-2 bg-white border-b border-gray-200 flex items-center justify-center gap-3">
|
||||||
|
<span className="text-sm font-bold text-blue-600">{fromWh.warehouse_name}</span>
|
||||||
|
<span className="text-lg">→</span>
|
||||||
|
<span className="text-sm font-bold text-green-600">{toWh.warehouse_name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 이동 대기 목록 */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-3 py-2">
|
||||||
|
{pendingItems.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-gray-400">
|
||||||
|
<span className="text-4xl mb-3">📋</span>
|
||||||
|
<p className="text-base font-semibold">왼쪽에서 품목을 선택하세요</p>
|
||||||
|
<p className="text-sm mt-1">선택한 품목이 여기에 표시됩니다</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{pendingItems.map((p) => (
|
||||||
|
<div key={p.stock.id} className="bg-white rounded-xl border-l-4 border-l-blue-500 border border-gray-200 p-3.5">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<p className="text-base font-bold text-gray-900">{p.stock.item_name || p.stock.item_code}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => removePending(p.stock.id)}
|
||||||
|
className="px-3 py-1.5 rounded-lg border border-red-200 bg-red-50 text-red-600 text-sm font-bold active:bg-red-100"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400 mb-2">{p.stock.item_code}</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-bold text-blue-600 bg-blue-50 px-2 py-0.5 rounded">
|
||||||
|
{p.moveQty.toLocaleString()} EA
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
{p.stock.warehouse_code} → {p.toWarehouse}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 하단 확정 바 */}
|
||||||
|
<div className="px-4 py-3 border-t border-gray-200 bg-white flex items-center justify-between shrink-0">
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
이동 대기: <strong className="text-blue-600">{pendingItems.length}건</strong>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => alert("재고 이동 API 준비 중입니다.")}
|
||||||
|
disabled={pendingItems.length === 0}
|
||||||
|
className={`px-6 py-3 rounded-xl text-base font-bold text-white active:scale-[0.98] transition-all ${
|
||||||
|
pendingItems.length > 0
|
||||||
|
? "bg-red-500 hover:bg-red-600"
|
||||||
|
: "bg-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
이동 확정 {pendingItems.length > 0 && (
|
||||||
|
<span className="ml-1 bg-white text-red-500 text-xs font-bold px-2 py-0.5 rounded-full">
|
||||||
|
{pendingItems.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { PopShell } from "../PopShell";
|
||||||
|
|
||||||
|
interface Warehouse {
|
||||||
|
id: string;
|
||||||
|
warehouse_code: string;
|
||||||
|
warehouse_name: string;
|
||||||
|
warehouse_type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StockItem {
|
||||||
|
id: string;
|
||||||
|
item_code: string;
|
||||||
|
item_name?: string;
|
||||||
|
warehouse_code: string;
|
||||||
|
location_code?: string;
|
||||||
|
current_qty: string;
|
||||||
|
unit?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectedItem {
|
||||||
|
stock: StockItem;
|
||||||
|
adjustQty: string;
|
||||||
|
type: "confirm" | "adjust";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InventoryTransfer() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
|
||||||
|
const [selectedWarehouse, setSelectedWarehouse] = useState("all");
|
||||||
|
const [stockItems, setStockItems] = useState<StockItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchKeyword, setSearchKeyword] = useState("");
|
||||||
|
const [selectedItems, setSelectedItems] = useState<SelectedItem[]>([]);
|
||||||
|
|
||||||
|
const fetchWarehouses = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get("/outbound/warehouses");
|
||||||
|
setWarehouses(res.data?.data || []);
|
||||||
|
} catch { /* */ }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchStock = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params: Record<string, string> = { pageSize: "500" };
|
||||||
|
if (selectedWarehouse !== "all") {
|
||||||
|
params.filters = JSON.stringify({ warehouse_code: selectedWarehouse });
|
||||||
|
}
|
||||||
|
const res = await apiClient.get("/data/inventory_stock", { params });
|
||||||
|
const data = res.data?.data?.data ?? res.data?.data ?? [];
|
||||||
|
setStockItems(Array.isArray(data) ? data.filter((d: StockItem) => parseFloat(d.current_qty || "0") > 0) : []);
|
||||||
|
} catch {
|
||||||
|
setStockItems([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [selectedWarehouse]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchWarehouses(); }, [fetchWarehouses]);
|
||||||
|
useEffect(() => { fetchStock(); }, [fetchStock]);
|
||||||
|
|
||||||
|
const filtered = stockItems.filter((item) => {
|
||||||
|
if (!searchKeyword) return true;
|
||||||
|
const kw = searchKeyword.toLowerCase();
|
||||||
|
return (item.item_code || "").toLowerCase().includes(kw) || (item.item_name || "").toLowerCase().includes(kw);
|
||||||
|
});
|
||||||
|
|
||||||
|
const addItem = (stock: StockItem) => {
|
||||||
|
if (selectedItems.find((s) => s.stock.id === stock.id)) return;
|
||||||
|
setSelectedItems((prev) => [...prev, { stock, adjustQty: "", type: "confirm" }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeItem = (id: string) => {
|
||||||
|
setSelectedItems((prev) => prev.filter((s) => s.stock.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmCount = selectedItems.filter((s) => s.type === "confirm").length;
|
||||||
|
const adjustCount = selectedItems.filter((s) => s.type === "adjust").length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PopShell title="재고조정" showBanner={false}>
|
||||||
|
<div className="min-h-screen bg-gray-50 flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-white border-b border-gray-200 px-5 pt-5 pb-4 shrink-0">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button onClick={() => router.back()} className="w-9 h-9 rounded-xl bg-gray-100 flex items-center justify-center">
|
||||||
|
<svg className="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<h1 className="text-xl font-bold text-gray-900">📦 재고조정</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main — 2단 레이아웃 */}
|
||||||
|
<div className="flex-1 flex flex-col lg:flex-row overflow-hidden">
|
||||||
|
{/* 왼쪽: 제품 선택 */}
|
||||||
|
<div className="flex-1 flex flex-col border-r border-gray-200 bg-white">
|
||||||
|
<div className="px-4 pt-4 pb-2 border-b border-gray-100">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h2 className="text-sm font-bold text-gray-700">📦 제품 선택</h2>
|
||||||
|
<button className="px-3 py-1.5 text-xs rounded-lg border border-gray-200 text-gray-500">📋 이력</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 창고 탭 */}
|
||||||
|
<div className="flex gap-2 mb-3 overflow-x-auto pb-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedWarehouse("all")}
|
||||||
|
className={`px-4 py-2 rounded-full text-xs font-semibold whitespace-nowrap transition-all ${
|
||||||
|
selectedWarehouse === "all" ? "bg-amber-500 text-white" : "bg-gray-100 text-gray-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
전체
|
||||||
|
</button>
|
||||||
|
{warehouses.map((wh) => (
|
||||||
|
<button
|
||||||
|
key={wh.warehouse_code}
|
||||||
|
onClick={() => setSelectedWarehouse(wh.warehouse_code)}
|
||||||
|
className={`px-4 py-2 rounded-full text-xs font-semibold whitespace-nowrap transition-all ${
|
||||||
|
selectedWarehouse === wh.warehouse_code ? "bg-amber-500 text-white" : "bg-gray-100 text-gray-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{wh.warehouse_name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 검색 */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="품목명 / 코드 검색"
|
||||||
|
value={searchKeyword}
|
||||||
|
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||||
|
className="flex-1 px-4 py-2.5 rounded-xl border border-gray-200 text-sm focus:outline-none focus:border-amber-400"
|
||||||
|
/>
|
||||||
|
<button className="px-4 py-2.5 rounded-xl bg-amber-500 text-white text-sm font-bold">🔍</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 품목 리스트 */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-4 py-2">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex justify-center py-16">
|
||||||
|
<div className="w-8 h-8 border-4 border-amber-500 border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
|
||||||
|
<span className="text-4xl mb-3">📦</span>
|
||||||
|
<p className="text-sm">해당 창고에 재고가 없습니다</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{filtered.map((item) => (
|
||||||
|
<div key={item.id} className="flex items-center justify-between py-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-amber-50 flex items-center justify-center text-sm">📦</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-bold text-gray-900">
|
||||||
|
{item.item_name || item.item_code}
|
||||||
|
{item.item_name && <span className="text-gray-400 font-normal"> ({item.item_code})</span>}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400">{item.warehouse_code}{item.location_code ? ` · ${item.location_code}` : ""}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-lg font-bold text-gray-900">{parseFloat(item.current_qty || "0").toLocaleString()}</p>
|
||||||
|
<p className="text-[10px] text-gray-400">{item.location_code || item.warehouse_code}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => addItem(item)}
|
||||||
|
className={`w-8 h-8 rounded-full flex items-center justify-center text-white text-lg font-bold transition-all ${
|
||||||
|
selectedItems.find((s) => s.stock.id === item.id)
|
||||||
|
? "bg-gray-300"
|
||||||
|
: "bg-amber-500 active:bg-amber-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 오른쪽: 처리 결과 */}
|
||||||
|
<div className="w-full lg:w-[400px] bg-gray-50 flex flex-col">
|
||||||
|
<div className="px-4 pt-4 pb-2 border-b border-gray-200 bg-white flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-bold text-gray-700">📋 처리 결과</h2>
|
||||||
|
<span className="text-xs font-bold text-amber-600 bg-amber-50 px-2.5 py-1 rounded-full">
|
||||||
|
{selectedItems.length}건
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-4 py-3">
|
||||||
|
{selectedItems.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
|
||||||
|
<span className="text-4xl mb-3">📋</span>
|
||||||
|
<p className="text-sm">제품을 스캔/선택하여 처리하세요</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{selectedItems.map((sel) => (
|
||||||
|
<div key={sel.stock.id} className="bg-white rounded-xl border border-gray-200 p-3">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<p className="text-sm font-bold text-gray-900">{sel.stock.item_name || sel.stock.item_code}</p>
|
||||||
|
<button onClick={() => removeItem(sel.stock.id)} className="text-xs text-red-500 font-semibold">삭제</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400 mb-2">현재 재고: {parseFloat(sel.stock.current_qty || "0").toLocaleString()}</p>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
placeholder="조정 수량"
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-gray-200 text-sm focus:outline-none focus:border-amber-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="px-4 py-3 border-t border-gray-200 bg-white flex items-center justify-between">
|
||||||
|
<div className="flex gap-3 text-xs">
|
||||||
|
<span className="text-blue-600 font-semibold">확인 {confirmCount}</span>
|
||||||
|
<span className="text-amber-600 font-semibold">조정 {adjustCount}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedItems([])}
|
||||||
|
className="px-4 py-2.5 rounded-xl border border-gray-200 text-xs font-semibold text-gray-600"
|
||||||
|
>
|
||||||
|
전체삭제
|
||||||
|
</button>
|
||||||
|
<button className="px-4 py-2.5 rounded-xl bg-amber-500 text-white text-xs font-bold active:bg-amber-600">
|
||||||
|
일괄 확정
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1208,34 +1208,45 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
|
|||||||
behavior: "smooth",
|
behavior: "smooth",
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
className={`w-full flex items-center gap-2 px-3 py-2 text-left transition-all ${
|
className={`w-full flex items-center gap-3 mx-2 mb-1.5 px-3 py-3 rounded-xl text-left transition-all ${
|
||||||
isSelected
|
isSelected
|
||||||
? "border-l-[3px] border-l-gray-900 bg-white"
|
? "bg-blue-50 border-2 border-blue-400 shadow-sm"
|
||||||
: "border-l-[3px] border-l-transparent hover:bg-gray-50"
|
: isDone
|
||||||
|
? "bg-green-50/50 border border-green-200"
|
||||||
|
: "bg-white border border-gray-200 hover:border-blue-300 hover:shadow-sm"
|
||||||
}`}
|
}`}
|
||||||
|
style={{ width: "calc(100% - 16px)" }}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`w-2 h-2 rounded-full shrink-0 ${
|
className={`w-3 h-3 rounded-full shrink-0 ${
|
||||||
isDone
|
isDone
|
||||||
? "bg-green-500"
|
? "bg-green-500"
|
||||||
: g.timerStarted
|
: g.timerStarted
|
||||||
? "bg-blue-500 animate-pulse"
|
? "bg-blue-500 animate-pulse"
|
||||||
: "bg-gray-300"
|
: isSelected
|
||||||
|
? "bg-blue-500"
|
||||||
|
: "bg-gray-300"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className={`text-sm truncate flex-1 ${
|
className={`text-sm flex-1 ${
|
||||||
isSelected
|
isSelected
|
||||||
? "font-semibold text-blue-700"
|
? "font-bold text-blue-800"
|
||||||
: isDone
|
: isDone
|
||||||
? "text-gray-400"
|
? "text-green-700 font-medium"
|
||||||
: "text-gray-700"
|
: "text-gray-700 font-medium"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{g.title}
|
{g.title}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={`text-[13px] shrink-0 ${isDone ? "text-green-500" : "text-gray-300"}`}
|
className={`text-xs font-bold px-2 py-0.5 rounded-full ${
|
||||||
|
isDone
|
||||||
|
? "bg-green-100 text-green-600"
|
||||||
|
: isSelected
|
||||||
|
? "bg-blue-100 text-blue-600"
|
||||||
|
: "bg-gray-100 text-gray-400"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{g.completed}/{g.total}
|
{g.completed}/{g.total}
|
||||||
</span>
|
</span>
|
||||||
@@ -1259,41 +1270,22 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
|
|||||||
behavior: "smooth",
|
behavior: "smooth",
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
className={`w-full flex items-center gap-2 px-3 py-2.5 mb-2 text-left transition-all ${
|
className={`w-full flex items-center gap-3 mx-2 mb-1.5 px-3 py-3 rounded-xl text-left transition-all ${
|
||||||
activeSection === "material"
|
activeSection === "material"
|
||||||
? "border-l-[3px] border-l-gray-900 bg-white"
|
? "bg-blue-50 border-2 border-blue-400 shadow-sm"
|
||||||
: "border-l-[3px] border-l-transparent hover:bg-gray-50"
|
: "bg-white border border-gray-200 hover:border-blue-300 hover:shadow-sm"
|
||||||
}`}
|
}`}
|
||||||
|
style={{ width: "calc(100% - 16px)" }}
|
||||||
>
|
>
|
||||||
<span className="text-sm">📦</span>
|
<span className="text-base">📦</span>
|
||||||
<span
|
<span
|
||||||
className={`text-sm ${activeSection === "material" ? "font-semibold text-blue-700" : "text-gray-600 font-medium"}`}
|
className={`text-sm font-medium ${activeSection === "material" ? "font-bold text-blue-800" : "text-gray-700"}`}
|
||||||
>
|
>
|
||||||
자재 투입
|
자재 투입
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-2 px-3 mb-1.5">
|
|
||||||
<div className="w-4 h-4 rounded-full bg-amber-500 flex items-center justify-center">
|
|
||||||
<svg
|
|
||||||
className="w-2.5 h-2.5 text-white"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={2.5}
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15a2.25 2.25 0 012.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V19.5a2.25 2.25 0 002.25 2.25h.75"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<span className="text-base font-bold text-gray-400 uppercase tracking-wider">
|
|
||||||
실적
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setActiveSection("result");
|
setActiveSection("result");
|
||||||
@@ -1302,27 +1294,16 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
|
|||||||
behavior: "smooth",
|
behavior: "smooth",
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
className={`w-full flex items-center gap-2 px-3 py-2 text-left transition-all ${
|
className={`w-full flex items-center gap-3 mx-2 mb-1.5 px-3 py-3 rounded-xl text-left transition-all ${
|
||||||
activeSection === "result"
|
activeSection === "result"
|
||||||
? "border-l-[3px] border-l-gray-900 bg-white"
|
? "bg-blue-50 border-2 border-blue-400 shadow-sm"
|
||||||
: "border-l-[3px] border-l-transparent hover:bg-gray-50"
|
: "bg-white border border-gray-200 hover:border-blue-300 hover:shadow-sm"
|
||||||
}`}
|
}`}
|
||||||
|
style={{ width: "calc(100% - 16px)" }}
|
||||||
>
|
>
|
||||||
<svg
|
<span className="text-base">📋</span>
|
||||||
className={`w-3.5 h-3.5 ${activeSection === "result" ? "text-blue-500" : "text-amber-500"}`}
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={2}
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M12 9v6m3-3H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span
|
<span
|
||||||
className={`text-sm ${activeSection === "result" ? "font-semibold text-blue-700" : "text-amber-700 font-medium"}`}
|
className={`text-sm font-medium ${activeSection === "result" ? "font-bold text-blue-800" : "text-gray-700"}`}
|
||||||
>
|
>
|
||||||
실적 입력
|
실적 입력
|
||||||
</span>
|
</span>
|
||||||
@@ -1333,28 +1314,6 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
|
|||||||
{/* Inventory section link */}
|
{/* Inventory section link */}
|
||||||
{isLastProcess && (
|
{isLastProcess && (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 px-3 mb-1.5">
|
|
||||||
<div
|
|
||||||
className={`w-4 h-4 rounded-full flex items-center justify-center ${inboundDone ? "bg-green-500" : "bg-amber-500"}`}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="w-2.5 h-2.5 text-white"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={2.5}
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<span className="text-base font-bold text-gray-400 uppercase tracking-wider">
|
|
||||||
입고
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setActiveSection("inventory");
|
setActiveSection("inventory");
|
||||||
@@ -1363,45 +1322,27 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
|
|||||||
behavior: "smooth",
|
behavior: "smooth",
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
className={`w-full flex items-center gap-2 px-3 py-2 text-left transition-all ${
|
className={`w-full flex items-center gap-3 mx-2 mb-1.5 px-3 py-3 rounded-xl text-left transition-all ${
|
||||||
activeSection === "inventory"
|
activeSection === "inventory"
|
||||||
? "border-l-[3px] border-l-gray-900 bg-white"
|
? "bg-blue-50 border-2 border-blue-400 shadow-sm"
|
||||||
: "border-l-[3px] border-l-transparent hover:bg-gray-50"
|
: inboundDone
|
||||||
|
? "bg-green-50 border border-green-300"
|
||||||
|
: "bg-white border border-gray-200 hover:border-blue-300 hover:shadow-sm"
|
||||||
}`}
|
}`}
|
||||||
|
style={{ width: "calc(100% - 16px)" }}
|
||||||
>
|
>
|
||||||
<svg
|
<span className="text-base">{inboundDone ? "✅" : "🏭"}</span>
|
||||||
className={`w-3.5 h-3.5 ${activeSection === "inventory" ? "text-blue-500" : inboundDone ? "text-green-500" : "text-amber-500"}`}
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={2}
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span
|
<span
|
||||||
className={`text-sm ${activeSection === "inventory" ? "font-semibold text-blue-700" : inboundDone ? "text-green-700 font-medium" : "text-amber-700 font-medium"}`}
|
className={`text-sm font-medium ${
|
||||||
|
activeSection === "inventory"
|
||||||
|
? "font-bold text-blue-800"
|
||||||
|
: inboundDone
|
||||||
|
? "text-green-700 font-bold"
|
||||||
|
: "text-gray-700"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
재고 입고
|
재고 입고{inboundDone ? " 완료" : ""}
|
||||||
</span>
|
</span>
|
||||||
{inboundDone && (
|
|
||||||
<svg
|
|
||||||
className="w-3.5 h-3.5 ml-auto text-green-500"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={2.5}
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M4.5 12.75l6 6 9-13.5"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -2767,18 +2708,16 @@ function MaterialQtyInputRow({
|
|||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center shrink-0">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen(true)}
|
onClick={() => setOpen(true)}
|
||||||
className="flex-1 px-4 py-3 rounded-xl border-2 border-gray-200 text-base font-bold text-left text-gray-900 hover:border-blue-400 active:scale-[0.98] transition-all bg-white"
|
className="px-8 py-4 rounded-xl border-2 border-blue-300 text-xl font-bold text-blue-700 hover:border-blue-500 active:scale-[0.96] transition-all bg-blue-50 min-w-[120px] text-center"
|
||||||
style={{ minHeight: 56 }}
|
|
||||||
>
|
>
|
||||||
{value || (
|
{value || (
|
||||||
<span className="text-gray-300 font-normal">투입 수량 입력</span>
|
<span className="text-blue-300 font-semibold">입력</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<span className="text-sm text-gray-500 shrink-0">{material.unit}</span>
|
|
||||||
<MaterialQtyKeypad
|
<MaterialQtyKeypad
|
||||||
open={open}
|
open={open}
|
||||||
onClose={() => setOpen(false)}
|
onClose={() => setOpen(false)}
|
||||||
@@ -2903,50 +2842,44 @@ function MaterialInputSection({ processId }: { processId: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
{/* BOM 기준 자재 목록 */}
|
{/* BOM 기준 자재 목록 — 컴팩트 */}
|
||||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4">
|
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-3">
|
||||||
<h3 className="text-base font-bold text-gray-900 mb-3">
|
<div className="flex items-center justify-between mb-2">
|
||||||
BOM 자재 목록
|
<h3 className="text-sm font-bold text-gray-900">BOM 자재 목록</h3>
|
||||||
</h3>
|
<span className="text-xs text-gray-400">{bomMaterials.length}건</span>
|
||||||
|
</div>
|
||||||
{bomMaterials.length === 0 ? (
|
{bomMaterials.length === 0 ? (
|
||||||
<p className="text-sm text-gray-400 py-4 text-center">
|
<p className="text-sm text-gray-400 py-4 text-center">
|
||||||
BOM 자재 정보가 없습니다
|
BOM 자재 정보가 없습니다
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div>
|
||||||
{bomMaterials.map((m) => (
|
<div className="divide-y divide-gray-200">
|
||||||
<div
|
{bomMaterials.map((m) => (
|
||||||
key={m.id}
|
<div key={m.id} className="flex items-center gap-2 py-3">
|
||||||
className="p-3 rounded-xl border border-gray-200 bg-gray-50"
|
{/* 자재명(코드) + 소요량 */}
|
||||||
>
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<span className="text-base font-bold text-gray-900">{m.child_item_name}</span>
|
||||||
<div>
|
<span className="text-sm text-gray-400 ml-1">({m.child_item_code})</span>
|
||||||
<p className="text-base font-semibold text-gray-900">
|
<span className="text-base font-bold text-blue-600 ml-3">소요 {m.required_qty}</span>
|
||||||
{m.child_item_name}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-gray-400">{m.child_item_code}</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<p className="text-sm text-gray-500">소요량</p>
|
|
||||||
<p className="text-lg font-bold text-blue-600">
|
|
||||||
{m.required_qty} {m.unit}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
{/* 입력 버튼 + 단위 */}
|
||||||
|
<MaterialQtyInputRow
|
||||||
|
material={m}
|
||||||
|
value={inputValues[m.id] || ""}
|
||||||
|
onChange={(v) =>
|
||||||
|
setInputValues((prev) => ({ ...prev, [m.id]: v }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="text-base font-semibold text-gray-500 shrink-0 w-8">{m.unit}</span>
|
||||||
</div>
|
</div>
|
||||||
<MaterialQtyInputRow
|
))}
|
||||||
material={m}
|
</div>
|
||||||
value={inputValues[m.id] || ""}
|
|
||||||
onChange={(v) =>
|
|
||||||
setInputValues((prev) => ({ ...prev, [m.id]: v }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="w-full py-4 rounded-xl text-base font-bold text-white active:scale-[0.98] transition-all disabled:opacity-40"
|
className="w-full mt-4 py-4 rounded-xl text-lg font-bold text-white active:scale-[0.98] transition-all disabled:opacity-40"
|
||||||
style={{
|
style={{
|
||||||
background: "linear-gradient(135deg, #3b82f6, #1d4ed8)",
|
background: "linear-gradient(135deg, #3b82f6, #1d4ed8)",
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -2077,7 +2077,7 @@ export function WorkOrderList() {
|
|||||||
(p) =>
|
(p) =>
|
||||||
p.parent_process_id &&
|
p.parent_process_id &&
|
||||||
p.accepted_by === currentUserId &&
|
p.accepted_by === currentUserId &&
|
||||||
(p.status === "in_progress" || p.status === "completed"),
|
p.status === "in_progress",
|
||||||
)}
|
)}
|
||||||
instructionMap={instructionMap}
|
instructionMap={instructionMap}
|
||||||
onSwitch={(id) => setWorkModalProcessId(id)}
|
onSwitch={(id) => setWorkModalProcessId(id)}
|
||||||
|
|||||||
Reference in New Issue
Block a user