Files
pipeline/frontend/app/(main)/COMPANY_7/quality/item-inspection/page.tsx
T
kjs 2f50d7d809 fix: Enhance file handling and inspection method mapping
- Updated fileController to include Cross-Origin-Resource-Policy headers for improved security and file handling.
- Added error handling for file streams to ensure robust responses in case of read errors.
- Modified materialStatusController to correctly map material IDs to their respective codes for inventory stock queries.
- Enhanced moldController to include warranty shot count in mold creation and update processes.
- Improved item inspection page by adding inspection method category loading and mapping, ensuring accurate display of method labels in the UI.

These changes aim to enhance the overall functionality and user experience across multiple companies by ensuring proper file handling, data mapping, and error management.
2026-04-10 15:59:38 +09:00

767 lines
40 KiB
TypeScript

"use client";
import React, { useState, useEffect, useCallback, useMemo } 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";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
const TABLE_NAME = "item_inspection_info";
const GRID_COLUMNS = [
{ key: "item_code", label: "품목코드" },
{ key: "item_name", label: "품목명" },
{ key: "inspection_type", label: "검사유형" },
{ key: "item_count", 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: "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 [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
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 [inspMethodCatOptions, setInspMethodCatOptions] = 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 */ }
// 검사방법 카테고리 값 로드 (코드→라벨 매핑용)
try {
const methodRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/inspection_method/values`);
const flatMethods: { code: string; label: string }[] = [];
const flattenM = (arr: any[]) => { for (const v of arr) { flatMethods.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) flattenM(v.children); } };
if (methodRes.data?.data?.length) flattenM(methodRes.data.data);
setInspMethodCatOptions(flatMethods);
} 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]);
// item_code별 그룹핑
const groupedData = useMemo(() => {
const map: Record<string, { item_code: string; item_name: string; is_active: string; types: string[]; rows: any[] }> = {};
for (const row of data) {
const key = row.item_code || row.id;
if (!map[key]) {
map[key] = { item_code: row.item_code, item_name: row.item_name, is_active: row.is_active || "", types: [], rows: [] };
}
map[key].rows.push(row);
if (row.inspection_type && !map[key].types.includes(row.inspection_type)) {
map[key].types.push(row.inspection_type);
}
}
return Object.values(map);
}, [data]);
// 검사기준 ID → 라벨 resolve
const resolveInspLabel = useCallback((id: string) => {
const opt = inspOptions.find(o => o.code === id);
return opt?.label || id || "-";
}, [inspOptions]);
/* ═══════════════════ CRUD ═══════════════════ */
const openCreate = () => { setForm({}); setEditMode(false); setInspectionRows({}); setCollapsedTypes({}); setModalOpen(true); };
const openEdit = async (row: any) => {
setForm({ ...row });
setEditMode(true);
setCollapsedTypes({});
// 같은 item_code의 모든 검사항목 행을 조회하여 유형별로 분류
try {
const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: row.item_code }] },
autoFilter: true,
});
const allRows = res.data?.data?.data || res.data?.data?.rows || [];
const rowMap: Record<string, InspectionRow[]> = {};
const typeFlags: Record<string, boolean> = {};
for (const r of allRows) {
const inspType = r.inspection_type || "";
// 카테고리 코드/라벨로 INSPECTION_TYPES 키 매칭
const matched = INSPECTION_TYPES.find(t =>
t.matchLabels.some(ml => inspType.includes(ml)) ||
inspTypeCatOptions.some(cat => inspType.includes(cat.code) && t.matchLabels.some(ml => cat.label.includes(ml)))
);
const typeKey = matched?.key || "";
if (!typeKey) continue;
typeFlags[typeKey] = true;
if (!rowMap[typeKey]) rowMap[typeKey] = [];
const mCode = r.inspection_method || "";
const mLabel = inspMethodCatOptions.find(o => o.code === mCode)?.label || mCode;
rowMap[typeKey].push({
id: r.id,
inspection_standard_id: r.inspection_standard_id || "",
inspection_detail: r.inspection_item_name || r.inspection_standard || "",
inspection_method: mLabel,
apply_process: "",
acceptance_criteria: r.pass_criteria || "",
is_required: r.is_required === "true" || r.is_required === true,
});
}
setInspectionRows(rowMap);
setForm(p => ({ ...p, ...typeFlags }));
} catch {
setInspectionRows({});
}
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);
const methodCode = opt?.method || "";
const methodLabel = inspMethodCatOptions.find(o => o.code === methodCode)?.label || methodCode;
return { ...r, inspection_standard_id: value, inspection_detail: opt?.detail || "", inspection_method: methodLabel };
}
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; }
setSaving(true);
try {
// 기존 행 삭제 (수정 모드)
if (editMode) {
const existRes = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: form.item_code }] },
autoFilter: true,
});
const existing = existRes.data?.data?.data || existRes.data?.data?.rows || [];
if (existing.length > 0) {
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, {
data: existing.map((r: any) => ({ id: r.id })),
});
}
}
// 검사유형별 항목을 개별 행으로 INSERT
const enabledTypes = INSPECTION_TYPES.filter(t => !!form[t.key]);
const rows: any[] = [];
for (const t of enabledTypes) {
const typeLabel = t.label;
const typeRows = inspectionRows[t.key] || [];
if (typeRows.length === 0) {
// 유형만 체크하고 항목 없는 경우에도 1행 생성
rows.push({
id: crypto.randomUUID(),
item_code: form.item_code,
item_name: form.item_name,
inspection_type: typeLabel,
is_active: form.is_active || "사용",
manager_id: form.manager_id || "",
memo: form.remarks || "",
});
} else {
for (const r of typeRows) {
rows.push({
id: crypto.randomUUID(),
item_code: form.item_code,
item_name: form.item_name,
inspection_type: typeLabel,
inspection_standard_id: r.inspection_standard_id || "",
inspection_item_name: r.inspection_detail || "",
inspection_method: r.inspection_method || "",
pass_criteria: r.acceptance_criteria || "",
is_required: r.is_required ? "true" : "false",
is_active: form.is_active || "사용",
manager_id: form.manager_id || "",
memo: form.remarks || "",
});
}
}
}
for (const row of rows) {
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, row);
}
toast.success(editMode ? "품목검사정보를 수정했어요" : "품목검사정보를 등록했어요");
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) {
const group = groupedData.find(g => g.item_code === sel.item_code);
openEdit(group?.rows[0] || 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 overflow-auto">
{loading ? (
<div className="flex items-center justify-center py-16"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : groupedData.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Inbox className="h-10 w-10 mb-2 opacity-30" />
<p className="text-sm"> </p>
</div>
) : (
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-10" />
<TableHead className="w-10"><Checkbox checked={groupedData.length > 0 && checkedIds.length === data.length} onCheckedChange={(v) => setCheckedIds(v ? data.map(r => r.id) : [])} /></TableHead>
{ts.visibleColumns.map((col) => (
<TableHead key={col.key} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground" style={ts.thStyle(col.key)}>
{col.label}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{ts.groupData(groupedData).map((group) => {
if ((group as any)._isGroupSummary || (group as any)._isGroupHeader) return null;
const isExpanded = expandedItems.has(group.item_code);
const groupIds = group.rows.map((r: any) => r.id);
const allChecked = groupIds.every((id: string) => checkedIds.includes(id));
const renderCell = (key: string) => {
switch (key) {
case "item_code": return <TableCell key={key} className="text-sm font-medium text-primary">{group.item_code}</TableCell>;
case "item_name": return <TableCell key={key} className="text-sm">{group.item_name}</TableCell>;
case "inspection_type": return (
<TableCell key={key}>
<div className="flex gap-1 flex-wrap">
{group.types.map((t: string) => <Badge key={t} variant="secondary" className="text-[10px]">{t}</Badge>)}
</div>
</TableCell>
);
case "item_count": return <TableCell key={key} className="text-sm text-center">{group.rows.filter((r: any) => r.inspection_standard_id).length}</TableCell>;
case "is_active": return (
<TableCell key={key}>
<Badge variant={group.is_active === "사용" || group.is_active === "true" ? "default" : "secondary"} className="text-[10px]">
{group.is_active === "사용" || group.is_active === "true" ? "사용" : "미사용"}
</Badge>
</TableCell>
);
default: return <TableCell key={key}>{(group as any)[key] ?? ""}</TableCell>;
}
};
return (
<React.Fragment key={group.item_code}>
<TableRow
className={cn("cursor-pointer border-l-2 border-l-transparent", allChecked && "border-l-primary bg-primary/5")}
onClick={() => setExpandedItems(prev => { const next = new Set(prev); if (next.has(group.item_code)) next.delete(group.item_code); else next.add(group.item_code); return next; })}
onDoubleClick={() => openEdit(group.rows[0])}
>
<TableCell className="text-center p-2">
<ChevronDown className={cn("w-4 h-4 transition-transform text-muted-foreground", !isExpanded && "-rotate-90")} />
</TableCell>
<TableCell className="text-center p-2" onClick={(e) => { e.stopPropagation(); setCheckedIds(prev => allChecked ? prev.filter(id => !groupIds.includes(id)) : [...new Set([...prev, ...groupIds])]); }}>
<Checkbox checked={allChecked} onCheckedChange={() => {}} />
</TableCell>
{ts.visibleColumns.map((col) => renderCell(col.key))}
</TableRow>
{isExpanded && (
<TableRow className="bg-muted/30">
<TableCell />
<TableCell />
<TableCell colSpan={ts.visibleColumns.length} className="p-0">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="text-[10px] font-bold h-7"></TableHead>
<TableHead className="text-[10px] font-bold h-7"></TableHead>
<TableHead className="text-[10px] font-bold h-7"></TableHead>
<TableHead className="text-[10px] font-bold h-7"></TableHead>
<TableHead className="text-[10px] font-bold h-7"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{group.rows.filter((r: any) => r.inspection_standard_id).map((row: any) => (
<TableRow key={row.id} className="text-xs">
<TableCell className="py-1.5 text-muted-foreground">{row.inspection_type}</TableCell>
<TableCell className="py-1.5">{resolveInspLabel(row.inspection_standard_id)}</TableCell>
<TableCell className="py-1.5">{row.inspection_item_name || "-"}</TableCell>
<TableCell className="py-1.5">{row.inspection_method || "-"}</TableCell>
<TableCell className="py-1.5">{row.pass_criteria || "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableCell>
</TableRow>
)}
</React.Fragment>
);
})}
</TableBody>
</Table>
)}
<div className="mt-2 text-xs text-muted-foreground"> {groupedData.length} ( )</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>
);
}