589 lines
31 KiB
TypeScript
589 lines
31 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 { Checkbox } from "@/components/ui/checkbox";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
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, Inbox, Settings2, Search, ChevronDown,
|
|
} 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 { useTableSettings } from "@/hooks/useTableSettings";
|
|
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
|
import { toast } from "sonner";
|
|
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
|
|
|
const TABLE_NAME = "item_inspection_info";
|
|
|
|
const GRID_COLUMNS = [
|
|
{ key: "item_code", label: "품목코드" },
|
|
{ key: "item_name", label: "품목명" },
|
|
{ key: "inspection_standard_id", label: "검사기준ID" },
|
|
{ key: "inspection_standard_name", label: "검사기준명" },
|
|
{ key: "inspection_level", label: "검사수준" },
|
|
{ key: "sampling_method", label: "샘플링방법" },
|
|
{ key: "is_active", label: "사용여부" },
|
|
];
|
|
const ITEM_TABLE = "item_info";
|
|
const INSPECTION_TABLE = "inspection_standard";
|
|
|
|
const INSPECTION_TYPES = [
|
|
{ key: "incoming_inspection", label: "입고검사", matchLabels: ["입고검사", "수입검사", "입고", "수입"] },
|
|
{ key: "outgoing_inspection", label: "출고검사", matchLabels: ["출고검사", "출하검사", "출고", "출하"] },
|
|
{ key: "inventory_inspection", label: "재고검사", matchLabels: ["재고검사", "재고"] },
|
|
{ key: "process_inspection", label: "공정검사", matchLabels: ["공정검사", "공정"] },
|
|
{ key: "final_inspection", label: "최종검사", matchLabels: ["최종검사", "최종", "완제품검사"] },
|
|
] as const;
|
|
|
|
type InspectionRow = {
|
|
id: string;
|
|
inspection_standard_id: string;
|
|
inspection_detail: string;
|
|
inspection_method: string;
|
|
apply_process: string;
|
|
acceptance_criteria: string;
|
|
is_required: boolean;
|
|
};
|
|
|
|
export default function ItemInspectionInfoPage() {
|
|
const { user } = useAuth();
|
|
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
|
const ts = useTableSettings("c16-item-inspection", TABLE_NAME, GRID_COLUMNS);
|
|
|
|
const [data, setData] = useState<any[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [totalCount, setTotalCount] = useState(0);
|
|
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
|
const [checkedIds, setCheckedIds] = useState<string[]>([]);
|
|
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
const [editMode, setEditMode] = useState(false);
|
|
const [form, setForm] = useState<Record<string, any>>({});
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
/* FK 옵션 */
|
|
const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; unit: string }[]>([]);
|
|
const [inspOptions, setInspOptions] = useState<{ code: string; label: string; detail: string; method: string; types: string[] }[]>([]);
|
|
const [inspTypeCatOptions, setInspTypeCatOptions] = useState<{ code: string; label: string }[]>([]);
|
|
const [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]);
|
|
|
|
/* 검사유형별 검사항목 rows */
|
|
const [inspectionRows, setInspectionRows] = useState<Record<string, InspectionRow[]>>({});
|
|
const [collapsedTypes, setCollapsedTypes] = useState<Record<string, boolean>>({});
|
|
|
|
/* 품목 선택 모달 */
|
|
const [itemModalOpen, setItemModalOpen] = useState(false);
|
|
const [itemSearchKeyword, setItemSearchKeyword] = useState("");
|
|
const [filteredItems, setFilteredItems] = useState<typeof itemOptions>([]);
|
|
|
|
/* ═══════════════════ FK 옵션 로드 ═══════════════════ */
|
|
useEffect(() => {
|
|
const loadOptions = async () => {
|
|
try {
|
|
const [itemRes, inspRes, userRes] = await Promise.all([
|
|
apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, { page: 1, size: 500, autoFilter: true }),
|
|
apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, { page: 1, size: 500, autoFilter: true }),
|
|
apiClient.post(`/table-management/tables/user_info/data`, { page: 1, size: 500, autoFilter: true }),
|
|
]);
|
|
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
|
|
setItemOptions(items.map((r: any) => ({
|
|
code: r.item_number || r.item_code || "",
|
|
name: r.item_name || "",
|
|
item_type: r.type || r.item_type || "",
|
|
unit: r.unit || "",
|
|
})));
|
|
|
|
const insps = inspRes.data?.data?.data || inspRes.data?.data?.rows || [];
|
|
setInspOptions(insps.map((r: any) => ({
|
|
code: r.id,
|
|
label: r.inspection_criteria || r.inspection_standard || r.id,
|
|
detail: r.inspection_item || r.inspection_criteria || "",
|
|
method: r.inspection_method || "",
|
|
types: r.inspection_type ? r.inspection_type.split(",").filter(Boolean) : [],
|
|
})));
|
|
|
|
// 검사유형 카테고리 값 로드 (코드→라벨 매핑용)
|
|
try {
|
|
const catRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/inspection_type/values`);
|
|
const flatCats: { code: string; label: string }[] = [];
|
|
const flatten = (arr: any[]) => { for (const v of arr) { flatCats.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) flatten(v.children); } };
|
|
if (catRes.data?.data?.length) flatten(catRes.data.data);
|
|
setInspTypeCatOptions(flatCats);
|
|
} catch { /* skip */ }
|
|
|
|
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
|
|
setUserOptions(users.map((u: any) => ({
|
|
code: u.user_id || u.id,
|
|
label: `${u.user_name || u.name || u.user_id}${u.dept_name ? ` (${u.dept_name})` : ""}`,
|
|
})));
|
|
} catch { /* skip */ }
|
|
};
|
|
loadOptions();
|
|
}, []);
|
|
|
|
/* ═══════════════════ 품목 선택 모달 ═══════════════════ */
|
|
const openItemModal = () => {
|
|
setItemSearchKeyword("");
|
|
setFilteredItems(itemOptions);
|
|
setItemModalOpen(true);
|
|
};
|
|
|
|
const handleItemSearch = () => {
|
|
const kw = itemSearchKeyword.trim().toLowerCase();
|
|
if (!kw) { setFilteredItems(itemOptions); return; }
|
|
setFilteredItems(itemOptions.filter(o =>
|
|
o.code.toLowerCase().includes(kw) || o.name.toLowerCase().includes(kw)
|
|
));
|
|
};
|
|
|
|
const selectItem = (item: typeof itemOptions[0]) => {
|
|
setForm(p => ({ ...p, item_code: item.code, item_name: item.name }));
|
|
setItemModalOpen(false);
|
|
};
|
|
|
|
/* ═══════════════════ 데이터 조회 ═══════════════════ */
|
|
const fetchData = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
|
|
const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/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 || [];
|
|
setData(rows);
|
|
setTotalCount(rows.length);
|
|
} catch {
|
|
toast.error("품목검사정보 조회에 실패했어요");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [searchFilters]);
|
|
|
|
useEffect(() => { fetchData(); }, [fetchData]);
|
|
|
|
/* ═══════════════════ CRUD ═══════════════════ */
|
|
const openCreate = () => { setForm({}); setEditMode(false); setInspectionRows({}); setCollapsedTypes({}); setModalOpen(true); };
|
|
const openEdit = (row: any) => {
|
|
setForm({ ...row });
|
|
setEditMode(true);
|
|
// 저장된 검사항목 rows 복원
|
|
const saved = row.inspection_items || {};
|
|
setInspectionRows(saved);
|
|
setCollapsedTypes({});
|
|
setModalOpen(true);
|
|
};
|
|
|
|
/* ═══════════════════ 검사항목 행 관리 ═══════════════════ */
|
|
const addInspRow = (typeKey: string) => {
|
|
setInspectionRows(prev => ({
|
|
...prev,
|
|
[typeKey]: [...(prev[typeKey] || []), {
|
|
id: crypto.randomUUID(),
|
|
inspection_standard_id: "",
|
|
inspection_detail: "",
|
|
inspection_method: "",
|
|
apply_process: "",
|
|
acceptance_criteria: "",
|
|
is_required: false,
|
|
}],
|
|
}));
|
|
};
|
|
|
|
const removeInspRow = (typeKey: string, rowId: string) => {
|
|
setInspectionRows(prev => ({
|
|
...prev,
|
|
[typeKey]: (prev[typeKey] || []).filter(r => r.id !== rowId),
|
|
}));
|
|
};
|
|
|
|
const updateInspRow = (typeKey: string, rowId: string, field: string, value: any) => {
|
|
setInspectionRows(prev => ({
|
|
...prev,
|
|
[typeKey]: (prev[typeKey] || []).map(r => {
|
|
if (r.id !== rowId) return r;
|
|
if (field === "inspection_standard_id") {
|
|
const opt = inspOptions.find(o => o.code === value);
|
|
return { ...r, inspection_standard_id: value, inspection_detail: opt?.detail || "", inspection_method: opt?.method || "" };
|
|
}
|
|
return { ...r, [field]: value };
|
|
}),
|
|
}));
|
|
};
|
|
|
|
/** 검사유형 키에 매칭되는 검사기준만 필터링 */
|
|
const getFilteredInspOptions = (typeKey: string) => {
|
|
const typeDef = INSPECTION_TYPES.find(t => t.key === typeKey);
|
|
if (!typeDef) return inspOptions;
|
|
// matchLabels와 카테고리 라벨을 비교하여 해당 카테고리 코드를 찾음
|
|
const matchCodes = inspTypeCatOptions
|
|
.filter(cat => typeDef.matchLabels.some(ml => cat.label.includes(ml)))
|
|
.map(cat => cat.code);
|
|
if (matchCodes.length === 0) return inspOptions;
|
|
return inspOptions.filter(opt => opt.types.some(t => matchCodes.includes(t)));
|
|
};
|
|
|
|
const toggleCollapse = (typeKey: string) => {
|
|
setCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] }));
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
if (!form.item_code) { toast.error("품목코드는 필수 입력이에요"); return; }
|
|
const saveData = { ...form, inspection_items: inspectionRows };
|
|
setSaving(true);
|
|
try {
|
|
if (editMode) {
|
|
await apiClient.put(`/table-management/tables/${TABLE_NAME}/edit`, {
|
|
originalData: { id: form.id }, updatedData: saveData,
|
|
});
|
|
toast.success("품목검사정보를 수정했어요");
|
|
} else {
|
|
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, { id: crypto.randomUUID(), ...saveData });
|
|
toast.success("품목검사정보를 등록했어요");
|
|
}
|
|
setModalOpen(false);
|
|
fetchData();
|
|
} catch { toast.error("저장에 실패했어요"); }
|
|
finally { setSaving(false); }
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
if (checkedIds.length === 0) { toast.error("삭제할 항목을 선택해주세요"); return; }
|
|
const ok = await confirm("품목검사정보 삭제", {
|
|
description: `선택한 ${checkedIds.length}건을 삭제할까요? 이 작업은 되돌릴 수 없어요.`,
|
|
});
|
|
if (!ok) return;
|
|
try {
|
|
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, {
|
|
data: checkedIds.map(id => ({ id })),
|
|
});
|
|
toast.success(`${checkedIds.length}건을 삭제했어요`);
|
|
setCheckedIds([]);
|
|
fetchData();
|
|
} catch { toast.error("삭제에 실패했어요"); }
|
|
};
|
|
|
|
/* ═══════════════════ JSX ═══════════════════ */
|
|
return (
|
|
<div className="flex flex-col gap-3 p-3">
|
|
{ConfirmDialogComponent}
|
|
|
|
<div className="rounded-lg border bg-card">
|
|
<div className="px-3 py-2.5 border-b bg-muted/50">
|
|
<DynamicSearchFilter
|
|
tableName={TABLE_NAME}
|
|
filterId="c16-item-inspection"
|
|
onFilterChange={setSearchFilters}
|
|
externalFilterConfig={ts.filterConfig}
|
|
dataCount={totalCount}
|
|
extraActions={
|
|
<div className="flex items-center gap-2">
|
|
<Button size="sm" onClick={openCreate}><Plus className="w-4 h-4 mr-1" />등록</Button>
|
|
<Button size="sm" variant="outline" onClick={() => {
|
|
const sel = data.find(r => checkedIds.includes(r.id));
|
|
if (sel) openEdit(sel);
|
|
else toast.error("수정할 항목을 선택해주세요");
|
|
}}><Pencil className="w-4 h-4 mr-1" />수정</Button>
|
|
<Button size="sm" variant="destructive" onClick={handleDelete}><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="p-3">
|
|
<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={data.length > 0 && checkedIds.length === data.length}
|
|
onCheckedChange={(v) => setCheckedIds(v ? data.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>
|
|
{loading ? (
|
|
<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>
|
|
) : data.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>
|
|
) : data.map((row) => (
|
|
<TableRow
|
|
key={row.id}
|
|
className={cn("cursor-pointer", checkedIds.includes(row.id) && "bg-primary/5")}
|
|
onClick={() => setCheckedIds(prev => prev.includes(row.id) ? prev.filter(id => id !== row.id) : [...prev, row.id])}
|
|
onDoubleClick={() => openEdit(row)}
|
|
>
|
|
<TableCell onClick={(e) => e.stopPropagation()}>
|
|
<Checkbox checked={checkedIds.includes(row.id)} onCheckedChange={(v) => setCheckedIds(prev => v ? [...prev, row.id] : prev.filter(id => id !== row.id))} />
|
|
</TableCell>
|
|
{ts.visibleColumns.map((col) => (
|
|
<TableCell key={col.key} style={ts.thStyle(col.key)}>
|
|
{col.key === "is_active"
|
|
? <Badge variant={row.is_active ? "default" : "secondary"} className="text-xs">{row.is_active ? "사용" : "미사용"}</Badge>
|
|
: row[col.key] ?? ""}
|
|
</TableCell>
|
|
))}
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ═══════════════════ 등록/수정 모달 (품목선택 뷰 포함) ═══════════════════ */}
|
|
<Dialog open={modalOpen} onOpenChange={(open) => { if (!open) setItemModalOpen(false); setModalOpen(open); }}>
|
|
<DialogContent className={cn("max-w-[95vw] max-h-[85vh] overflow-y-auto", itemModalOpen ? "sm:max-w-xl" : "sm:max-w-4xl")}>
|
|
{itemModalOpen ? (
|
|
<>
|
|
<DialogHeader>
|
|
<DialogTitle>품목 선택</DialogTitle>
|
|
<DialogDescription>품목코드 또는 품목명으로 검색</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
className="h-9 flex-1"
|
|
placeholder="품목코드 또는 품목명으로 검색"
|
|
value={itemSearchKeyword}
|
|
onChange={(e) => setItemSearchKeyword(e.target.value)}
|
|
onKeyDown={(e) => { if (e.key === "Enter") handleItemSearch(); }}
|
|
/>
|
|
<Button size="sm" className="h-9" onClick={handleItemSearch}>
|
|
<Search className="w-4 h-4 mr-1" />검색
|
|
</Button>
|
|
</div>
|
|
<div className="border rounded-lg overflow-auto max-h-[50vh]">
|
|
<Table>
|
|
<TableHeader className="sticky top-0 z-10">
|
|
<TableRow className="bg-muted hover:bg-muted">
|
|
<TableHead className="text-[11px] font-bold">품목코드</TableHead>
|
|
<TableHead className="text-[11px] font-bold">품목명</TableHead>
|
|
<TableHead className="text-[11px] font-bold">품목유형</TableHead>
|
|
<TableHead className="text-[11px] font-bold">단위</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{filteredItems.length === 0 ? (
|
|
<TableRow><TableCell colSpan={4} className="text-center py-8 text-muted-foreground text-sm">검색 결과가 없어요</TableCell></TableRow>
|
|
) : filteredItems.map((item) => (
|
|
<TableRow
|
|
key={item.code}
|
|
className="cursor-pointer hover:bg-primary/5"
|
|
onClick={() => selectItem(item)}
|
|
>
|
|
<TableCell className="text-sm">{item.code}</TableCell>
|
|
<TableCell className="text-sm">{item.name}</TableCell>
|
|
<TableCell className="text-sm">{item.item_type}</TableCell>
|
|
<TableCell className="text-sm">{item.unit}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setItemModalOpen(false)}>취소</Button>
|
|
</DialogFooter>
|
|
</>
|
|
) : (
|
|
<>
|
|
<DialogHeader>
|
|
<DialogTitle>{editMode ? "품목검사정보 수정" : "품목검사정보 등록"}</DialogTitle>
|
|
<DialogDescription className="sr-only">품목검사정보를 등록합니다</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-4">
|
|
{/* 품목 정보 */}
|
|
<div className="space-y-3">
|
|
<h4 className="text-sm font-semibold flex items-center gap-1.5">📦 품목 정보</h4>
|
|
<div className="flex items-end gap-2">
|
|
<div className="space-y-1.5 flex-1">
|
|
<Label className="text-xs font-semibold text-muted-foreground">품목코드 <span className="text-destructive">*</span></Label>
|
|
<Input className="h-9 bg-muted" value={form.item_code || ""} readOnly placeholder="품목코드" />
|
|
</div>
|
|
<div className="space-y-1.5 flex-1">
|
|
<Label className="text-xs font-semibold text-muted-foreground">품목명 <span className="text-destructive">*</span></Label>
|
|
<Input className="h-9 bg-muted" value={form.item_name || ""} readOnly placeholder="품목명" />
|
|
</div>
|
|
<Button type="button" variant="outline" size="sm" className="h-9 px-3 shrink-0" onClick={openItemModal}>
|
|
<Search className="w-4 h-4 mr-1" />품목선택
|
|
</Button>
|
|
</div>
|
|
<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={form.is_active === false ? "N" : "Y"} onValueChange={(v) => setForm(p => ({ ...p, is_active: v === "Y" }))}>
|
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="Y">사용</SelectItem>
|
|
<SelectItem value="N">미사용</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-semibold text-muted-foreground">관리자</Label>
|
|
<Select value={form.manager || ""} onValueChange={(v) => setForm(p => ({ ...p, manager: v }))}>
|
|
<SelectTrigger className="h-9"><SelectValue placeholder="관리자 선택" /></SelectTrigger>
|
|
<SelectContent>
|
|
{userOptions.map(o => (
|
|
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-semibold text-muted-foreground">비고</Label>
|
|
<textarea
|
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm min-h-[70px] resize-none focus:outline-none focus:ring-2 focus:ring-ring"
|
|
value={form.remarks || ""}
|
|
onChange={(e) => setForm(p => ({ ...p, remarks: e.target.value }))}
|
|
placeholder="비고 사항"
|
|
/>
|
|
</div>
|
|
</div>
|
|
{/* 검사유형 선택 */}
|
|
<div className="space-y-3">
|
|
<h4 className="text-sm font-semibold flex items-center gap-1.5">✅ 검사유형 선택</h4>
|
|
<div className="flex flex-wrap gap-4">
|
|
{INSPECTION_TYPES.map(({ key, label }) => (
|
|
<div key={key} className="flex items-center gap-1.5">
|
|
<Checkbox
|
|
checked={!!form[key]}
|
|
onCheckedChange={(v) => setForm(p => ({ ...p, [key]: !!v }))}
|
|
/>
|
|
<Label className="text-sm cursor-pointer">{label}</Label>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
{/* 검사유형별 검사항목 설정 */}
|
|
{INSPECTION_TYPES.filter(t => !!form[t.key]).map(({ key, label }) => (
|
|
<div key={key} className="space-y-2">
|
|
<button
|
|
type="button"
|
|
className="w-full flex items-center gap-2 py-2 px-3 rounded-md border bg-muted/50 hover:bg-muted text-left"
|
|
onClick={() => toggleCollapse(key)}
|
|
>
|
|
<Badge variant="default" className="text-xs">{label}</Badge>
|
|
<span className="text-sm font-medium">검사항목 설정</span>
|
|
<ChevronDown className={cn("w-4 h-4 ml-auto transition-transform", collapsedTypes[key] && "-rotate-90")} />
|
|
</button>
|
|
{!collapsedTypes[key] && (
|
|
<div className="space-y-2 pl-1">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs font-semibold text-muted-foreground">검사항목 목록</span>
|
|
<Button type="button" size="sm" variant="outline" className="h-7 text-xs" onClick={() => addInspRow(key)}>
|
|
<Plus className="w-3 h-3 mr-1" />항목추가
|
|
</Button>
|
|
</div>
|
|
<div className="border rounded-lg overflow-hidden">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow className="bg-muted/50 hover:bg-muted/50">
|
|
<TableHead className="text-[10px] font-bold w-[170px]">검사기준 선택</TableHead>
|
|
<TableHead className="text-[10px] font-bold w-[130px]">검사기준 상세</TableHead>
|
|
<TableHead className="text-[10px] font-bold w-[90px]">검사방법</TableHead>
|
|
<TableHead className="text-[10px] font-bold w-[100px]">적용공정</TableHead>
|
|
<TableHead className="text-[10px] font-bold">합격기준 (판단기준별)</TableHead>
|
|
<TableHead className="text-[10px] font-bold w-[40px]">필수</TableHead>
|
|
<TableHead className="text-[10px] font-bold w-[36px]" />
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{(!inspectionRows[key] || inspectionRows[key].length === 0) ? (
|
|
<TableRow><TableCell colSpan={7} className="text-center py-4 text-xs text-muted-foreground">항목추가 버튼으로 검사항목을 추가하세요</TableCell></TableRow>
|
|
) : inspectionRows[key].map((row) => (
|
|
<TableRow key={row.id}>
|
|
<TableCell className="p-1">
|
|
<Select value={row.inspection_standard_id || ""} onValueChange={(v) => updateInspRow(key, row.id, "inspection_standard_id", v)}>
|
|
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="검사기준 선택" /></SelectTrigger>
|
|
<SelectContent>
|
|
{getFilteredInspOptions(key).map(o => (
|
|
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</TableCell>
|
|
<TableCell className="p-1">
|
|
<Input className="h-8 text-xs bg-muted" value={row.inspection_detail} readOnly placeholder="검사기준 상세" />
|
|
</TableCell>
|
|
<TableCell className="p-1">
|
|
<Input className="h-8 text-xs bg-muted" value={row.inspection_method} readOnly placeholder="선택" />
|
|
</TableCell>
|
|
<TableCell className="p-1">
|
|
<Select value={row.apply_process || ""} onValueChange={(v) => updateInspRow(key, row.id, "apply_process", v)}>
|
|
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="공정 선택" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="incoming">입고</SelectItem>
|
|
<SelectItem value="process">공정</SelectItem>
|
|
<SelectItem value="outgoing">출고</SelectItem>
|
|
<SelectItem value="final">최종</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</TableCell>
|
|
<TableCell className="p-1">
|
|
<Input
|
|
className="h-8 text-xs"
|
|
value={row.acceptance_criteria}
|
|
onChange={(e) => updateInspRow(key, row.id, "acceptance_criteria", e.target.value)}
|
|
placeholder={row.inspection_standard_id ? "합격기준 입력" : "검사기준을 먼저 선택하세요"}
|
|
disabled={!row.inspection_standard_id}
|
|
/>
|
|
</TableCell>
|
|
<TableCell className="p-1 text-center">
|
|
<Checkbox checked={row.is_required} onCheckedChange={(v) => updateInspRow(key, row.id, "is_required", !!v)} />
|
|
</TableCell>
|
|
<TableCell className="p-1">
|
|
<Button type="button" variant="destructive" size="sm" className="h-7 w-7 p-0" onClick={() => removeInspRow(key, row.id)}>
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setModalOpen(false)}>취소</Button>
|
|
<Button onClick={handleSave} disabled={saving}>
|
|
{saving ? <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>
|
|
);
|
|
}
|