717 lines
40 KiB
TypeScript
717 lines
40 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useCallback } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
|
import {
|
|
Plus, Trash2, Save, Loader2, Pencil,
|
|
ClipboardCheck, AlertTriangle, Wrench, Search, Inbox, Settings2,
|
|
} from "lucide-react";
|
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
|
import { cn } from "@/lib/utils";
|
|
import { apiClient } from "@/lib/api/client";
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
import { toast } from "sonner";
|
|
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
|
import { useTableSettings } from "@/hooks/useTableSettings";
|
|
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
|
|
|
/* ───── 테이블명 ───── */
|
|
const INSPECTION_TABLE = "inspection_standard";
|
|
|
|
const INSPECTION_COLUMNS = [
|
|
{ key: "inspection_type", label: "검사유형" },
|
|
{ key: "inspection_standard", label: "검사기준" },
|
|
{ key: "inspection_item_name", label: "검사항목명" },
|
|
{ key: "inspection_method", label: "검사방법" },
|
|
{ key: "unit", label: "단위" },
|
|
{ key: "apply_type", label: "적용유형" },
|
|
{ key: "is_active", label: "사용여부" },
|
|
];
|
|
const DEFECT_TABLE = "defect_standard_mng";
|
|
const EQUIPMENT_TABLE = "inspection_equipment_mng";
|
|
|
|
/* ───── 카테고리 flatten ───── */
|
|
const flattenCategories = (vals: any[]): { code: string; label: string }[] => {
|
|
const result: { code: string; label: string }[] = [];
|
|
for (const v of vals) {
|
|
result.push({ code: v.valueCode, label: v.valueLabel });
|
|
if (v.children?.length) result.push(...flattenCategories(v.children));
|
|
}
|
|
return result;
|
|
};
|
|
|
|
export default function InspectionManagementPage() {
|
|
const { user } = useAuth();
|
|
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
|
const ts = useTableSettings("c16-inspection", INSPECTION_TABLE, INSPECTION_COLUMNS);
|
|
|
|
const [activeTab, setActiveTab] = useState("inspection");
|
|
|
|
/* ───── 검사기준 ───── */
|
|
const [inspections, setInspections] = useState<any[]>([]);
|
|
const [inspLoading, setInspLoading] = useState(false);
|
|
const [inspCount, setInspCount] = useState(0);
|
|
const [inspChecked, setInspChecked] = useState<string[]>([]);
|
|
const [inspModalOpen, setInspModalOpen] = useState(false);
|
|
const [inspEditMode, setInspEditMode] = useState(false);
|
|
const [inspForm, setInspForm] = useState<Record<string, any>>({});
|
|
const [inspSaving, setInspSaving] = useState(false);
|
|
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
|
|
|
/* ───── 불량관리 ───── */
|
|
const [defects, setDefects] = useState<any[]>([]);
|
|
const [defLoading, setDefLoading] = useState(false);
|
|
const [defCount, setDefCount] = useState(0);
|
|
const [defChecked, setDefChecked] = useState<string[]>([]);
|
|
const [defModalOpen, setDefModalOpen] = useState(false);
|
|
const [defEditMode, setDefEditMode] = useState(false);
|
|
const [defForm, setDefForm] = useState<Record<string, any>>({});
|
|
const [defSaving, setDefSaving] = useState(false);
|
|
const [defKeyword, setDefKeyword] = useState("");
|
|
|
|
/* ───── 검사장비 ───── */
|
|
const [equipments, setEquipments] = useState<any[]>([]);
|
|
const [eqLoading, setEqLoading] = useState(false);
|
|
const [eqCount, setEqCount] = useState(0);
|
|
const [eqChecked, setEqChecked] = useState<string[]>([]);
|
|
const [eqModalOpen, setEqModalOpen] = useState(false);
|
|
const [eqEditMode, setEqEditMode] = useState(false);
|
|
const [eqForm, setEqForm] = useState<Record<string, any>>({});
|
|
const [eqSaving, setEqSaving] = useState(false);
|
|
const [eqKeyword, setEqKeyword] = useState("");
|
|
|
|
/* ───── 카테고리 옵션 ───── */
|
|
const [catOptions, setCatOptions] = useState<Record<string, { code: string; label: string }[]>>({});
|
|
|
|
/* ═══════════════════ 카테고리 로드 ═══════════════════ */
|
|
useEffect(() => {
|
|
const load = async () => {
|
|
const optMap: Record<string, { code: string; label: string }[]> = {};
|
|
const catList = [
|
|
{ table: INSPECTION_TABLE, col: "inspection_type" },
|
|
{ table: INSPECTION_TABLE, col: "apply_type" },
|
|
{ table: DEFECT_TABLE, col: "defect_type" },
|
|
{ table: EQUIPMENT_TABLE, col: "equipment_status" },
|
|
];
|
|
await Promise.all(
|
|
catList.map(async ({ table, col }) => {
|
|
try {
|
|
const res = await apiClient.get(`/table-categories/${table}/${col}/values`);
|
|
if (res.data?.data?.length > 0) {
|
|
optMap[`${table}.${col}`] = flattenCategories(res.data.data);
|
|
}
|
|
} catch { /* skip */ }
|
|
})
|
|
);
|
|
setCatOptions(optMap);
|
|
};
|
|
load();
|
|
}, []);
|
|
|
|
const getCatLabel = (table: string, col: string, code: string) => {
|
|
const opts = catOptions[`${table}.${col}`];
|
|
if (!opts) return code;
|
|
return opts.find(o => o.code === code)?.label || code;
|
|
};
|
|
|
|
/* ═══════════════════ 데이터 조회 ═══════════════════ */
|
|
const fetchInspections = useCallback(async () => {
|
|
setInspLoading(true);
|
|
try {
|
|
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
|
|
const res = await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, {
|
|
page: 1, size: 500,
|
|
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
|
autoFilter: true,
|
|
});
|
|
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
|
setInspections(rows);
|
|
setInspCount(rows.length);
|
|
} catch {
|
|
toast.error("검사기준 조회에 실패했어요");
|
|
} finally {
|
|
setInspLoading(false);
|
|
}
|
|
}, [searchFilters]);
|
|
|
|
const fetchDefects = useCallback(async () => {
|
|
setDefLoading(true);
|
|
try {
|
|
const res = await apiClient.post(`/table-management/tables/${DEFECT_TABLE}/data`, {
|
|
page: 1, size: 500, autoFilter: true,
|
|
});
|
|
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
|
setDefects(rows);
|
|
setDefCount(rows.length);
|
|
} catch {
|
|
toast.error("불량관리 조회에 실패했어요");
|
|
} finally {
|
|
setDefLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
const fetchEquipments = useCallback(async () => {
|
|
setEqLoading(true);
|
|
try {
|
|
const res = await apiClient.post(`/table-management/tables/${EQUIPMENT_TABLE}/data`, {
|
|
page: 1, size: 500, autoFilter: true,
|
|
});
|
|
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
|
setEquipments(rows);
|
|
setEqCount(rows.length);
|
|
} catch {
|
|
toast.error("검사장비 조회에 실패했어요");
|
|
} finally {
|
|
setEqLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => { fetchInspections(); }, [fetchInspections]);
|
|
useEffect(() => { fetchDefects(); fetchEquipments(); }, []);
|
|
|
|
/* ───── 클라이언트 필터 ───── */
|
|
const filteredDefects = defKeyword.trim()
|
|
? defects.filter(r => (r.defect_name || "").toLowerCase().includes(defKeyword.toLowerCase()) || (r.defect_type || "").toLowerCase().includes(defKeyword.toLowerCase()))
|
|
: defects;
|
|
|
|
const filteredEquipments = eqKeyword.trim()
|
|
? equipments.filter(r => (r.equipment_name || "").toLowerCase().includes(eqKeyword.toLowerCase()) || (r.model_name || "").toLowerCase().includes(eqKeyword.toLowerCase()))
|
|
: equipments;
|
|
|
|
/* ═══════════════════ 검사기준 CRUD ═══════════════════ */
|
|
const openInspCreate = () => { setInspForm({}); setInspEditMode(false); setInspModalOpen(true); };
|
|
const openInspEdit = (row: any) => { setInspForm({ ...row }); setInspEditMode(true); setInspModalOpen(true); };
|
|
const saveInspection = async () => {
|
|
if (!inspForm.inspection_standard) { toast.error("검사기준은 필수 입력이에요"); return; }
|
|
setInspSaving(true);
|
|
try {
|
|
if (inspEditMode) {
|
|
await apiClient.put(`/table-management/tables/${INSPECTION_TABLE}/edit`, {
|
|
originalData: { id: inspForm.id }, updatedData: inspForm,
|
|
});
|
|
toast.success("검사기준을 수정했어요");
|
|
} else {
|
|
await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/add`, { id: crypto.randomUUID(), ...inspForm });
|
|
toast.success("검사기준을 등록했어요");
|
|
}
|
|
setInspModalOpen(false);
|
|
fetchInspections();
|
|
} catch { toast.error("저장에 실패했어요"); }
|
|
finally { setInspSaving(false); }
|
|
};
|
|
const deleteInspections = async () => {
|
|
if (inspChecked.length === 0) { toast.error("삭제할 항목을 선택해주세요"); return; }
|
|
const ok = await confirm("검사기준 삭제", { description: `선택한 ${inspChecked.length}건을 삭제할까요? 이 작업은 되돌릴 수 없어요.` });
|
|
if (!ok) return;
|
|
try {
|
|
await apiClient.delete(`/table-management/tables/${INSPECTION_TABLE}/delete`, {
|
|
data: inspChecked.map(id => ({ id })),
|
|
});
|
|
toast.success(`${inspChecked.length}건을 삭제했어요`);
|
|
setInspChecked([]);
|
|
fetchInspections();
|
|
} catch { toast.error("삭제에 실패했어요"); }
|
|
};
|
|
|
|
/* ═══════════════════ 불량관리 CRUD ═══════════════════ */
|
|
const openDefCreate = () => { setDefForm({}); setDefEditMode(false); setDefModalOpen(true); };
|
|
const openDefEdit = (row: any) => { setDefForm({ ...row }); setDefEditMode(true); setDefModalOpen(true); };
|
|
const saveDefect = async () => {
|
|
if (!defForm.defect_name) { toast.error("불량명은 필수 입력이에요"); return; }
|
|
setDefSaving(true);
|
|
try {
|
|
if (defEditMode) {
|
|
await apiClient.put(`/table-management/tables/${DEFECT_TABLE}/edit`, {
|
|
originalData: { id: defForm.id }, updatedData: defForm,
|
|
});
|
|
toast.success("불량유형을 수정했어요");
|
|
} else {
|
|
await apiClient.post(`/table-management/tables/${DEFECT_TABLE}/add`, { id: crypto.randomUUID(), ...defForm });
|
|
toast.success("불량유형을 등록했어요");
|
|
}
|
|
setDefModalOpen(false);
|
|
fetchDefects();
|
|
} catch { toast.error("저장에 실패했어요"); }
|
|
finally { setDefSaving(false); }
|
|
};
|
|
const deleteDefects = async () => {
|
|
if (defChecked.length === 0) { toast.error("삭제할 항목을 선택해주세요"); return; }
|
|
const ok = await confirm("불량유형 삭제", { description: `선택한 ${defChecked.length}건을 삭제할까요? 이 작업은 되돌릴 수 없어요.` });
|
|
if (!ok) return;
|
|
try {
|
|
await apiClient.delete(`/table-management/tables/${DEFECT_TABLE}/delete`, {
|
|
data: defChecked.map(id => ({ id })),
|
|
});
|
|
toast.success(`${defChecked.length}건을 삭제했어요`);
|
|
setDefChecked([]);
|
|
fetchDefects();
|
|
} catch { toast.error("삭제에 실패했어요"); }
|
|
};
|
|
|
|
/* ═══════════════════ 검사장비 CRUD ═══════════════════ */
|
|
const openEqCreate = () => { setEqForm({}); setEqEditMode(false); setEqModalOpen(true); };
|
|
const openEqEdit = (row: any) => { setEqForm({ ...row }); setEqEditMode(true); setEqModalOpen(true); };
|
|
const saveEquipment = async () => {
|
|
if (!eqForm.equipment_name) { toast.error("장비명은 필수 입력이에요"); return; }
|
|
setEqSaving(true);
|
|
try {
|
|
if (eqEditMode) {
|
|
await apiClient.put(`/table-management/tables/${EQUIPMENT_TABLE}/edit`, {
|
|
originalData: { id: eqForm.id }, updatedData: eqForm,
|
|
});
|
|
toast.success("검사장비를 수정했어요");
|
|
} else {
|
|
await apiClient.post(`/table-management/tables/${EQUIPMENT_TABLE}/add`, { id: crypto.randomUUID(), ...eqForm });
|
|
toast.success("검사장비를 등록했어요");
|
|
}
|
|
setEqModalOpen(false);
|
|
fetchEquipments();
|
|
} catch { toast.error("저장에 실패했어요"); }
|
|
finally { setEqSaving(false); }
|
|
};
|
|
const deleteEquipments = async () => {
|
|
if (eqChecked.length === 0) { toast.error("삭제할 항목을 선택해주세요"); return; }
|
|
const ok = await confirm("검사장비 삭제", { description: `선택한 ${eqChecked.length}건을 삭제할까요? 이 작업은 되돌릴 수 없어요.` });
|
|
if (!ok) return;
|
|
try {
|
|
await apiClient.delete(`/table-management/tables/${EQUIPMENT_TABLE}/delete`, {
|
|
data: eqChecked.map(id => ({ id })),
|
|
});
|
|
toast.success(`${eqChecked.length}건을 삭제했어요`);
|
|
setEqChecked([]);
|
|
fetchEquipments();
|
|
} catch { toast.error("삭제에 실패했어요"); }
|
|
};
|
|
|
|
/* ═══════════════════ JSX ═══════════════════ */
|
|
return (
|
|
<div className="flex flex-col gap-3 p-3">
|
|
{ConfirmDialogComponent}
|
|
|
|
<div className="rounded-lg border bg-card">
|
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
|
<div className="border-b px-3">
|
|
<TabsList className="bg-transparent h-auto p-0 gap-0">
|
|
<TabsTrigger
|
|
value="inspection"
|
|
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 py-3 text-sm font-medium"
|
|
>
|
|
<ClipboardCheck className="w-4 h-4 mr-2" />
|
|
검사기준
|
|
<Badge variant="secondary" className="ml-2 bg-primary/10 text-primary text-xs">{inspCount}</Badge>
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
value="defect"
|
|
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 py-3 text-sm font-medium"
|
|
>
|
|
<AlertTriangle className="w-4 h-4 mr-2" />
|
|
불량관리
|
|
<Badge variant="secondary" className="ml-2 bg-primary/10 text-primary text-xs">{defCount}</Badge>
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
value="equipment"
|
|
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 py-3 text-sm font-medium"
|
|
>
|
|
<Wrench className="w-4 h-4 mr-2" />
|
|
검사장비
|
|
<Badge variant="secondary" className="ml-2 bg-primary/10 text-primary text-xs">{eqCount}</Badge>
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
</div>
|
|
|
|
{/* ──── 검사기준 탭 ──── */}
|
|
<TabsContent value="inspection" className="p-3 mt-0">
|
|
<div className="mb-3">
|
|
<DynamicSearchFilter
|
|
tableName={INSPECTION_TABLE}
|
|
filterId="c16-inspection"
|
|
onFilterChange={setSearchFilters}
|
|
externalFilterConfig={ts.filterConfig}
|
|
dataCount={inspCount}
|
|
extraActions={
|
|
<div className="flex items-center gap-2">
|
|
<Button size="sm" onClick={openInspCreate}><Plus className="w-4 h-4 mr-1" />등록</Button>
|
|
<Button size="sm" variant="outline" onClick={() => {
|
|
const sel = inspections.find(r => inspChecked.includes(r.id));
|
|
if (sel) openInspEdit(sel);
|
|
else toast.error("수정할 항목을 선택해주세요");
|
|
}}><Pencil className="w-4 h-4 mr-1" />수정</Button>
|
|
<Button size="sm" variant="destructive" onClick={deleteInspections}><Trash2 className="w-4 h-4 mr-1" />삭제</Button>
|
|
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
|
|
<Settings2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="border rounded-lg overflow-hidden">
|
|
<Table style={{ tableLayout: "fixed" }}>
|
|
<TableHeader className="sticky top-0 z-10">
|
|
<TableRow className="bg-muted hover:bg-muted">
|
|
<TableHead className="w-10">
|
|
<Checkbox
|
|
checked={inspections.length > 0 && inspChecked.length === inspections.length}
|
|
onCheckedChange={(v) => setInspChecked(v ? inspections.map(r => r.id) : [])}
|
|
/>
|
|
</TableHead>
|
|
{ts.visibleColumns.map((col) => (
|
|
<TableHead key={col.key} style={ts.thStyle(col.key)} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">{col.label}</TableHead>
|
|
))}
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{inspLoading ? (
|
|
<TableRow><TableCell colSpan={ts.visibleColumns.length + 1} className="text-center py-8"><Loader2 className="w-5 h-5 animate-spin mx-auto text-muted-foreground" /></TableCell></TableRow>
|
|
) : inspections.length === 0 ? (
|
|
<TableRow><TableCell colSpan={ts.visibleColumns.length + 1} className="text-center py-10 text-muted-foreground"><Inbox className="w-8 h-8 mx-auto mb-2 opacity-40" /><p className="text-sm">등록된 검사기준이 없어요</p></TableCell></TableRow>
|
|
) : inspections.map((row) => (
|
|
<TableRow
|
|
key={row.id}
|
|
className={cn("cursor-pointer", inspChecked.includes(row.id) && "bg-primary/5")}
|
|
onClick={() => setInspChecked(prev => prev.includes(row.id) ? prev.filter(id => id !== row.id) : [...prev, row.id])}
|
|
onDoubleClick={() => openInspEdit(row)}
|
|
>
|
|
<TableCell onClick={(e) => e.stopPropagation()}>
|
|
<Checkbox checked={inspChecked.includes(row.id)} onCheckedChange={(v) => setInspChecked(prev => v ? [...prev, row.id] : prev.filter(id => id !== row.id))} />
|
|
</TableCell>
|
|
{ts.visibleColumns.map((col) => {
|
|
if (col.key === "inspection_type") return <TableCell key={col.key} style={ts.thStyle(col.key)}>{getCatLabel(INSPECTION_TABLE, "inspection_type", row.inspection_type)}</TableCell>;
|
|
if (col.key === "apply_type") return <TableCell key={col.key} style={ts.thStyle(col.key)}>{getCatLabel(INSPECTION_TABLE, "apply_type", row.apply_type)}</TableCell>;
|
|
if (col.key === "is_active") return <TableCell key={col.key} style={ts.thStyle(col.key)} className="text-center"><Badge variant={row.is_active ? "default" : "secondary"} className="text-xs">{row.is_active ? "사용" : "미사용"}</Badge></TableCell>;
|
|
return <TableCell key={col.key} style={ts.thStyle(col.key)}>{row[col.key] ?? ""}</TableCell>;
|
|
})}
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
{/* ──── 불량관리 탭 ──── */}
|
|
<TabsContent value="defect" className="p-3 mt-0">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<div className="flex items-center gap-2">
|
|
<div className="relative">
|
|
<Search className="absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
|
|
<Input
|
|
className="h-8 pl-8 w-56 text-sm"
|
|
placeholder="불량명 검색..."
|
|
value={defKeyword}
|
|
onChange={(e) => setDefKeyword(e.target.value)}
|
|
/>
|
|
</div>
|
|
<Badge variant="secondary" className="bg-primary/10 text-primary">{filteredDefects.length}건</Badge>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button size="sm" onClick={openDefCreate}><Plus className="w-4 h-4 mr-1" />등록</Button>
|
|
<Button size="sm" variant="outline" onClick={() => {
|
|
const sel = defects.find(r => defChecked.includes(r.id));
|
|
if (sel) openDefEdit(sel);
|
|
else toast.error("수정할 항목을 선택해주세요");
|
|
}}><Pencil className="w-4 h-4 mr-1" />수정</Button>
|
|
<Button size="sm" variant="destructive" onClick={deleteDefects}><Trash2 className="w-4 h-4 mr-1" />삭제</Button>
|
|
</div>
|
|
</div>
|
|
<div className="border rounded-lg overflow-hidden">
|
|
<Table>
|
|
<TableHeader className="sticky top-0 z-10">
|
|
<TableRow className="bg-muted hover:bg-muted">
|
|
<TableHead className="w-10">
|
|
<Checkbox
|
|
checked={filteredDefects.length > 0 && defChecked.length === filteredDefects.length}
|
|
onCheckedChange={(v) => setDefChecked(v ? filteredDefects.map(r => r.id) : [])}
|
|
/>
|
|
</TableHead>
|
|
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">불량유형</TableHead>
|
|
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">불량명</TableHead>
|
|
<TableHead className="w-[80px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">심각도</TableHead>
|
|
<TableHead className="w-[80px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">사용여부</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{defLoading ? (
|
|
<TableRow><TableCell colSpan={5} className="text-center py-8"><Loader2 className="w-5 h-5 animate-spin mx-auto text-muted-foreground" /></TableCell></TableRow>
|
|
) : filteredDefects.length === 0 ? (
|
|
<TableRow><TableCell colSpan={5} className="text-center py-10 text-muted-foreground"><Inbox className="w-8 h-8 mx-auto mb-2 opacity-40" /><p className="text-sm">등록된 불량유형이 없어요</p></TableCell></TableRow>
|
|
) : filteredDefects.map((row) => (
|
|
<TableRow
|
|
key={row.id}
|
|
className={cn("cursor-pointer", defChecked.includes(row.id) && "bg-primary/5")}
|
|
onClick={() => setDefChecked(prev => prev.includes(row.id) ? prev.filter(id => id !== row.id) : [...prev, row.id])}
|
|
onDoubleClick={() => openDefEdit(row)}
|
|
>
|
|
<TableCell onClick={(e) => e.stopPropagation()}>
|
|
<Checkbox checked={defChecked.includes(row.id)} onCheckedChange={(v) => setDefChecked(prev => v ? [...prev, row.id] : prev.filter(id => id !== row.id))} />
|
|
</TableCell>
|
|
<TableCell>{getCatLabel(DEFECT_TABLE, "defect_type", row.defect_type)}</TableCell>
|
|
<TableCell>{row.defect_name}</TableCell>
|
|
<TableCell className="text-center">
|
|
<Badge variant={row.severity === "Critical" ? "destructive" : "secondary"} className="text-xs">{row.severity}</Badge>
|
|
</TableCell>
|
|
<TableCell className="text-center">
|
|
<Badge variant={row.is_active ? "default" : "secondary"} className="text-xs">{row.is_active ? "사용" : "미사용"}</Badge>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
{/* ──── 검사장비 탭 ──── */}
|
|
<TabsContent value="equipment" className="p-3 mt-0">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<div className="flex items-center gap-2">
|
|
<div className="relative">
|
|
<Search className="absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
|
|
<Input
|
|
className="h-8 pl-8 w-56 text-sm"
|
|
placeholder="장비명 검색..."
|
|
value={eqKeyword}
|
|
onChange={(e) => setEqKeyword(e.target.value)}
|
|
/>
|
|
</div>
|
|
<Badge variant="secondary" className="bg-primary/10 text-primary">{filteredEquipments.length}건</Badge>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button size="sm" onClick={openEqCreate}><Plus className="w-4 h-4 mr-1" />등록</Button>
|
|
<Button size="sm" variant="outline" onClick={() => {
|
|
const sel = equipments.find(r => eqChecked.includes(r.id));
|
|
if (sel) openEqEdit(sel);
|
|
else toast.error("수정할 항목을 선택해주세요");
|
|
}}><Pencil className="w-4 h-4 mr-1" />수정</Button>
|
|
<Button size="sm" variant="destructive" onClick={deleteEquipments}><Trash2 className="w-4 h-4 mr-1" />삭제</Button>
|
|
</div>
|
|
</div>
|
|
<div className="border rounded-lg overflow-hidden">
|
|
<Table>
|
|
<TableHeader className="sticky top-0 z-10">
|
|
<TableRow className="bg-muted hover:bg-muted">
|
|
<TableHead className="w-10">
|
|
<Checkbox
|
|
checked={filteredEquipments.length > 0 && eqChecked.length === filteredEquipments.length}
|
|
onCheckedChange={(v) => setEqChecked(v ? filteredEquipments.map(r => r.id) : [])}
|
|
/>
|
|
</TableHead>
|
|
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">장비명</TableHead>
|
|
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">모델명</TableHead>
|
|
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">제조사</TableHead>
|
|
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">교정주기</TableHead>
|
|
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">최종교정일</TableHead>
|
|
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">장비상태</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{eqLoading ? (
|
|
<TableRow><TableCell colSpan={7} className="text-center py-8"><Loader2 className="w-5 h-5 animate-spin mx-auto text-muted-foreground" /></TableCell></TableRow>
|
|
) : filteredEquipments.length === 0 ? (
|
|
<TableRow><TableCell colSpan={7} className="text-center py-10 text-muted-foreground"><Inbox className="w-8 h-8 mx-auto mb-2 opacity-40" /><p className="text-sm">등록된 검사장비가 없어요</p></TableCell></TableRow>
|
|
) : filteredEquipments.map((row) => (
|
|
<TableRow
|
|
key={row.id}
|
|
className={cn("cursor-pointer", eqChecked.includes(row.id) && "bg-primary/5")}
|
|
onClick={() => setEqChecked(prev => prev.includes(row.id) ? prev.filter(id => id !== row.id) : [...prev, row.id])}
|
|
onDoubleClick={() => openEqEdit(row)}
|
|
>
|
|
<TableCell onClick={(e) => e.stopPropagation()}>
|
|
<Checkbox checked={eqChecked.includes(row.id)} onCheckedChange={(v) => setEqChecked(prev => v ? [...prev, row.id] : prev.filter(id => id !== row.id))} />
|
|
</TableCell>
|
|
<TableCell>{row.equipment_name}</TableCell>
|
|
<TableCell>{row.model_name}</TableCell>
|
|
<TableCell>{row.manufacturer}</TableCell>
|
|
<TableCell>{row.calibration_cycle}</TableCell>
|
|
<TableCell>{row.last_calibration_date}</TableCell>
|
|
<TableCell>{getCatLabel(EQUIPMENT_TABLE, "equipment_status", row.equipment_status)}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
|
|
{/* ═══════════════════ 검사기준 모달 ═══════════════════ */}
|
|
<Dialog open={inspModalOpen} onOpenChange={setInspModalOpen}>
|
|
<DialogContent className="max-w-[95vw] sm:max-w-lg max-h-[85vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>{inspEditMode ? "검사기준 수정" : "검사기준 등록"}</DialogTitle>
|
|
<DialogDescription>검사기준 정보를 입력해주세요</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-semibold text-muted-foreground">검사유형</Label>
|
|
<Select value={inspForm.inspection_type || ""} onValueChange={(v) => setInspForm(p => ({ ...p, inspection_type: v }))}>
|
|
<SelectTrigger className="h-9"><SelectValue placeholder="선택해주세요" /></SelectTrigger>
|
|
<SelectContent>
|
|
{(catOptions[`${INSPECTION_TABLE}.inspection_type`] || []).map(o => (
|
|
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-semibold text-muted-foreground">적용유형</Label>
|
|
<Select value={inspForm.apply_type || ""} onValueChange={(v) => setInspForm(p => ({ ...p, apply_type: v }))}>
|
|
<SelectTrigger className="h-9"><SelectValue placeholder="선택해주세요" /></SelectTrigger>
|
|
<SelectContent>
|
|
{(catOptions[`${INSPECTION_TABLE}.apply_type`] || []).map(o => (
|
|
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-1.5 col-span-2">
|
|
<Label className="text-xs font-semibold text-muted-foreground">검사기준 <span className="text-destructive">*</span></Label>
|
|
<Input className="h-9" value={inspForm.inspection_standard || ""} onChange={(e) => setInspForm(p => ({ ...p, inspection_standard: e.target.value }))} placeholder="검사기준을 입력해주세요" />
|
|
</div>
|
|
<div className="space-y-1.5 col-span-2">
|
|
<Label className="text-xs font-semibold text-muted-foreground">검사항목명</Label>
|
|
<Input className="h-9" value={inspForm.inspection_item_name || ""} onChange={(e) => setInspForm(p => ({ ...p, inspection_item_name: e.target.value }))} placeholder="검사항목명을 입력해주세요" />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-semibold text-muted-foreground">검사방법</Label>
|
|
<Input className="h-9" value={inspForm.inspection_method || ""} onChange={(e) => setInspForm(p => ({ ...p, inspection_method: e.target.value }))} placeholder="검사방법" />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-semibold text-muted-foreground">단위</Label>
|
|
<Input className="h-9" value={inspForm.unit || ""} onChange={(e) => setInspForm(p => ({ ...p, unit: e.target.value }))} placeholder="단위" />
|
|
</div>
|
|
<div className="space-y-1.5 col-span-2">
|
|
<div className="flex items-center gap-2">
|
|
<Checkbox checked={inspForm.is_active ?? true} onCheckedChange={(v) => setInspForm(p => ({ ...p, is_active: !!v }))} />
|
|
<Label className="text-sm">사용</Label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setInspModalOpen(false)}>취소</Button>
|
|
<Button onClick={saveInspection} disabled={inspSaving}>
|
|
{inspSaving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}
|
|
저장해요
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* ═══════════════════ 불량관리 모달 ═══════════════════ */}
|
|
<Dialog open={defModalOpen} onOpenChange={setDefModalOpen}>
|
|
<DialogContent className="max-w-[95vw] sm:max-w-lg max-h-[85vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>{defEditMode ? "불량유형 수정" : "불량유형 등록"}</DialogTitle>
|
|
<DialogDescription>불량유형 정보를 입력해주세요</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-semibold text-muted-foreground">불량유형</Label>
|
|
<Select value={defForm.defect_type || ""} onValueChange={(v) => setDefForm(p => ({ ...p, defect_type: v }))}>
|
|
<SelectTrigger className="h-9"><SelectValue placeholder="선택해주세요" /></SelectTrigger>
|
|
<SelectContent>
|
|
{(catOptions[`${DEFECT_TABLE}.defect_type`] || []).map(o => (
|
|
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-semibold text-muted-foreground">심각도 <span className="text-destructive">*</span></Label>
|
|
<Select value={defForm.severity || ""} onValueChange={(v) => setDefForm(p => ({ ...p, severity: v }))}>
|
|
<SelectTrigger className="h-9"><SelectValue placeholder="선택해주세요" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="Critical">Critical</SelectItem>
|
|
<SelectItem value="Major">Major</SelectItem>
|
|
<SelectItem value="Minor">Minor</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-1.5 col-span-2">
|
|
<Label className="text-xs font-semibold text-muted-foreground">불량명 <span className="text-destructive">*</span></Label>
|
|
<Input className="h-9" value={defForm.defect_name || ""} onChange={(e) => setDefForm(p => ({ ...p, defect_name: e.target.value }))} placeholder="불량명을 입력해주세요" />
|
|
</div>
|
|
<div className="space-y-1.5 col-span-2">
|
|
<div className="flex items-center gap-2">
|
|
<Checkbox checked={defForm.is_active ?? true} onCheckedChange={(v) => setDefForm(p => ({ ...p, is_active: !!v }))} />
|
|
<Label className="text-sm">사용</Label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setDefModalOpen(false)}>취소</Button>
|
|
<Button onClick={saveDefect} disabled={defSaving}>
|
|
{defSaving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}
|
|
저장해요
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* ═══════════════════ 검사장비 모달 ═══════════════════ */}
|
|
<Dialog open={eqModalOpen} onOpenChange={setEqModalOpen}>
|
|
<DialogContent className="max-w-[95vw] sm:max-w-lg max-h-[85vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>{eqEditMode ? "검사장비 수정" : "검사장비 등록"}</DialogTitle>
|
|
<DialogDescription>검사장비 정보를 입력해주세요</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1.5 col-span-2">
|
|
<Label className="text-xs font-semibold text-muted-foreground">장비명 <span className="text-destructive">*</span></Label>
|
|
<Input className="h-9" value={eqForm.equipment_name || ""} onChange={(e) => setEqForm(p => ({ ...p, equipment_name: e.target.value }))} placeholder="장비명을 입력해주세요" />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-semibold text-muted-foreground">모델명</Label>
|
|
<Input className="h-9" value={eqForm.model_name || ""} onChange={(e) => setEqForm(p => ({ ...p, model_name: e.target.value }))} placeholder="모델명" />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-semibold text-muted-foreground">제조사</Label>
|
|
<Input className="h-9" value={eqForm.manufacturer || ""} onChange={(e) => setEqForm(p => ({ ...p, manufacturer: e.target.value }))} placeholder="제조사" />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-semibold text-muted-foreground">교정주기</Label>
|
|
<Input className="h-9" value={eqForm.calibration_cycle || ""} onChange={(e) => setEqForm(p => ({ ...p, calibration_cycle: e.target.value }))} placeholder="예: 12개월" />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-semibold text-muted-foreground">최종교정일</Label>
|
|
<Input type="date" className="h-9" value={eqForm.last_calibration_date || ""} onChange={(e) => setEqForm(p => ({ ...p, last_calibration_date: e.target.value }))} />
|
|
</div>
|
|
<div className="space-y-1.5 col-span-2">
|
|
<Label className="text-xs font-semibold text-muted-foreground">장비상태</Label>
|
|
<Select value={eqForm.equipment_status || ""} onValueChange={(v) => setEqForm(p => ({ ...p, equipment_status: v }))}>
|
|
<SelectTrigger className="h-9"><SelectValue placeholder="선택해주세요" /></SelectTrigger>
|
|
<SelectContent>
|
|
{(catOptions[`${EQUIPMENT_TABLE}.equipment_status`] || []).map(o => (
|
|
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setEqModalOpen(false)}>취소</Button>
|
|
<Button onClick={saveEquipment} disabled={eqSaving}>
|
|
{eqSaving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}
|
|
저장해요
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<TableSettingsModal
|
|
open={ts.open}
|
|
onOpenChange={ts.setOpen}
|
|
tableName={ts.tableName}
|
|
settingsId={ts.settingsId}
|
|
defaultVisibleKeys={ts.defaultVisibleKeys}
|
|
onSave={ts.applySettings}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|