refactor: Enhance item inspection page functionality and UI
- Added resizable panel components to improve layout flexibility. - Updated item name label from "품목명" to "품명" for consistency. - Refactored state management for selected items and inspection types to enhance user interaction. - Improved modal handling and search functionality for item selection. - Enhanced inspection method and category loading to ensure accurate data representation. These changes aim to provide a better user experience and streamline the item inspection process across multiple companies.
This commit is contained in:
@@ -9,8 +9,9 @@ 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 { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import {
|
||||
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ChevronDown,
|
||||
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ClipboardList,
|
||||
} from "lucide-react";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -20,19 +21,17 @@ 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 ITEM_TABLE = "item_info";
|
||||
const INSPECTION_TABLE = "inspection_standard";
|
||||
|
||||
const GRID_COLUMNS = [
|
||||
{ key: "item_code", label: "품목코드" },
|
||||
{ key: "item_name", 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: ["수입검사", "입고검사", "수입", "입고"] },
|
||||
@@ -61,25 +60,29 @@ export default function ItemInspectionInfoPage() {
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
const [checkedIds, setCheckedIds] = useState<string[]>([]);
|
||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
|
||||
|
||||
// 우측 패널: 선택된 품목
|
||||
const [selectedItemCode, setSelectedItemCode] = useState<string | null>(null);
|
||||
const [selectedTypeTab, setSelectedTypeTab] = 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 옵션 */
|
||||
// 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 */
|
||||
// 검사유형별 검사항목 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>([]);
|
||||
@@ -110,7 +113,7 @@ export default function ItemInspectionInfoPage() {
|
||||
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 }[] = [];
|
||||
@@ -119,7 +122,7 @@ export default function ItemInspectionInfoPage() {
|
||||
setInspTypeCatOptions(flatCats);
|
||||
} catch { /* skip */ }
|
||||
|
||||
// 검사방법 카테고리 값 로드 (코드→라벨 매핑용)
|
||||
// 검사방법 카테고리
|
||||
try {
|
||||
const methodRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/inspection_method/values`);
|
||||
const flatMethods: { code: string; label: string }[] = [];
|
||||
@@ -139,20 +142,12 @@ export default function ItemInspectionInfoPage() {
|
||||
}, []);
|
||||
|
||||
/* ═══════════════════ 품목 선택 모달 ═══════════════════ */
|
||||
const openItemModal = () => {
|
||||
setItemSearchKeyword("");
|
||||
setFilteredItems(itemOptions);
|
||||
setItemModalOpen(true);
|
||||
};
|
||||
|
||||
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)
|
||||
));
|
||||
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);
|
||||
@@ -196,24 +191,45 @@ export default function ItemInspectionInfoPage() {
|
||||
return Object.values(map);
|
||||
}, [data]);
|
||||
|
||||
// 검사기준 ID → 라벨 resolve
|
||||
// 선택된 품목의 그룹 데이터
|
||||
const selectedGroup = useMemo(() => {
|
||||
if (!selectedItemCode) return null;
|
||||
return groupedData.find(g => g.item_code === selectedItemCode) || null;
|
||||
}, [selectedItemCode, groupedData]);
|
||||
|
||||
// 선택된 탭의 검사항목 행
|
||||
const selectedTabRows = useMemo(() => {
|
||||
if (!selectedGroup || !selectedTypeTab) return [];
|
||||
return selectedGroup.rows.filter((r: any) => r.inspection_type === selectedTypeTab && r.inspection_standard_id);
|
||||
}, [selectedGroup, selectedTypeTab]);
|
||||
|
||||
// 검사기준 ID → 라벨
|
||||
const resolveInspLabel = useCallback((id: string) => {
|
||||
const opt = inspOptions.find(o => o.code === id);
|
||||
return opt?.label || id || "-";
|
||||
return inspOptions.find(o => o.code === id)?.label || id || "-";
|
||||
}, [inspOptions]);
|
||||
|
||||
// 검사방법 코드 → 라벨
|
||||
const resolveMethodLabel = useCallback((code: string) => {
|
||||
return inspMethodCatOptions.find(o => o.code === code)?.label || code || "-";
|
||||
}, [inspMethodCatOptions]);
|
||||
|
||||
/* ═══════════════════ CRUD ═══════════════════ */
|
||||
const openCreate = () => { setForm({}); setEditMode(false); setInspectionRows({}); setCollapsedTypes({}); setModalOpen(true); };
|
||||
const openEdit = async (row: any) => {
|
||||
|
||||
const openEdit = async (itemCode?: string) => {
|
||||
const code = itemCode || selectedItemCode;
|
||||
if (!code) { toast.error("수정할 항목을 선택해주세요"); return; }
|
||||
const group = groupedData.find(g => g.item_code === code);
|
||||
if (!group) return;
|
||||
const row = group.rows[0];
|
||||
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 }] },
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: code }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const allRows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
@@ -222,7 +238,6 @@ export default function ItemInspectionInfoPage() {
|
||||
|
||||
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)))
|
||||
@@ -243,38 +258,22 @@ export default function ItemInspectionInfoPage() {
|
||||
is_required: r.is_required === "true" || r.is_required === true,
|
||||
});
|
||||
}
|
||||
|
||||
setInspectionRows(rowMap);
|
||||
setForm(p => ({ ...p, ...typeFlags }));
|
||||
} catch {
|
||||
setInspectionRows({});
|
||||
}
|
||||
} 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,
|
||||
}],
|
||||
[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),
|
||||
}));
|
||||
setInspectionRows(prev => ({ ...prev, [typeKey]: (prev[typeKey] || []).filter(r => r.id !== rowId) }));
|
||||
};
|
||||
|
||||
const updateInspRow = (typeKey: string, rowId: string, field: string, value: any) => {
|
||||
setInspectionRows(prev => ({
|
||||
...prev,
|
||||
@@ -290,28 +289,19 @@ export default function ItemInspectionInfoPage() {
|
||||
}),
|
||||
}));
|
||||
};
|
||||
|
||||
/** 검사유형 키에 매칭되는 검사기준만 필터링 */
|
||||
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);
|
||||
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 toggleCollapse = (typeKey: string) => { setCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] })); };
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.item_code) { toast.error("품목코드는 필수 입력이에요"); return; }
|
||||
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,
|
||||
@@ -320,54 +310,29 @@ export default function ItemInspectionInfoPage() {
|
||||
});
|
||||
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 })),
|
||||
});
|
||||
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 || "",
|
||||
});
|
||||
rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, 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 || "",
|
||||
id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label,
|
||||
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 ? "품목검사정보를 수정했어요" : "품목검사정보를 등록했어요");
|
||||
for (const row of rows) { await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, row); }
|
||||
toast.success(editMode ? "수정했어요" : "등록했어요");
|
||||
setModalOpen(false);
|
||||
fetchData();
|
||||
} catch { toast.error("저장에 실패했어요"); }
|
||||
@@ -375,157 +340,231 @@ export default function ItemInspectionInfoPage() {
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (checkedIds.length === 0) { toast.error("삭제할 항목을 선택해주세요"); return; }
|
||||
const ok = await confirm("품목검사정보 삭제", {
|
||||
description: `선택한 ${checkedIds.length}건을 삭제할까요? 이 작업은 되돌릴 수 없어요.`,
|
||||
});
|
||||
if (!selectedItemCode) { toast.error("삭제할 품목을 선택해주세요"); return; }
|
||||
const group = groupedData.find(g => g.item_code === selectedItemCode);
|
||||
if (!group) return;
|
||||
const ok = await confirm(`${selectedItemCode} 검사정보 삭제`, { description: "선택한 품목의 검사정보를 모두 삭제할까요?", variant: "destructive", confirmText: "삭제" });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, {
|
||||
data: checkedIds.map(id => ({ id })),
|
||||
});
|
||||
toast.success(`${checkedIds.length}건을 삭제했어요`);
|
||||
setCheckedIds([]);
|
||||
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: group.rows.map((r: any) => ({ id: r.id })) });
|
||||
toast.success("삭제했어요");
|
||||
setSelectedItemCode(null);
|
||||
fetchData();
|
||||
} catch { toast.error("삭제에 실패했어요"); }
|
||||
};
|
||||
|
||||
/* ═══════════════════ JSX ═══════════════════ */
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<div className="flex h-full flex-col">
|
||||
{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 className="shrink-0 px-3 pt-3 pb-2">
|
||||
<DynamicSearchFilter
|
||||
tableName={TABLE_NAME}
|
||||
filterId="c16-item-inspection"
|
||||
onFilterChange={setSearchFilters}
|
||||
externalFilterConfig={ts.filterConfig}
|
||||
dataCount={totalCount}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ═══════════════════ 등록/수정 모달 (품목선택 뷰 포함) ═══════════════════ */}
|
||||
{/* 좌우 분할 패널 */}
|
||||
<div className="flex-1 min-h-0 px-3 pb-3">
|
||||
<ResizablePanelGroup direction="horizontal" className="h-full rounded-lg border">
|
||||
{/* ═══════ 좌측: 품목 목록 ═══════ */}
|
||||
<ResizablePanel defaultSize={50} minSize={30}>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="shrink-0 flex items-center justify-between px-4 py-3 border-b bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardList className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-semibold">품목 목록</span>
|
||||
<Badge variant="secondary" className="text-[10px]">{groupedData.length}건</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button size="sm" className="h-7 text-xs" onClick={openCreate}><Plus className="w-3.5 h-3.5 mr-1" />등록</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" />삭제</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => ts.setOpen(true)}><Settings2 className="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 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">
|
||||
{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>
|
||||
{groupedData.map((group) => (
|
||||
<TableRow
|
||||
key={group.item_code}
|
||||
className={cn("cursor-pointer", selectedItemCode === group.item_code ? "bg-primary/10 border-l-2 border-l-primary" : "hover:bg-muted/50")}
|
||||
onClick={() => {
|
||||
setSelectedItemCode(group.item_code);
|
||||
setSelectedTypeTab(group.types[0] || "");
|
||||
}}
|
||||
>
|
||||
{ts.visibleColumns.map((col) => {
|
||||
switch (col.key) {
|
||||
case "item_code": return <TableCell key={col.key} className="text-sm font-medium text-primary">{group.item_code}</TableCell>;
|
||||
case "item_name": return <TableCell key={col.key} className="text-sm">{group.item_name}</TableCell>;
|
||||
case "inspection_type": return (
|
||||
<TableCell key={col.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 "is_active": return (
|
||||
<TableCell key={col.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={col.key}>{(group as any)[col.key] ?? ""}</TableCell>;
|
||||
}
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 px-4 py-2 border-t text-xs text-muted-foreground">
|
||||
전체 {groupedData.length}건 (품목 기준)
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* ═══════ 우측: 검사유형별 검사항목 ═══════ */}
|
||||
<ResizablePanel defaultSize={50} minSize={30}>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="shrink-0 flex items-center justify-between px-4 py-3 border-b bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold">검사유형별 검사항목</span>
|
||||
</div>
|
||||
{selectedGroup && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" />삭제</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!selectedGroup ? (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center space-y-2">
|
||||
<ClipboardList className="h-8 w-8 mx-auto opacity-30" />
|
||||
<p className="text-sm">좌측에서 품목을 선택해주세요</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
{/* 검사유형 탭 */}
|
||||
<div className="shrink-0 flex items-center gap-2 px-4 py-3">
|
||||
{selectedGroup.types.map((type: string) => {
|
||||
const count = selectedGroup.rows.filter((r: any) => r.inspection_type === type && r.inspection_standard_id).length;
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => setSelectedTypeTab(type)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-colors border",
|
||||
selectedTypeTab === type
|
||||
? "bg-primary text-primary-foreground border-primary"
|
||||
: "bg-muted/50 text-muted-foreground border-border hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
{type}
|
||||
<span className={cn(
|
||||
"inline-flex items-center justify-center h-4 min-w-[16px] rounded-full text-[10px] font-bold px-1",
|
||||
selectedTypeTab === type ? "bg-primary-foreground/20 text-primary-foreground" : "bg-muted-foreground/20 text-muted-foreground"
|
||||
)}>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 검사항목 상세 테이블 */}
|
||||
<div className="flex-1 min-h-0 overflow-auto px-4 pb-4">
|
||||
{selectedTypeTab && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="default" className="text-xs">{selectedTypeTab}</Badge>
|
||||
<span className="text-sm font-medium">검사항목 상세</span>
|
||||
</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 h-8">검사항목</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8">검사기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8">검사방법</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8">적용공정</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8">판단기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8">합격기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8 w-[50px]">필수</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{selectedTabRows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-8 text-xs text-muted-foreground">등록된 검사항목이 없어요</TableCell>
|
||||
</TableRow>
|
||||
) : selectedTabRows.map((row: any) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="text-xs py-2">{row.inspection_item_name || "-"}</TableCell>
|
||||
<TableCell className="text-xs py-2">{resolveInspLabel(row.inspection_standard_id)}</TableCell>
|
||||
<TableCell className="text-xs py-2">{resolveMethodLabel(row.inspection_method)}</TableCell>
|
||||
<TableCell className="text-xs py-2">{row.apply_process || "-"}</TableCell>
|
||||
<TableCell className="text-xs py-2">
|
||||
{row.judgment_criteria ? (
|
||||
<Badge variant="outline" className="text-[10px]">{row.judgment_criteria}</Badge>
|
||||
) : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs py-2 font-mono">{row.pass_criteria || "-"}</TableCell>
|
||||
<TableCell className="text-xs py-2 text-center">
|
||||
{row.is_required === "true" || row.is_required === true ? (
|
||||
<Badge variant="destructive" className="text-[9px]">필수</Badge>
|
||||
) : "-"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</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 ? (
|
||||
@@ -535,16 +574,8 @@ export default function ItemInspectionInfoPage() {
|
||||
<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>
|
||||
<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>
|
||||
@@ -560,11 +591,7 @@ export default function ItemInspectionInfoPage() {
|
||||
{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)}
|
||||
>
|
||||
<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>
|
||||
@@ -574,9 +601,7 @@ export default function ItemInspectionInfoPage() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setItemModalOpen(false)}>취소</Button>
|
||||
</DialogFooter>
|
||||
<DialogFooter><Button variant="outline" onClick={() => setItemModalOpen(false)}>취소</Button></DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -587,14 +612,14 @@ export default function ItemInspectionInfoPage() {
|
||||
<div className="space-y-4">
|
||||
{/* 품목 정보 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold flex items-center gap-1.5">📦 품목 정보</h4>
|
||||
<h4 className="text-sm font-semibold">품목 정보</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>
|
||||
<Label className="text-xs font-semibold text-muted-foreground">품목명</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}>
|
||||
@@ -604,7 +629,7 @@ export default function ItemInspectionInfoPage() {
|
||||
<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" }))}>
|
||||
<Select value={form.is_active === false || form.is_active === "N" ? "N" : "Y"} onValueChange={(v) => setForm(p => ({ ...p, is_active: v === "Y" ? "사용" : "미사용" }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Y">사용</SelectItem>
|
||||
@@ -616,50 +641,32 @@ export default function ItemInspectionInfoPage() {
|
||||
<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>
|
||||
<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>
|
||||
<h4 className="text-sm font-semibold">검사유형 선택</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 }))}
|
||||
/>
|
||||
<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)}
|
||||
>
|
||||
<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")} />
|
||||
<span className="text-xs text-muted-foreground ml-auto">{(inspectionRows[key] || []).length}개</span>
|
||||
</button>
|
||||
{!collapsedTypes[key] && (
|
||||
<div className="space-y-2 pl-1">
|
||||
@@ -674,10 +681,10 @@ export default function ItemInspectionInfoPage() {
|
||||
<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-[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">합격기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[40px]">필수</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[36px]" />
|
||||
</TableRow>
|
||||
@@ -690,46 +697,20 @@ export default function ItemInspectionInfoPage() {
|
||||
<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>
|
||||
<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">
|
||||
<Input className="h-8 text-xs bg-muted" value={row.inspection_detail} readOnly placeholder="검사기준 상세" />
|
||||
<Input className="h-8 text-xs" value={row.apply_process} onChange={(e) => updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input className="h-8 text-xs bg-muted" value={row.inspection_method} readOnly placeholder="선택" />
|
||||
<Input className="h-8 text-xs" value={row.acceptance_criteria} onChange={(e) => updateInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준" 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">
|
||||
<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>
|
||||
<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>
|
||||
))}
|
||||
@@ -744,8 +725,7 @@ export default function ItemInspectionInfoPage() {
|
||||
<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" />}
|
||||
저장
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
@@ -753,14 +733,7 @@ export default function ItemInspectionInfoPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<TableSettingsModal
|
||||
open={ts.open}
|
||||
onOpenChange={ts.setOpen}
|
||||
tableName={ts.tableName}
|
||||
settingsId={ts.settingsId}
|
||||
defaultVisibleKeys={ts.defaultVisibleKeys}
|
||||
onSave={ts.applySettings}
|
||||
/>
|
||||
<TableSettingsModal open={ts.open} onOpenChange={ts.setOpen} tableName={ts.tableName} settingsId={ts.settingsId} defaultVisibleKeys={ts.defaultVisibleKeys} onSave={ts.applySettings} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,8 +9,9 @@ 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 { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import {
|
||||
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ChevronDown,
|
||||
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ClipboardList,
|
||||
} from "lucide-react";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -20,19 +21,17 @@ 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 ITEM_TABLE = "item_info";
|
||||
const INSPECTION_TABLE = "inspection_standard";
|
||||
|
||||
const GRID_COLUMNS = [
|
||||
{ key: "item_code", label: "품목코드" },
|
||||
{ key: "item_name", 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: ["수입검사", "입고검사", "수입", "입고"] },
|
||||
@@ -61,25 +60,29 @@ export default function ItemInspectionInfoPage() {
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
const [checkedIds, setCheckedIds] = useState<string[]>([]);
|
||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
|
||||
|
||||
// 우측 패널: 선택된 품목
|
||||
const [selectedItemCode, setSelectedItemCode] = useState<string | null>(null);
|
||||
const [selectedTypeTab, setSelectedTypeTab] = 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 옵션 */
|
||||
// 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 */
|
||||
// 검사유형별 검사항목 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>([]);
|
||||
@@ -110,7 +113,7 @@ export default function ItemInspectionInfoPage() {
|
||||
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 }[] = [];
|
||||
@@ -119,7 +122,7 @@ export default function ItemInspectionInfoPage() {
|
||||
setInspTypeCatOptions(flatCats);
|
||||
} catch { /* skip */ }
|
||||
|
||||
// 검사방법 카테고리 값 로드 (코드→라벨 매핑용)
|
||||
// 검사방법 카테고리
|
||||
try {
|
||||
const methodRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/inspection_method/values`);
|
||||
const flatMethods: { code: string; label: string }[] = [];
|
||||
@@ -139,20 +142,12 @@ export default function ItemInspectionInfoPage() {
|
||||
}, []);
|
||||
|
||||
/* ═══════════════════ 품목 선택 모달 ═══════════════════ */
|
||||
const openItemModal = () => {
|
||||
setItemSearchKeyword("");
|
||||
setFilteredItems(itemOptions);
|
||||
setItemModalOpen(true);
|
||||
};
|
||||
|
||||
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)
|
||||
));
|
||||
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);
|
||||
@@ -196,24 +191,45 @@ export default function ItemInspectionInfoPage() {
|
||||
return Object.values(map);
|
||||
}, [data]);
|
||||
|
||||
// 검사기준 ID → 라벨 resolve
|
||||
// 선택된 품목의 그룹 데이터
|
||||
const selectedGroup = useMemo(() => {
|
||||
if (!selectedItemCode) return null;
|
||||
return groupedData.find(g => g.item_code === selectedItemCode) || null;
|
||||
}, [selectedItemCode, groupedData]);
|
||||
|
||||
// 선택된 탭의 검사항목 행
|
||||
const selectedTabRows = useMemo(() => {
|
||||
if (!selectedGroup || !selectedTypeTab) return [];
|
||||
return selectedGroup.rows.filter((r: any) => r.inspection_type === selectedTypeTab && r.inspection_standard_id);
|
||||
}, [selectedGroup, selectedTypeTab]);
|
||||
|
||||
// 검사기준 ID → 라벨
|
||||
const resolveInspLabel = useCallback((id: string) => {
|
||||
const opt = inspOptions.find(o => o.code === id);
|
||||
return opt?.label || id || "-";
|
||||
return inspOptions.find(o => o.code === id)?.label || id || "-";
|
||||
}, [inspOptions]);
|
||||
|
||||
// 검사방법 코드 → 라벨
|
||||
const resolveMethodLabel = useCallback((code: string) => {
|
||||
return inspMethodCatOptions.find(o => o.code === code)?.label || code || "-";
|
||||
}, [inspMethodCatOptions]);
|
||||
|
||||
/* ═══════════════════ CRUD ═══════════════════ */
|
||||
const openCreate = () => { setForm({}); setEditMode(false); setInspectionRows({}); setCollapsedTypes({}); setModalOpen(true); };
|
||||
const openEdit = async (row: any) => {
|
||||
|
||||
const openEdit = async (itemCode?: string) => {
|
||||
const code = itemCode || selectedItemCode;
|
||||
if (!code) { toast.error("수정할 항목을 선택해주세요"); return; }
|
||||
const group = groupedData.find(g => g.item_code === code);
|
||||
if (!group) return;
|
||||
const row = group.rows[0];
|
||||
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 }] },
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: code }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const allRows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
@@ -222,7 +238,6 @@ export default function ItemInspectionInfoPage() {
|
||||
|
||||
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)))
|
||||
@@ -243,38 +258,22 @@ export default function ItemInspectionInfoPage() {
|
||||
is_required: r.is_required === "true" || r.is_required === true,
|
||||
});
|
||||
}
|
||||
|
||||
setInspectionRows(rowMap);
|
||||
setForm(p => ({ ...p, ...typeFlags }));
|
||||
} catch {
|
||||
setInspectionRows({});
|
||||
}
|
||||
} 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,
|
||||
}],
|
||||
[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),
|
||||
}));
|
||||
setInspectionRows(prev => ({ ...prev, [typeKey]: (prev[typeKey] || []).filter(r => r.id !== rowId) }));
|
||||
};
|
||||
|
||||
const updateInspRow = (typeKey: string, rowId: string, field: string, value: any) => {
|
||||
setInspectionRows(prev => ({
|
||||
...prev,
|
||||
@@ -290,28 +289,19 @@ export default function ItemInspectionInfoPage() {
|
||||
}),
|
||||
}));
|
||||
};
|
||||
|
||||
/** 검사유형 키에 매칭되는 검사기준만 필터링 */
|
||||
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);
|
||||
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 toggleCollapse = (typeKey: string) => { setCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] })); };
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.item_code) { toast.error("품목코드는 필수 입력이에요"); return; }
|
||||
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,
|
||||
@@ -320,54 +310,29 @@ export default function ItemInspectionInfoPage() {
|
||||
});
|
||||
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 })),
|
||||
});
|
||||
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 || "",
|
||||
});
|
||||
rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, 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 || "",
|
||||
id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label,
|
||||
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 ? "품목검사정보를 수정했어요" : "품목검사정보를 등록했어요");
|
||||
for (const row of rows) { await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, row); }
|
||||
toast.success(editMode ? "수정했어요" : "등록했어요");
|
||||
setModalOpen(false);
|
||||
fetchData();
|
||||
} catch { toast.error("저장에 실패했어요"); }
|
||||
@@ -375,157 +340,231 @@ export default function ItemInspectionInfoPage() {
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (checkedIds.length === 0) { toast.error("삭제할 항목을 선택해주세요"); return; }
|
||||
const ok = await confirm("품목검사정보 삭제", {
|
||||
description: `선택한 ${checkedIds.length}건을 삭제할까요? 이 작업은 되돌릴 수 없어요.`,
|
||||
});
|
||||
if (!selectedItemCode) { toast.error("삭제할 품목을 선택해주세요"); return; }
|
||||
const group = groupedData.find(g => g.item_code === selectedItemCode);
|
||||
if (!group) return;
|
||||
const ok = await confirm(`${selectedItemCode} 검사정보 삭제`, { description: "선택한 품목의 검사정보를 모두 삭제할까요?", variant: "destructive", confirmText: "삭제" });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, {
|
||||
data: checkedIds.map(id => ({ id })),
|
||||
});
|
||||
toast.success(`${checkedIds.length}건을 삭제했어요`);
|
||||
setCheckedIds([]);
|
||||
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: group.rows.map((r: any) => ({ id: r.id })) });
|
||||
toast.success("삭제했어요");
|
||||
setSelectedItemCode(null);
|
||||
fetchData();
|
||||
} catch { toast.error("삭제에 실패했어요"); }
|
||||
};
|
||||
|
||||
/* ═══════════════════ JSX ═══════════════════ */
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<div className="flex h-full flex-col">
|
||||
{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 className="shrink-0 px-3 pt-3 pb-2">
|
||||
<DynamicSearchFilter
|
||||
tableName={TABLE_NAME}
|
||||
filterId="c16-item-inspection"
|
||||
onFilterChange={setSearchFilters}
|
||||
externalFilterConfig={ts.filterConfig}
|
||||
dataCount={totalCount}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ═══════════════════ 등록/수정 모달 (품목선택 뷰 포함) ═══════════════════ */}
|
||||
{/* 좌우 분할 패널 */}
|
||||
<div className="flex-1 min-h-0 px-3 pb-3">
|
||||
<ResizablePanelGroup direction="horizontal" className="h-full rounded-lg border">
|
||||
{/* ═══════ 좌측: 품목 목록 ═══════ */}
|
||||
<ResizablePanel defaultSize={50} minSize={30}>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="shrink-0 flex items-center justify-between px-4 py-3 border-b bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardList className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-semibold">품목 목록</span>
|
||||
<Badge variant="secondary" className="text-[10px]">{groupedData.length}건</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button size="sm" className="h-7 text-xs" onClick={openCreate}><Plus className="w-3.5 h-3.5 mr-1" />등록</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" />삭제</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => ts.setOpen(true)}><Settings2 className="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 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">
|
||||
{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>
|
||||
{groupedData.map((group) => (
|
||||
<TableRow
|
||||
key={group.item_code}
|
||||
className={cn("cursor-pointer", selectedItemCode === group.item_code ? "bg-primary/10 border-l-2 border-l-primary" : "hover:bg-muted/50")}
|
||||
onClick={() => {
|
||||
setSelectedItemCode(group.item_code);
|
||||
setSelectedTypeTab(group.types[0] || "");
|
||||
}}
|
||||
>
|
||||
{ts.visibleColumns.map((col) => {
|
||||
switch (col.key) {
|
||||
case "item_code": return <TableCell key={col.key} className="text-sm font-medium text-primary">{group.item_code}</TableCell>;
|
||||
case "item_name": return <TableCell key={col.key} className="text-sm">{group.item_name}</TableCell>;
|
||||
case "inspection_type": return (
|
||||
<TableCell key={col.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 "is_active": return (
|
||||
<TableCell key={col.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={col.key}>{(group as any)[col.key] ?? ""}</TableCell>;
|
||||
}
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 px-4 py-2 border-t text-xs text-muted-foreground">
|
||||
전체 {groupedData.length}건 (품목 기준)
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* ═══════ 우측: 검사유형별 검사항목 ═══════ */}
|
||||
<ResizablePanel defaultSize={50} minSize={30}>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="shrink-0 flex items-center justify-between px-4 py-3 border-b bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold">검사유형별 검사항목</span>
|
||||
</div>
|
||||
{selectedGroup && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" />삭제</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!selectedGroup ? (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center space-y-2">
|
||||
<ClipboardList className="h-8 w-8 mx-auto opacity-30" />
|
||||
<p className="text-sm">좌측에서 품목을 선택해주세요</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
{/* 검사유형 탭 */}
|
||||
<div className="shrink-0 flex items-center gap-2 px-4 py-3">
|
||||
{selectedGroup.types.map((type: string) => {
|
||||
const count = selectedGroup.rows.filter((r: any) => r.inspection_type === type && r.inspection_standard_id).length;
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => setSelectedTypeTab(type)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-colors border",
|
||||
selectedTypeTab === type
|
||||
? "bg-primary text-primary-foreground border-primary"
|
||||
: "bg-muted/50 text-muted-foreground border-border hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
{type}
|
||||
<span className={cn(
|
||||
"inline-flex items-center justify-center h-4 min-w-[16px] rounded-full text-[10px] font-bold px-1",
|
||||
selectedTypeTab === type ? "bg-primary-foreground/20 text-primary-foreground" : "bg-muted-foreground/20 text-muted-foreground"
|
||||
)}>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 검사항목 상세 테이블 */}
|
||||
<div className="flex-1 min-h-0 overflow-auto px-4 pb-4">
|
||||
{selectedTypeTab && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="default" className="text-xs">{selectedTypeTab}</Badge>
|
||||
<span className="text-sm font-medium">검사항목 상세</span>
|
||||
</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 h-8">검사항목</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8">검사기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8">검사방법</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8">적용공정</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8">판단기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8">합격기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8 w-[50px]">필수</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{selectedTabRows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-8 text-xs text-muted-foreground">등록된 검사항목이 없어요</TableCell>
|
||||
</TableRow>
|
||||
) : selectedTabRows.map((row: any) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="text-xs py-2">{row.inspection_item_name || "-"}</TableCell>
|
||||
<TableCell className="text-xs py-2">{resolveInspLabel(row.inspection_standard_id)}</TableCell>
|
||||
<TableCell className="text-xs py-2">{resolveMethodLabel(row.inspection_method)}</TableCell>
|
||||
<TableCell className="text-xs py-2">{row.apply_process || "-"}</TableCell>
|
||||
<TableCell className="text-xs py-2">
|
||||
{row.judgment_criteria ? (
|
||||
<Badge variant="outline" className="text-[10px]">{row.judgment_criteria}</Badge>
|
||||
) : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs py-2 font-mono">{row.pass_criteria || "-"}</TableCell>
|
||||
<TableCell className="text-xs py-2 text-center">
|
||||
{row.is_required === "true" || row.is_required === true ? (
|
||||
<Badge variant="destructive" className="text-[9px]">필수</Badge>
|
||||
) : "-"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</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 ? (
|
||||
@@ -535,16 +574,8 @@ export default function ItemInspectionInfoPage() {
|
||||
<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>
|
||||
<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>
|
||||
@@ -560,11 +591,7 @@ export default function ItemInspectionInfoPage() {
|
||||
{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)}
|
||||
>
|
||||
<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>
|
||||
@@ -574,9 +601,7 @@ export default function ItemInspectionInfoPage() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setItemModalOpen(false)}>취소</Button>
|
||||
</DialogFooter>
|
||||
<DialogFooter><Button variant="outline" onClick={() => setItemModalOpen(false)}>취소</Button></DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -587,14 +612,14 @@ export default function ItemInspectionInfoPage() {
|
||||
<div className="space-y-4">
|
||||
{/* 품목 정보 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold flex items-center gap-1.5">📦 품목 정보</h4>
|
||||
<h4 className="text-sm font-semibold">품목 정보</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>
|
||||
<Label className="text-xs font-semibold text-muted-foreground">품목명</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}>
|
||||
@@ -604,7 +629,7 @@ export default function ItemInspectionInfoPage() {
|
||||
<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" }))}>
|
||||
<Select value={form.is_active === false || form.is_active === "N" ? "N" : "Y"} onValueChange={(v) => setForm(p => ({ ...p, is_active: v === "Y" ? "사용" : "미사용" }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Y">사용</SelectItem>
|
||||
@@ -616,50 +641,32 @@ export default function ItemInspectionInfoPage() {
|
||||
<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>
|
||||
<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>
|
||||
<h4 className="text-sm font-semibold">검사유형 선택</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 }))}
|
||||
/>
|
||||
<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)}
|
||||
>
|
||||
<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")} />
|
||||
<span className="text-xs text-muted-foreground ml-auto">{(inspectionRows[key] || []).length}개</span>
|
||||
</button>
|
||||
{!collapsedTypes[key] && (
|
||||
<div className="space-y-2 pl-1">
|
||||
@@ -674,10 +681,10 @@ export default function ItemInspectionInfoPage() {
|
||||
<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-[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">합격기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[40px]">필수</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[36px]" />
|
||||
</TableRow>
|
||||
@@ -690,46 +697,20 @@ export default function ItemInspectionInfoPage() {
|
||||
<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>
|
||||
<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">
|
||||
<Input className="h-8 text-xs bg-muted" value={row.inspection_detail} readOnly placeholder="검사기준 상세" />
|
||||
<Input className="h-8 text-xs" value={row.apply_process} onChange={(e) => updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input className="h-8 text-xs bg-muted" value={row.inspection_method} readOnly placeholder="선택" />
|
||||
<Input className="h-8 text-xs" value={row.acceptance_criteria} onChange={(e) => updateInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준" 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">
|
||||
<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>
|
||||
<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>
|
||||
))}
|
||||
@@ -744,8 +725,7 @@ export default function ItemInspectionInfoPage() {
|
||||
<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" />}
|
||||
저장
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
@@ -753,14 +733,7 @@ export default function ItemInspectionInfoPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<TableSettingsModal
|
||||
open={ts.open}
|
||||
onOpenChange={ts.setOpen}
|
||||
tableName={ts.tableName}
|
||||
settingsId={ts.settingsId}
|
||||
defaultVisibleKeys={ts.defaultVisibleKeys}
|
||||
onSave={ts.applySettings}
|
||||
/>
|
||||
<TableSettingsModal open={ts.open} onOpenChange={ts.setOpen} tableName={ts.tableName} settingsId={ts.settingsId} defaultVisibleKeys={ts.defaultVisibleKeys} onSave={ts.applySettings} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,8 +9,9 @@ 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 { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import {
|
||||
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ChevronDown,
|
||||
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ClipboardList,
|
||||
} from "lucide-react";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -20,19 +21,17 @@ 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 ITEM_TABLE = "item_info";
|
||||
const INSPECTION_TABLE = "inspection_standard";
|
||||
|
||||
const GRID_COLUMNS = [
|
||||
{ key: "item_code", label: "품목코드" },
|
||||
{ key: "item_name", 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: ["수입검사", "입고검사", "수입", "입고"] },
|
||||
@@ -61,25 +60,29 @@ export default function ItemInspectionInfoPage() {
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
const [checkedIds, setCheckedIds] = useState<string[]>([]);
|
||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
|
||||
|
||||
// 우측 패널: 선택된 품목
|
||||
const [selectedItemCode, setSelectedItemCode] = useState<string | null>(null);
|
||||
const [selectedTypeTab, setSelectedTypeTab] = 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 옵션 */
|
||||
// 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 */
|
||||
// 검사유형별 검사항목 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>([]);
|
||||
@@ -110,7 +113,7 @@ export default function ItemInspectionInfoPage() {
|
||||
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 }[] = [];
|
||||
@@ -119,7 +122,7 @@ export default function ItemInspectionInfoPage() {
|
||||
setInspTypeCatOptions(flatCats);
|
||||
} catch { /* skip */ }
|
||||
|
||||
// 검사방법 카테고리 값 로드 (코드→라벨 매핑용)
|
||||
// 검사방법 카테고리
|
||||
try {
|
||||
const methodRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/inspection_method/values`);
|
||||
const flatMethods: { code: string; label: string }[] = [];
|
||||
@@ -139,20 +142,12 @@ export default function ItemInspectionInfoPage() {
|
||||
}, []);
|
||||
|
||||
/* ═══════════════════ 품목 선택 모달 ═══════════════════ */
|
||||
const openItemModal = () => {
|
||||
setItemSearchKeyword("");
|
||||
setFilteredItems(itemOptions);
|
||||
setItemModalOpen(true);
|
||||
};
|
||||
|
||||
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)
|
||||
));
|
||||
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);
|
||||
@@ -196,24 +191,45 @@ export default function ItemInspectionInfoPage() {
|
||||
return Object.values(map);
|
||||
}, [data]);
|
||||
|
||||
// 검사기준 ID → 라벨 resolve
|
||||
// 선택된 품목의 그룹 데이터
|
||||
const selectedGroup = useMemo(() => {
|
||||
if (!selectedItemCode) return null;
|
||||
return groupedData.find(g => g.item_code === selectedItemCode) || null;
|
||||
}, [selectedItemCode, groupedData]);
|
||||
|
||||
// 선택된 탭의 검사항목 행
|
||||
const selectedTabRows = useMemo(() => {
|
||||
if (!selectedGroup || !selectedTypeTab) return [];
|
||||
return selectedGroup.rows.filter((r: any) => r.inspection_type === selectedTypeTab && r.inspection_standard_id);
|
||||
}, [selectedGroup, selectedTypeTab]);
|
||||
|
||||
// 검사기준 ID → 라벨
|
||||
const resolveInspLabel = useCallback((id: string) => {
|
||||
const opt = inspOptions.find(o => o.code === id);
|
||||
return opt?.label || id || "-";
|
||||
return inspOptions.find(o => o.code === id)?.label || id || "-";
|
||||
}, [inspOptions]);
|
||||
|
||||
// 검사방법 코드 → 라벨
|
||||
const resolveMethodLabel = useCallback((code: string) => {
|
||||
return inspMethodCatOptions.find(o => o.code === code)?.label || code || "-";
|
||||
}, [inspMethodCatOptions]);
|
||||
|
||||
/* ═══════════════════ CRUD ═══════════════════ */
|
||||
const openCreate = () => { setForm({}); setEditMode(false); setInspectionRows({}); setCollapsedTypes({}); setModalOpen(true); };
|
||||
const openEdit = async (row: any) => {
|
||||
|
||||
const openEdit = async (itemCode?: string) => {
|
||||
const code = itemCode || selectedItemCode;
|
||||
if (!code) { toast.error("수정할 항목을 선택해주세요"); return; }
|
||||
const group = groupedData.find(g => g.item_code === code);
|
||||
if (!group) return;
|
||||
const row = group.rows[0];
|
||||
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 }] },
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: code }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const allRows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
@@ -222,7 +238,6 @@ export default function ItemInspectionInfoPage() {
|
||||
|
||||
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)))
|
||||
@@ -243,38 +258,22 @@ export default function ItemInspectionInfoPage() {
|
||||
is_required: r.is_required === "true" || r.is_required === true,
|
||||
});
|
||||
}
|
||||
|
||||
setInspectionRows(rowMap);
|
||||
setForm(p => ({ ...p, ...typeFlags }));
|
||||
} catch {
|
||||
setInspectionRows({});
|
||||
}
|
||||
} 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,
|
||||
}],
|
||||
[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),
|
||||
}));
|
||||
setInspectionRows(prev => ({ ...prev, [typeKey]: (prev[typeKey] || []).filter(r => r.id !== rowId) }));
|
||||
};
|
||||
|
||||
const updateInspRow = (typeKey: string, rowId: string, field: string, value: any) => {
|
||||
setInspectionRows(prev => ({
|
||||
...prev,
|
||||
@@ -290,28 +289,19 @@ export default function ItemInspectionInfoPage() {
|
||||
}),
|
||||
}));
|
||||
};
|
||||
|
||||
/** 검사유형 키에 매칭되는 검사기준만 필터링 */
|
||||
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);
|
||||
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 toggleCollapse = (typeKey: string) => { setCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] })); };
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.item_code) { toast.error("품목코드는 필수 입력이에요"); return; }
|
||||
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,
|
||||
@@ -320,54 +310,29 @@ export default function ItemInspectionInfoPage() {
|
||||
});
|
||||
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 })),
|
||||
});
|
||||
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 || "",
|
||||
});
|
||||
rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, 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 || "",
|
||||
id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label,
|
||||
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 ? "품목검사정보를 수정했어요" : "품목검사정보를 등록했어요");
|
||||
for (const row of rows) { await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, row); }
|
||||
toast.success(editMode ? "수정했어요" : "등록했어요");
|
||||
setModalOpen(false);
|
||||
fetchData();
|
||||
} catch { toast.error("저장에 실패했어요"); }
|
||||
@@ -375,157 +340,231 @@ export default function ItemInspectionInfoPage() {
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (checkedIds.length === 0) { toast.error("삭제할 항목을 선택해주세요"); return; }
|
||||
const ok = await confirm("품목검사정보 삭제", {
|
||||
description: `선택한 ${checkedIds.length}건을 삭제할까요? 이 작업은 되돌릴 수 없어요.`,
|
||||
});
|
||||
if (!selectedItemCode) { toast.error("삭제할 품목을 선택해주세요"); return; }
|
||||
const group = groupedData.find(g => g.item_code === selectedItemCode);
|
||||
if (!group) return;
|
||||
const ok = await confirm(`${selectedItemCode} 검사정보 삭제`, { description: "선택한 품목의 검사정보를 모두 삭제할까요?", variant: "destructive", confirmText: "삭제" });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, {
|
||||
data: checkedIds.map(id => ({ id })),
|
||||
});
|
||||
toast.success(`${checkedIds.length}건을 삭제했어요`);
|
||||
setCheckedIds([]);
|
||||
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: group.rows.map((r: any) => ({ id: r.id })) });
|
||||
toast.success("삭제했어요");
|
||||
setSelectedItemCode(null);
|
||||
fetchData();
|
||||
} catch { toast.error("삭제에 실패했어요"); }
|
||||
};
|
||||
|
||||
/* ═══════════════════ JSX ═══════════════════ */
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<div className="flex h-full flex-col">
|
||||
{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 className="shrink-0 px-3 pt-3 pb-2">
|
||||
<DynamicSearchFilter
|
||||
tableName={TABLE_NAME}
|
||||
filterId="c16-item-inspection"
|
||||
onFilterChange={setSearchFilters}
|
||||
externalFilterConfig={ts.filterConfig}
|
||||
dataCount={totalCount}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ═══════════════════ 등록/수정 모달 (품목선택 뷰 포함) ═══════════════════ */}
|
||||
{/* 좌우 분할 패널 */}
|
||||
<div className="flex-1 min-h-0 px-3 pb-3">
|
||||
<ResizablePanelGroup direction="horizontal" className="h-full rounded-lg border">
|
||||
{/* ═══════ 좌측: 품목 목록 ═══════ */}
|
||||
<ResizablePanel defaultSize={50} minSize={30}>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="shrink-0 flex items-center justify-between px-4 py-3 border-b bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardList className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-semibold">품목 목록</span>
|
||||
<Badge variant="secondary" className="text-[10px]">{groupedData.length}건</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button size="sm" className="h-7 text-xs" onClick={openCreate}><Plus className="w-3.5 h-3.5 mr-1" />등록</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" />삭제</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => ts.setOpen(true)}><Settings2 className="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 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">
|
||||
{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>
|
||||
{groupedData.map((group) => (
|
||||
<TableRow
|
||||
key={group.item_code}
|
||||
className={cn("cursor-pointer", selectedItemCode === group.item_code ? "bg-primary/10 border-l-2 border-l-primary" : "hover:bg-muted/50")}
|
||||
onClick={() => {
|
||||
setSelectedItemCode(group.item_code);
|
||||
setSelectedTypeTab(group.types[0] || "");
|
||||
}}
|
||||
>
|
||||
{ts.visibleColumns.map((col) => {
|
||||
switch (col.key) {
|
||||
case "item_code": return <TableCell key={col.key} className="text-sm font-medium text-primary">{group.item_code}</TableCell>;
|
||||
case "item_name": return <TableCell key={col.key} className="text-sm">{group.item_name}</TableCell>;
|
||||
case "inspection_type": return (
|
||||
<TableCell key={col.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 "is_active": return (
|
||||
<TableCell key={col.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={col.key}>{(group as any)[col.key] ?? ""}</TableCell>;
|
||||
}
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 px-4 py-2 border-t text-xs text-muted-foreground">
|
||||
전체 {groupedData.length}건 (품목 기준)
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* ═══════ 우측: 검사유형별 검사항목 ═══════ */}
|
||||
<ResizablePanel defaultSize={50} minSize={30}>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="shrink-0 flex items-center justify-between px-4 py-3 border-b bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold">검사유형별 검사항목</span>
|
||||
</div>
|
||||
{selectedGroup && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" />삭제</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!selectedGroup ? (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center space-y-2">
|
||||
<ClipboardList className="h-8 w-8 mx-auto opacity-30" />
|
||||
<p className="text-sm">좌측에서 품목을 선택해주세요</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
{/* 검사유형 탭 */}
|
||||
<div className="shrink-0 flex items-center gap-2 px-4 py-3">
|
||||
{selectedGroup.types.map((type: string) => {
|
||||
const count = selectedGroup.rows.filter((r: any) => r.inspection_type === type && r.inspection_standard_id).length;
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => setSelectedTypeTab(type)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-colors border",
|
||||
selectedTypeTab === type
|
||||
? "bg-primary text-primary-foreground border-primary"
|
||||
: "bg-muted/50 text-muted-foreground border-border hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
{type}
|
||||
<span className={cn(
|
||||
"inline-flex items-center justify-center h-4 min-w-[16px] rounded-full text-[10px] font-bold px-1",
|
||||
selectedTypeTab === type ? "bg-primary-foreground/20 text-primary-foreground" : "bg-muted-foreground/20 text-muted-foreground"
|
||||
)}>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 검사항목 상세 테이블 */}
|
||||
<div className="flex-1 min-h-0 overflow-auto px-4 pb-4">
|
||||
{selectedTypeTab && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="default" className="text-xs">{selectedTypeTab}</Badge>
|
||||
<span className="text-sm font-medium">검사항목 상세</span>
|
||||
</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 h-8">검사항목</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8">검사기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8">검사방법</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8">적용공정</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8">판단기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8">합격기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8 w-[50px]">필수</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{selectedTabRows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-8 text-xs text-muted-foreground">등록된 검사항목이 없어요</TableCell>
|
||||
</TableRow>
|
||||
) : selectedTabRows.map((row: any) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="text-xs py-2">{row.inspection_item_name || "-"}</TableCell>
|
||||
<TableCell className="text-xs py-2">{resolveInspLabel(row.inspection_standard_id)}</TableCell>
|
||||
<TableCell className="text-xs py-2">{resolveMethodLabel(row.inspection_method)}</TableCell>
|
||||
<TableCell className="text-xs py-2">{row.apply_process || "-"}</TableCell>
|
||||
<TableCell className="text-xs py-2">
|
||||
{row.judgment_criteria ? (
|
||||
<Badge variant="outline" className="text-[10px]">{row.judgment_criteria}</Badge>
|
||||
) : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs py-2 font-mono">{row.pass_criteria || "-"}</TableCell>
|
||||
<TableCell className="text-xs py-2 text-center">
|
||||
{row.is_required === "true" || row.is_required === true ? (
|
||||
<Badge variant="destructive" className="text-[9px]">필수</Badge>
|
||||
) : "-"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</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 ? (
|
||||
@@ -535,16 +574,8 @@ export default function ItemInspectionInfoPage() {
|
||||
<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>
|
||||
<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>
|
||||
@@ -560,11 +591,7 @@ export default function ItemInspectionInfoPage() {
|
||||
{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)}
|
||||
>
|
||||
<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>
|
||||
@@ -574,9 +601,7 @@ export default function ItemInspectionInfoPage() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setItemModalOpen(false)}>취소</Button>
|
||||
</DialogFooter>
|
||||
<DialogFooter><Button variant="outline" onClick={() => setItemModalOpen(false)}>취소</Button></DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -587,14 +612,14 @@ export default function ItemInspectionInfoPage() {
|
||||
<div className="space-y-4">
|
||||
{/* 품목 정보 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold flex items-center gap-1.5">📦 품목 정보</h4>
|
||||
<h4 className="text-sm font-semibold">품목 정보</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>
|
||||
<Label className="text-xs font-semibold text-muted-foreground">품목명</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}>
|
||||
@@ -604,7 +629,7 @@ export default function ItemInspectionInfoPage() {
|
||||
<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" }))}>
|
||||
<Select value={form.is_active === false || form.is_active === "N" ? "N" : "Y"} onValueChange={(v) => setForm(p => ({ ...p, is_active: v === "Y" ? "사용" : "미사용" }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Y">사용</SelectItem>
|
||||
@@ -616,50 +641,32 @@ export default function ItemInspectionInfoPage() {
|
||||
<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>
|
||||
<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>
|
||||
<h4 className="text-sm font-semibold">검사유형 선택</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 }))}
|
||||
/>
|
||||
<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)}
|
||||
>
|
||||
<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")} />
|
||||
<span className="text-xs text-muted-foreground ml-auto">{(inspectionRows[key] || []).length}개</span>
|
||||
</button>
|
||||
{!collapsedTypes[key] && (
|
||||
<div className="space-y-2 pl-1">
|
||||
@@ -674,10 +681,10 @@ export default function ItemInspectionInfoPage() {
|
||||
<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-[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">합격기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[40px]">필수</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[36px]" />
|
||||
</TableRow>
|
||||
@@ -690,46 +697,20 @@ export default function ItemInspectionInfoPage() {
|
||||
<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>
|
||||
<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">
|
||||
<Input className="h-8 text-xs bg-muted" value={row.inspection_detail} readOnly placeholder="검사기준 상세" />
|
||||
<Input className="h-8 text-xs" value={row.apply_process} onChange={(e) => updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input className="h-8 text-xs bg-muted" value={row.inspection_method} readOnly placeholder="선택" />
|
||||
<Input className="h-8 text-xs" value={row.acceptance_criteria} onChange={(e) => updateInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준" 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">
|
||||
<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>
|
||||
<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>
|
||||
))}
|
||||
@@ -744,8 +725,7 @@ export default function ItemInspectionInfoPage() {
|
||||
<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" />}
|
||||
저장
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
@@ -753,14 +733,7 @@ export default function ItemInspectionInfoPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<TableSettingsModal
|
||||
open={ts.open}
|
||||
onOpenChange={ts.setOpen}
|
||||
tableName={ts.tableName}
|
||||
settingsId={ts.settingsId}
|
||||
defaultVisibleKeys={ts.defaultVisibleKeys}
|
||||
onSave={ts.applySettings}
|
||||
/>
|
||||
<TableSettingsModal open={ts.open} onOpenChange={ts.setOpen} tableName={ts.tableName} settingsId={ts.settingsId} defaultVisibleKeys={ts.defaultVisibleKeys} onSave={ts.applySettings} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,8 +9,9 @@ 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 { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import {
|
||||
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ChevronDown,
|
||||
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ClipboardList,
|
||||
} from "lucide-react";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -20,19 +21,17 @@ 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 ITEM_TABLE = "item_info";
|
||||
const INSPECTION_TABLE = "inspection_standard";
|
||||
|
||||
const GRID_COLUMNS = [
|
||||
{ key: "item_code", label: "품목코드" },
|
||||
{ key: "item_name", 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: ["수입검사", "입고검사", "수입", "입고"] },
|
||||
@@ -61,25 +60,29 @@ export default function ItemInspectionInfoPage() {
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
const [checkedIds, setCheckedIds] = useState<string[]>([]);
|
||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
|
||||
|
||||
// 우측 패널: 선택된 품목
|
||||
const [selectedItemCode, setSelectedItemCode] = useState<string | null>(null);
|
||||
const [selectedTypeTab, setSelectedTypeTab] = 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 옵션 */
|
||||
// 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 */
|
||||
// 검사유형별 검사항목 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>([]);
|
||||
@@ -110,7 +113,7 @@ export default function ItemInspectionInfoPage() {
|
||||
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 }[] = [];
|
||||
@@ -119,7 +122,7 @@ export default function ItemInspectionInfoPage() {
|
||||
setInspTypeCatOptions(flatCats);
|
||||
} catch { /* skip */ }
|
||||
|
||||
// 검사방법 카테고리 값 로드 (코드→라벨 매핑용)
|
||||
// 검사방법 카테고리
|
||||
try {
|
||||
const methodRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/inspection_method/values`);
|
||||
const flatMethods: { code: string; label: string }[] = [];
|
||||
@@ -139,20 +142,12 @@ export default function ItemInspectionInfoPage() {
|
||||
}, []);
|
||||
|
||||
/* ═══════════════════ 품목 선택 모달 ═══════════════════ */
|
||||
const openItemModal = () => {
|
||||
setItemSearchKeyword("");
|
||||
setFilteredItems(itemOptions);
|
||||
setItemModalOpen(true);
|
||||
};
|
||||
|
||||
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)
|
||||
));
|
||||
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);
|
||||
@@ -196,24 +191,45 @@ export default function ItemInspectionInfoPage() {
|
||||
return Object.values(map);
|
||||
}, [data]);
|
||||
|
||||
// 검사기준 ID → 라벨 resolve
|
||||
// 선택된 품목의 그룹 데이터
|
||||
const selectedGroup = useMemo(() => {
|
||||
if (!selectedItemCode) return null;
|
||||
return groupedData.find(g => g.item_code === selectedItemCode) || null;
|
||||
}, [selectedItemCode, groupedData]);
|
||||
|
||||
// 선택된 탭의 검사항목 행
|
||||
const selectedTabRows = useMemo(() => {
|
||||
if (!selectedGroup || !selectedTypeTab) return [];
|
||||
return selectedGroup.rows.filter((r: any) => r.inspection_type === selectedTypeTab && r.inspection_standard_id);
|
||||
}, [selectedGroup, selectedTypeTab]);
|
||||
|
||||
// 검사기준 ID → 라벨
|
||||
const resolveInspLabel = useCallback((id: string) => {
|
||||
const opt = inspOptions.find(o => o.code === id);
|
||||
return opt?.label || id || "-";
|
||||
return inspOptions.find(o => o.code === id)?.label || id || "-";
|
||||
}, [inspOptions]);
|
||||
|
||||
// 검사방법 코드 → 라벨
|
||||
const resolveMethodLabel = useCallback((code: string) => {
|
||||
return inspMethodCatOptions.find(o => o.code === code)?.label || code || "-";
|
||||
}, [inspMethodCatOptions]);
|
||||
|
||||
/* ═══════════════════ CRUD ═══════════════════ */
|
||||
const openCreate = () => { setForm({}); setEditMode(false); setInspectionRows({}); setCollapsedTypes({}); setModalOpen(true); };
|
||||
const openEdit = async (row: any) => {
|
||||
|
||||
const openEdit = async (itemCode?: string) => {
|
||||
const code = itemCode || selectedItemCode;
|
||||
if (!code) { toast.error("수정할 항목을 선택해주세요"); return; }
|
||||
const group = groupedData.find(g => g.item_code === code);
|
||||
if (!group) return;
|
||||
const row = group.rows[0];
|
||||
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 }] },
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: code }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const allRows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
@@ -222,7 +238,6 @@ export default function ItemInspectionInfoPage() {
|
||||
|
||||
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)))
|
||||
@@ -243,38 +258,22 @@ export default function ItemInspectionInfoPage() {
|
||||
is_required: r.is_required === "true" || r.is_required === true,
|
||||
});
|
||||
}
|
||||
|
||||
setInspectionRows(rowMap);
|
||||
setForm(p => ({ ...p, ...typeFlags }));
|
||||
} catch {
|
||||
setInspectionRows({});
|
||||
}
|
||||
} 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,
|
||||
}],
|
||||
[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),
|
||||
}));
|
||||
setInspectionRows(prev => ({ ...prev, [typeKey]: (prev[typeKey] || []).filter(r => r.id !== rowId) }));
|
||||
};
|
||||
|
||||
const updateInspRow = (typeKey: string, rowId: string, field: string, value: any) => {
|
||||
setInspectionRows(prev => ({
|
||||
...prev,
|
||||
@@ -290,28 +289,19 @@ export default function ItemInspectionInfoPage() {
|
||||
}),
|
||||
}));
|
||||
};
|
||||
|
||||
/** 검사유형 키에 매칭되는 검사기준만 필터링 */
|
||||
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);
|
||||
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 toggleCollapse = (typeKey: string) => { setCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] })); };
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.item_code) { toast.error("품목코드는 필수 입력이에요"); return; }
|
||||
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,
|
||||
@@ -320,54 +310,29 @@ export default function ItemInspectionInfoPage() {
|
||||
});
|
||||
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 })),
|
||||
});
|
||||
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 || "",
|
||||
});
|
||||
rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, 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 || "",
|
||||
id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label,
|
||||
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 ? "품목검사정보를 수정했어요" : "품목검사정보를 등록했어요");
|
||||
for (const row of rows) { await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, row); }
|
||||
toast.success(editMode ? "수정했어요" : "등록했어요");
|
||||
setModalOpen(false);
|
||||
fetchData();
|
||||
} catch { toast.error("저장에 실패했어요"); }
|
||||
@@ -375,157 +340,231 @@ export default function ItemInspectionInfoPage() {
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (checkedIds.length === 0) { toast.error("삭제할 항목을 선택해주세요"); return; }
|
||||
const ok = await confirm("품목검사정보 삭제", {
|
||||
description: `선택한 ${checkedIds.length}건을 삭제할까요? 이 작업은 되돌릴 수 없어요.`,
|
||||
});
|
||||
if (!selectedItemCode) { toast.error("삭제할 품목을 선택해주세요"); return; }
|
||||
const group = groupedData.find(g => g.item_code === selectedItemCode);
|
||||
if (!group) return;
|
||||
const ok = await confirm(`${selectedItemCode} 검사정보 삭제`, { description: "선택한 품목의 검사정보를 모두 삭제할까요?", variant: "destructive", confirmText: "삭제" });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, {
|
||||
data: checkedIds.map(id => ({ id })),
|
||||
});
|
||||
toast.success(`${checkedIds.length}건을 삭제했어요`);
|
||||
setCheckedIds([]);
|
||||
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: group.rows.map((r: any) => ({ id: r.id })) });
|
||||
toast.success("삭제했어요");
|
||||
setSelectedItemCode(null);
|
||||
fetchData();
|
||||
} catch { toast.error("삭제에 실패했어요"); }
|
||||
};
|
||||
|
||||
/* ═══════════════════ JSX ═══════════════════ */
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<div className="flex h-full flex-col">
|
||||
{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 className="shrink-0 px-3 pt-3 pb-2">
|
||||
<DynamicSearchFilter
|
||||
tableName={TABLE_NAME}
|
||||
filterId="c16-item-inspection"
|
||||
onFilterChange={setSearchFilters}
|
||||
externalFilterConfig={ts.filterConfig}
|
||||
dataCount={totalCount}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ═══════════════════ 등록/수정 모달 (품목선택 뷰 포함) ═══════════════════ */}
|
||||
{/* 좌우 분할 패널 */}
|
||||
<div className="flex-1 min-h-0 px-3 pb-3">
|
||||
<ResizablePanelGroup direction="horizontal" className="h-full rounded-lg border">
|
||||
{/* ═══════ 좌측: 품목 목록 ═══════ */}
|
||||
<ResizablePanel defaultSize={50} minSize={30}>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="shrink-0 flex items-center justify-between px-4 py-3 border-b bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardList className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-semibold">품목 목록</span>
|
||||
<Badge variant="secondary" className="text-[10px]">{groupedData.length}건</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button size="sm" className="h-7 text-xs" onClick={openCreate}><Plus className="w-3.5 h-3.5 mr-1" />등록</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" />삭제</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => ts.setOpen(true)}><Settings2 className="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 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">
|
||||
{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>
|
||||
{groupedData.map((group) => (
|
||||
<TableRow
|
||||
key={group.item_code}
|
||||
className={cn("cursor-pointer", selectedItemCode === group.item_code ? "bg-primary/10 border-l-2 border-l-primary" : "hover:bg-muted/50")}
|
||||
onClick={() => {
|
||||
setSelectedItemCode(group.item_code);
|
||||
setSelectedTypeTab(group.types[0] || "");
|
||||
}}
|
||||
>
|
||||
{ts.visibleColumns.map((col) => {
|
||||
switch (col.key) {
|
||||
case "item_code": return <TableCell key={col.key} className="text-sm font-medium text-primary">{group.item_code}</TableCell>;
|
||||
case "item_name": return <TableCell key={col.key} className="text-sm">{group.item_name}</TableCell>;
|
||||
case "inspection_type": return (
|
||||
<TableCell key={col.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 "is_active": return (
|
||||
<TableCell key={col.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={col.key}>{(group as any)[col.key] ?? ""}</TableCell>;
|
||||
}
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 px-4 py-2 border-t text-xs text-muted-foreground">
|
||||
전체 {groupedData.length}건 (품목 기준)
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* ═══════ 우측: 검사유형별 검사항목 ═══════ */}
|
||||
<ResizablePanel defaultSize={50} minSize={30}>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="shrink-0 flex items-center justify-between px-4 py-3 border-b bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold">검사유형별 검사항목</span>
|
||||
</div>
|
||||
{selectedGroup && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" />삭제</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!selectedGroup ? (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center space-y-2">
|
||||
<ClipboardList className="h-8 w-8 mx-auto opacity-30" />
|
||||
<p className="text-sm">좌측에서 품목을 선택해주세요</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
{/* 검사유형 탭 */}
|
||||
<div className="shrink-0 flex items-center gap-2 px-4 py-3">
|
||||
{selectedGroup.types.map((type: string) => {
|
||||
const count = selectedGroup.rows.filter((r: any) => r.inspection_type === type && r.inspection_standard_id).length;
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => setSelectedTypeTab(type)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-colors border",
|
||||
selectedTypeTab === type
|
||||
? "bg-primary text-primary-foreground border-primary"
|
||||
: "bg-muted/50 text-muted-foreground border-border hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
{type}
|
||||
<span className={cn(
|
||||
"inline-flex items-center justify-center h-4 min-w-[16px] rounded-full text-[10px] font-bold px-1",
|
||||
selectedTypeTab === type ? "bg-primary-foreground/20 text-primary-foreground" : "bg-muted-foreground/20 text-muted-foreground"
|
||||
)}>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 검사항목 상세 테이블 */}
|
||||
<div className="flex-1 min-h-0 overflow-auto px-4 pb-4">
|
||||
{selectedTypeTab && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="default" className="text-xs">{selectedTypeTab}</Badge>
|
||||
<span className="text-sm font-medium">검사항목 상세</span>
|
||||
</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 h-8">검사항목</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8">검사기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8">검사방법</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8">적용공정</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8">판단기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8">합격기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8 w-[50px]">필수</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{selectedTabRows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-8 text-xs text-muted-foreground">등록된 검사항목이 없어요</TableCell>
|
||||
</TableRow>
|
||||
) : selectedTabRows.map((row: any) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="text-xs py-2">{row.inspection_item_name || "-"}</TableCell>
|
||||
<TableCell className="text-xs py-2">{resolveInspLabel(row.inspection_standard_id)}</TableCell>
|
||||
<TableCell className="text-xs py-2">{resolveMethodLabel(row.inspection_method)}</TableCell>
|
||||
<TableCell className="text-xs py-2">{row.apply_process || "-"}</TableCell>
|
||||
<TableCell className="text-xs py-2">
|
||||
{row.judgment_criteria ? (
|
||||
<Badge variant="outline" className="text-[10px]">{row.judgment_criteria}</Badge>
|
||||
) : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs py-2 font-mono">{row.pass_criteria || "-"}</TableCell>
|
||||
<TableCell className="text-xs py-2 text-center">
|
||||
{row.is_required === "true" || row.is_required === true ? (
|
||||
<Badge variant="destructive" className="text-[9px]">필수</Badge>
|
||||
) : "-"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</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 ? (
|
||||
@@ -535,16 +574,8 @@ export default function ItemInspectionInfoPage() {
|
||||
<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>
|
||||
<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>
|
||||
@@ -560,11 +591,7 @@ export default function ItemInspectionInfoPage() {
|
||||
{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)}
|
||||
>
|
||||
<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>
|
||||
@@ -574,9 +601,7 @@ export default function ItemInspectionInfoPage() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setItemModalOpen(false)}>취소</Button>
|
||||
</DialogFooter>
|
||||
<DialogFooter><Button variant="outline" onClick={() => setItemModalOpen(false)}>취소</Button></DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -587,14 +612,14 @@ export default function ItemInspectionInfoPage() {
|
||||
<div className="space-y-4">
|
||||
{/* 품목 정보 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold flex items-center gap-1.5">📦 품목 정보</h4>
|
||||
<h4 className="text-sm font-semibold">품목 정보</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>
|
||||
<Label className="text-xs font-semibold text-muted-foreground">품목명</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}>
|
||||
@@ -604,7 +629,7 @@ export default function ItemInspectionInfoPage() {
|
||||
<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" }))}>
|
||||
<Select value={form.is_active === false || form.is_active === "N" ? "N" : "Y"} onValueChange={(v) => setForm(p => ({ ...p, is_active: v === "Y" ? "사용" : "미사용" }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Y">사용</SelectItem>
|
||||
@@ -616,50 +641,32 @@ export default function ItemInspectionInfoPage() {
|
||||
<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>
|
||||
<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>
|
||||
<h4 className="text-sm font-semibold">검사유형 선택</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 }))}
|
||||
/>
|
||||
<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)}
|
||||
>
|
||||
<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")} />
|
||||
<span className="text-xs text-muted-foreground ml-auto">{(inspectionRows[key] || []).length}개</span>
|
||||
</button>
|
||||
{!collapsedTypes[key] && (
|
||||
<div className="space-y-2 pl-1">
|
||||
@@ -674,10 +681,10 @@ export default function ItemInspectionInfoPage() {
|
||||
<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-[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">합격기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[40px]">필수</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[36px]" />
|
||||
</TableRow>
|
||||
@@ -690,46 +697,20 @@ export default function ItemInspectionInfoPage() {
|
||||
<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>
|
||||
<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">
|
||||
<Input className="h-8 text-xs bg-muted" value={row.inspection_detail} readOnly placeholder="검사기준 상세" />
|
||||
<Input className="h-8 text-xs" value={row.apply_process} onChange={(e) => updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input className="h-8 text-xs bg-muted" value={row.inspection_method} readOnly placeholder="선택" />
|
||||
<Input className="h-8 text-xs" value={row.acceptance_criteria} onChange={(e) => updateInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준" 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">
|
||||
<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>
|
||||
<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>
|
||||
))}
|
||||
@@ -744,8 +725,7 @@ export default function ItemInspectionInfoPage() {
|
||||
<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" />}
|
||||
저장
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
@@ -753,14 +733,7 @@ export default function ItemInspectionInfoPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<TableSettingsModal
|
||||
open={ts.open}
|
||||
onOpenChange={ts.setOpen}
|
||||
tableName={ts.tableName}
|
||||
settingsId={ts.settingsId}
|
||||
defaultVisibleKeys={ts.defaultVisibleKeys}
|
||||
onSave={ts.applySettings}
|
||||
/>
|
||||
<TableSettingsModal open={ts.open} onOpenChange={ts.setOpen} tableName={ts.tableName} settingsId={ts.settingsId} defaultVisibleKeys={ts.defaultVisibleKeys} onSave={ts.applySettings} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -905,24 +905,24 @@ export default function JeilGlassOrderPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto overflow-y-visible">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-background z-10">
|
||||
<Table className="min-w-[1800px]">
|
||||
<TableHeader className="sticky top-0 bg-background z-20">
|
||||
<TableRow>
|
||||
<TableHead className="w-[30px]"></TableHead>
|
||||
<TableHead className="w-[36px]"></TableHead>
|
||||
<TableHead className="w-[40px]">No</TableHead>
|
||||
<TableHead className="min-w-[90px]">구분</TableHead>
|
||||
<TableHead className="min-w-[150px]">품명</TableHead>
|
||||
<TableHead className="min-w-[100px]">규격</TableHead>
|
||||
<TableHead className="min-w-[80px]">가로</TableHead>
|
||||
<TableHead className="min-w-[80px]">세로</TableHead>
|
||||
<TableHead className="min-w-[80px]">두께</TableHead>
|
||||
<TableHead className="min-w-[80px]">면적(㎡)</TableHead>
|
||||
<TableHead className="min-w-[60px]">단위</TableHead>
|
||||
<TableHead className="min-w-[80px]">수량</TableHead>
|
||||
<TableHead className="min-w-[90px]">단가</TableHead>
|
||||
<TableHead className="min-w-[90px]">금액</TableHead>
|
||||
<TableHead className="min-w-[140px]">납기일</TableHead>
|
||||
<TableHead className="w-[36px] sticky left-0 z-20 bg-background"></TableHead>
|
||||
<TableHead className="w-[36px] sticky left-[36px] z-20 bg-background"></TableHead>
|
||||
<TableHead className="w-[45px] sticky left-[72px] z-20 bg-background border-r">No</TableHead>
|
||||
<TableHead className="w-[140px]">구분</TableHead>
|
||||
<TableHead className="w-[200px]">품명</TableHead>
|
||||
<TableHead className="w-[150px]">규격</TableHead>
|
||||
<TableHead className="w-[120px]">가로</TableHead>
|
||||
<TableHead className="w-[120px]">세로</TableHead>
|
||||
<TableHead className="w-[120px]">두께</TableHead>
|
||||
<TableHead className="w-[120px]">면적(㎡)</TableHead>
|
||||
<TableHead className="w-[80px]">단위</TableHead>
|
||||
<TableHead className="w-[120px]">수량</TableHead>
|
||||
<TableHead className="w-[120px]">단가</TableHead>
|
||||
<TableHead className="w-[120px]">금액</TableHead>
|
||||
<TableHead className="w-[160px]">납기일</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -950,26 +950,30 @@ export default function JeilGlassOrderPage() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TableCell className="cursor-grab active:cursor-grabbing">
|
||||
<TableCell className="cursor-grab active:cursor-grabbing sticky left-0 z-10 bg-background">
|
||||
<GripVertical className="w-4 h-4 text-muted-foreground" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCell className="sticky left-[36px] z-10 bg-background">
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-destructive" onClick={() => removeDetailRow(idx)}>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-xs text-muted-foreground">{idx + 1}</TableCell>
|
||||
{/* 구분: 품목검색 → 읽기전용, 행추가 → Select */}
|
||||
<TableCell className="text-center text-xs text-muted-foreground sticky left-[72px] z-10 bg-background border-r">{idx + 1}</TableCell>
|
||||
{/* 구분: 품목검색 → 읽기전용, 행추가 → native select */}
|
||||
<TableCell>
|
||||
{row._fromItemInfo ? (
|
||||
<span className="text-sm px-2">{row._divisionLabel || "-"}</span>
|
||||
) : (
|
||||
<Select value={row.division || ""} onValueChange={(v) => updateDetailRow(idx, "division", v)}>
|
||||
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder="구분" /></SelectTrigger>
|
||||
<SelectContent position="popper" sideOffset={4} className="z-[9999]">
|
||||
{(categoryOptions["item_division"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<select
|
||||
value={row.division || ""}
|
||||
onChange={(e) => updateDetailRow(idx, "division", e.target.value)}
|
||||
className="h-8 w-full rounded-md border border-input bg-transparent px-2 text-sm shadow-xs focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<option value="">구분</option>
|
||||
{(categoryOptions["item_division"] || []).map((o) => (
|
||||
<option key={o.code} value={o.code}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</TableCell>
|
||||
{/* 품명: 품목검색 → 읽기전용, 행추가 → 입력 */}
|
||||
@@ -1033,7 +1037,10 @@ export default function JeilGlassOrderPage() {
|
||||
{/* 합계 행 */}
|
||||
{modalDetailRows.length > 0 && (
|
||||
<TableRow className="bg-muted/30 font-semibold">
|
||||
<TableCell colSpan={11} className="text-right text-sm">합계</TableCell>
|
||||
<TableCell className="sticky left-0 z-10 bg-muted/30"></TableCell>
|
||||
<TableCell className="sticky left-[36px] z-10 bg-muted/30"></TableCell>
|
||||
<TableCell className="sticky left-[72px] z-10 bg-muted/30 border-r"></TableCell>
|
||||
<TableCell colSpan={8} className="text-right text-sm">합계</TableCell>
|
||||
<TableCell className="text-sm text-right">
|
||||
{modalDetailRows.reduce((s, r) => s + (parseFloat(r.qty) || 0), 0).toLocaleString()}
|
||||
</TableCell>
|
||||
|
||||
@@ -9,8 +9,9 @@ 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 { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import {
|
||||
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ChevronDown,
|
||||
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ClipboardList,
|
||||
} from "lucide-react";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -20,19 +21,17 @@ 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 ITEM_TABLE = "item_info";
|
||||
const INSPECTION_TABLE = "inspection_standard";
|
||||
|
||||
const GRID_COLUMNS = [
|
||||
{ key: "item_code", label: "품목코드" },
|
||||
{ key: "item_name", 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: ["수입검사", "입고검사", "수입", "입고"] },
|
||||
@@ -61,25 +60,29 @@ export default function ItemInspectionInfoPage() {
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
const [checkedIds, setCheckedIds] = useState<string[]>([]);
|
||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
|
||||
|
||||
// 우측 패널: 선택된 품목
|
||||
const [selectedItemCode, setSelectedItemCode] = useState<string | null>(null);
|
||||
const [selectedTypeTab, setSelectedTypeTab] = 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 옵션 */
|
||||
// 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 */
|
||||
// 검사유형별 검사항목 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>([]);
|
||||
@@ -110,7 +113,7 @@ export default function ItemInspectionInfoPage() {
|
||||
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 }[] = [];
|
||||
@@ -119,7 +122,7 @@ export default function ItemInspectionInfoPage() {
|
||||
setInspTypeCatOptions(flatCats);
|
||||
} catch { /* skip */ }
|
||||
|
||||
// 검사방법 카테고리 값 로드 (코드→라벨 매핑용)
|
||||
// 검사방법 카테고리
|
||||
try {
|
||||
const methodRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/inspection_method/values`);
|
||||
const flatMethods: { code: string; label: string }[] = [];
|
||||
@@ -139,20 +142,12 @@ export default function ItemInspectionInfoPage() {
|
||||
}, []);
|
||||
|
||||
/* ═══════════════════ 품목 선택 모달 ═══════════════════ */
|
||||
const openItemModal = () => {
|
||||
setItemSearchKeyword("");
|
||||
setFilteredItems(itemOptions);
|
||||
setItemModalOpen(true);
|
||||
};
|
||||
|
||||
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)
|
||||
));
|
||||
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);
|
||||
@@ -196,24 +191,45 @@ export default function ItemInspectionInfoPage() {
|
||||
return Object.values(map);
|
||||
}, [data]);
|
||||
|
||||
// 검사기준 ID → 라벨 resolve
|
||||
// 선택된 품목의 그룹 데이터
|
||||
const selectedGroup = useMemo(() => {
|
||||
if (!selectedItemCode) return null;
|
||||
return groupedData.find(g => g.item_code === selectedItemCode) || null;
|
||||
}, [selectedItemCode, groupedData]);
|
||||
|
||||
// 선택된 탭의 검사항목 행
|
||||
const selectedTabRows = useMemo(() => {
|
||||
if (!selectedGroup || !selectedTypeTab) return [];
|
||||
return selectedGroup.rows.filter((r: any) => r.inspection_type === selectedTypeTab && r.inspection_standard_id);
|
||||
}, [selectedGroup, selectedTypeTab]);
|
||||
|
||||
// 검사기준 ID → 라벨
|
||||
const resolveInspLabel = useCallback((id: string) => {
|
||||
const opt = inspOptions.find(o => o.code === id);
|
||||
return opt?.label || id || "-";
|
||||
return inspOptions.find(o => o.code === id)?.label || id || "-";
|
||||
}, [inspOptions]);
|
||||
|
||||
// 검사방법 코드 → 라벨
|
||||
const resolveMethodLabel = useCallback((code: string) => {
|
||||
return inspMethodCatOptions.find(o => o.code === code)?.label || code || "-";
|
||||
}, [inspMethodCatOptions]);
|
||||
|
||||
/* ═══════════════════ CRUD ═══════════════════ */
|
||||
const openCreate = () => { setForm({}); setEditMode(false); setInspectionRows({}); setCollapsedTypes({}); setModalOpen(true); };
|
||||
const openEdit = async (row: any) => {
|
||||
|
||||
const openEdit = async (itemCode?: string) => {
|
||||
const code = itemCode || selectedItemCode;
|
||||
if (!code) { toast.error("수정할 항목을 선택해주세요"); return; }
|
||||
const group = groupedData.find(g => g.item_code === code);
|
||||
if (!group) return;
|
||||
const row = group.rows[0];
|
||||
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 }] },
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: code }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const allRows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
@@ -222,7 +238,6 @@ export default function ItemInspectionInfoPage() {
|
||||
|
||||
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)))
|
||||
@@ -243,38 +258,22 @@ export default function ItemInspectionInfoPage() {
|
||||
is_required: r.is_required === "true" || r.is_required === true,
|
||||
});
|
||||
}
|
||||
|
||||
setInspectionRows(rowMap);
|
||||
setForm(p => ({ ...p, ...typeFlags }));
|
||||
} catch {
|
||||
setInspectionRows({});
|
||||
}
|
||||
} 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,
|
||||
}],
|
||||
[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),
|
||||
}));
|
||||
setInspectionRows(prev => ({ ...prev, [typeKey]: (prev[typeKey] || []).filter(r => r.id !== rowId) }));
|
||||
};
|
||||
|
||||
const updateInspRow = (typeKey: string, rowId: string, field: string, value: any) => {
|
||||
setInspectionRows(prev => ({
|
||||
...prev,
|
||||
@@ -290,28 +289,19 @@ export default function ItemInspectionInfoPage() {
|
||||
}),
|
||||
}));
|
||||
};
|
||||
|
||||
/** 검사유형 키에 매칭되는 검사기준만 필터링 */
|
||||
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);
|
||||
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 toggleCollapse = (typeKey: string) => { setCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] })); };
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.item_code) { toast.error("품목코드는 필수 입력이에요"); return; }
|
||||
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,
|
||||
@@ -320,54 +310,29 @@ export default function ItemInspectionInfoPage() {
|
||||
});
|
||||
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 })),
|
||||
});
|
||||
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 || "",
|
||||
});
|
||||
rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, 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 || "",
|
||||
id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label,
|
||||
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 ? "품목검사정보를 수정했어요" : "품목검사정보를 등록했어요");
|
||||
for (const row of rows) { await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, row); }
|
||||
toast.success(editMode ? "수정했어요" : "등록했어요");
|
||||
setModalOpen(false);
|
||||
fetchData();
|
||||
} catch { toast.error("저장에 실패했어요"); }
|
||||
@@ -375,157 +340,231 @@ export default function ItemInspectionInfoPage() {
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (checkedIds.length === 0) { toast.error("삭제할 항목을 선택해주세요"); return; }
|
||||
const ok = await confirm("품목검사정보 삭제", {
|
||||
description: `선택한 ${checkedIds.length}건을 삭제할까요? 이 작업은 되돌릴 수 없어요.`,
|
||||
});
|
||||
if (!selectedItemCode) { toast.error("삭제할 품목을 선택해주세요"); return; }
|
||||
const group = groupedData.find(g => g.item_code === selectedItemCode);
|
||||
if (!group) return;
|
||||
const ok = await confirm(`${selectedItemCode} 검사정보 삭제`, { description: "선택한 품목의 검사정보를 모두 삭제할까요?", variant: "destructive", confirmText: "삭제" });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, {
|
||||
data: checkedIds.map(id => ({ id })),
|
||||
});
|
||||
toast.success(`${checkedIds.length}건을 삭제했어요`);
|
||||
setCheckedIds([]);
|
||||
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: group.rows.map((r: any) => ({ id: r.id })) });
|
||||
toast.success("삭제했어요");
|
||||
setSelectedItemCode(null);
|
||||
fetchData();
|
||||
} catch { toast.error("삭제에 실패했어요"); }
|
||||
};
|
||||
|
||||
/* ═══════════════════ JSX ═══════════════════ */
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<div className="flex h-full flex-col">
|
||||
{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 className="shrink-0 px-3 pt-3 pb-2">
|
||||
<DynamicSearchFilter
|
||||
tableName={TABLE_NAME}
|
||||
filterId="c16-item-inspection"
|
||||
onFilterChange={setSearchFilters}
|
||||
externalFilterConfig={ts.filterConfig}
|
||||
dataCount={totalCount}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ═══════════════════ 등록/수정 모달 (품목선택 뷰 포함) ═══════════════════ */}
|
||||
{/* 좌우 분할 패널 */}
|
||||
<div className="flex-1 min-h-0 px-3 pb-3">
|
||||
<ResizablePanelGroup direction="horizontal" className="h-full rounded-lg border">
|
||||
{/* ═══════ 좌측: 품목 목록 ═══════ */}
|
||||
<ResizablePanel defaultSize={50} minSize={30}>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="shrink-0 flex items-center justify-between px-4 py-3 border-b bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardList className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-semibold">품목 목록</span>
|
||||
<Badge variant="secondary" className="text-[10px]">{groupedData.length}건</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button size="sm" className="h-7 text-xs" onClick={openCreate}><Plus className="w-3.5 h-3.5 mr-1" />등록</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" />삭제</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => ts.setOpen(true)}><Settings2 className="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 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">
|
||||
{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>
|
||||
{groupedData.map((group) => (
|
||||
<TableRow
|
||||
key={group.item_code}
|
||||
className={cn("cursor-pointer", selectedItemCode === group.item_code ? "bg-primary/10 border-l-2 border-l-primary" : "hover:bg-muted/50")}
|
||||
onClick={() => {
|
||||
setSelectedItemCode(group.item_code);
|
||||
setSelectedTypeTab(group.types[0] || "");
|
||||
}}
|
||||
>
|
||||
{ts.visibleColumns.map((col) => {
|
||||
switch (col.key) {
|
||||
case "item_code": return <TableCell key={col.key} className="text-sm font-medium text-primary">{group.item_code}</TableCell>;
|
||||
case "item_name": return <TableCell key={col.key} className="text-sm">{group.item_name}</TableCell>;
|
||||
case "inspection_type": return (
|
||||
<TableCell key={col.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 "is_active": return (
|
||||
<TableCell key={col.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={col.key}>{(group as any)[col.key] ?? ""}</TableCell>;
|
||||
}
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 px-4 py-2 border-t text-xs text-muted-foreground">
|
||||
전체 {groupedData.length}건 (품목 기준)
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* ═══════ 우측: 검사유형별 검사항목 ═══════ */}
|
||||
<ResizablePanel defaultSize={50} minSize={30}>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="shrink-0 flex items-center justify-between px-4 py-3 border-b bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold">검사유형별 검사항목</span>
|
||||
</div>
|
||||
{selectedGroup && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" />삭제</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!selectedGroup ? (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center space-y-2">
|
||||
<ClipboardList className="h-8 w-8 mx-auto opacity-30" />
|
||||
<p className="text-sm">좌측에서 품목을 선택해주세요</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
{/* 검사유형 탭 */}
|
||||
<div className="shrink-0 flex items-center gap-2 px-4 py-3">
|
||||
{selectedGroup.types.map((type: string) => {
|
||||
const count = selectedGroup.rows.filter((r: any) => r.inspection_type === type && r.inspection_standard_id).length;
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => setSelectedTypeTab(type)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-colors border",
|
||||
selectedTypeTab === type
|
||||
? "bg-primary text-primary-foreground border-primary"
|
||||
: "bg-muted/50 text-muted-foreground border-border hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
{type}
|
||||
<span className={cn(
|
||||
"inline-flex items-center justify-center h-4 min-w-[16px] rounded-full text-[10px] font-bold px-1",
|
||||
selectedTypeTab === type ? "bg-primary-foreground/20 text-primary-foreground" : "bg-muted-foreground/20 text-muted-foreground"
|
||||
)}>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 검사항목 상세 테이블 */}
|
||||
<div className="flex-1 min-h-0 overflow-auto px-4 pb-4">
|
||||
{selectedTypeTab && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="default" className="text-xs">{selectedTypeTab}</Badge>
|
||||
<span className="text-sm font-medium">검사항목 상세</span>
|
||||
</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 h-8">검사항목</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8">검사기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8">검사방법</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8">적용공정</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8">판단기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8">합격기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8 w-[50px]">필수</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{selectedTabRows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-8 text-xs text-muted-foreground">등록된 검사항목이 없어요</TableCell>
|
||||
</TableRow>
|
||||
) : selectedTabRows.map((row: any) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="text-xs py-2">{row.inspection_item_name || "-"}</TableCell>
|
||||
<TableCell className="text-xs py-2">{resolveInspLabel(row.inspection_standard_id)}</TableCell>
|
||||
<TableCell className="text-xs py-2">{resolveMethodLabel(row.inspection_method)}</TableCell>
|
||||
<TableCell className="text-xs py-2">{row.apply_process || "-"}</TableCell>
|
||||
<TableCell className="text-xs py-2">
|
||||
{row.judgment_criteria ? (
|
||||
<Badge variant="outline" className="text-[10px]">{row.judgment_criteria}</Badge>
|
||||
) : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs py-2 font-mono">{row.pass_criteria || "-"}</TableCell>
|
||||
<TableCell className="text-xs py-2 text-center">
|
||||
{row.is_required === "true" || row.is_required === true ? (
|
||||
<Badge variant="destructive" className="text-[9px]">필수</Badge>
|
||||
) : "-"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</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 ? (
|
||||
@@ -535,16 +574,8 @@ export default function ItemInspectionInfoPage() {
|
||||
<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>
|
||||
<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>
|
||||
@@ -560,11 +591,7 @@ export default function ItemInspectionInfoPage() {
|
||||
{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)}
|
||||
>
|
||||
<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>
|
||||
@@ -574,9 +601,7 @@ export default function ItemInspectionInfoPage() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setItemModalOpen(false)}>취소</Button>
|
||||
</DialogFooter>
|
||||
<DialogFooter><Button variant="outline" onClick={() => setItemModalOpen(false)}>취소</Button></DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -587,14 +612,14 @@ export default function ItemInspectionInfoPage() {
|
||||
<div className="space-y-4">
|
||||
{/* 품목 정보 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold flex items-center gap-1.5">📦 품목 정보</h4>
|
||||
<h4 className="text-sm font-semibold">품목 정보</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>
|
||||
<Label className="text-xs font-semibold text-muted-foreground">품목명</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}>
|
||||
@@ -604,7 +629,7 @@ export default function ItemInspectionInfoPage() {
|
||||
<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" }))}>
|
||||
<Select value={form.is_active === false || form.is_active === "N" ? "N" : "Y"} onValueChange={(v) => setForm(p => ({ ...p, is_active: v === "Y" ? "사용" : "미사용" }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Y">사용</SelectItem>
|
||||
@@ -616,50 +641,32 @@ export default function ItemInspectionInfoPage() {
|
||||
<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>
|
||||
<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>
|
||||
<h4 className="text-sm font-semibold">검사유형 선택</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 }))}
|
||||
/>
|
||||
<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)}
|
||||
>
|
||||
<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")} />
|
||||
<span className="text-xs text-muted-foreground ml-auto">{(inspectionRows[key] || []).length}개</span>
|
||||
</button>
|
||||
{!collapsedTypes[key] && (
|
||||
<div className="space-y-2 pl-1">
|
||||
@@ -674,10 +681,10 @@ export default function ItemInspectionInfoPage() {
|
||||
<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-[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">합격기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[40px]">필수</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[36px]" />
|
||||
</TableRow>
|
||||
@@ -690,46 +697,20 @@ export default function ItemInspectionInfoPage() {
|
||||
<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>
|
||||
<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">
|
||||
<Input className="h-8 text-xs bg-muted" value={row.inspection_detail} readOnly placeholder="검사기준 상세" />
|
||||
<Input className="h-8 text-xs" value={row.apply_process} onChange={(e) => updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input className="h-8 text-xs bg-muted" value={row.inspection_method} readOnly placeholder="선택" />
|
||||
<Input className="h-8 text-xs" value={row.acceptance_criteria} onChange={(e) => updateInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준" 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">
|
||||
<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>
|
||||
<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>
|
||||
))}
|
||||
@@ -744,8 +725,7 @@ export default function ItemInspectionInfoPage() {
|
||||
<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" />}
|
||||
저장
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
@@ -753,14 +733,7 @@ export default function ItemInspectionInfoPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<TableSettingsModal
|
||||
open={ts.open}
|
||||
onOpenChange={ts.setOpen}
|
||||
tableName={ts.tableName}
|
||||
settingsId={ts.settingsId}
|
||||
defaultVisibleKeys={ts.defaultVisibleKeys}
|
||||
onSave={ts.applySettings}
|
||||
/>
|
||||
<TableSettingsModal open={ts.open} onOpenChange={ts.setOpen} tableName={ts.tableName} settingsId={ts.settingsId} defaultVisibleKeys={ts.defaultVisibleKeys} onSave={ts.applySettings} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,8 +9,9 @@ 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 { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import {
|
||||
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ChevronDown,
|
||||
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ClipboardList,
|
||||
} from "lucide-react";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -20,19 +21,17 @@ 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 ITEM_TABLE = "item_info";
|
||||
const INSPECTION_TABLE = "inspection_standard";
|
||||
|
||||
const GRID_COLUMNS = [
|
||||
{ key: "item_code", label: "품목코드" },
|
||||
{ key: "item_name", 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: ["수입검사", "입고검사", "수입", "입고"] },
|
||||
@@ -61,25 +60,29 @@ export default function ItemInspectionInfoPage() {
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
const [checkedIds, setCheckedIds] = useState<string[]>([]);
|
||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
|
||||
|
||||
// 우측 패널: 선택된 품목
|
||||
const [selectedItemCode, setSelectedItemCode] = useState<string | null>(null);
|
||||
const [selectedTypeTab, setSelectedTypeTab] = 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 옵션 */
|
||||
// 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 */
|
||||
// 검사유형별 검사항목 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>([]);
|
||||
@@ -110,7 +113,7 @@ export default function ItemInspectionInfoPage() {
|
||||
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 }[] = [];
|
||||
@@ -119,7 +122,7 @@ export default function ItemInspectionInfoPage() {
|
||||
setInspTypeCatOptions(flatCats);
|
||||
} catch { /* skip */ }
|
||||
|
||||
// 검사방법 카테고리 값 로드 (코드→라벨 매핑용)
|
||||
// 검사방법 카테고리
|
||||
try {
|
||||
const methodRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/inspection_method/values`);
|
||||
const flatMethods: { code: string; label: string }[] = [];
|
||||
@@ -139,20 +142,12 @@ export default function ItemInspectionInfoPage() {
|
||||
}, []);
|
||||
|
||||
/* ═══════════════════ 품목 선택 모달 ═══════════════════ */
|
||||
const openItemModal = () => {
|
||||
setItemSearchKeyword("");
|
||||
setFilteredItems(itemOptions);
|
||||
setItemModalOpen(true);
|
||||
};
|
||||
|
||||
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)
|
||||
));
|
||||
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);
|
||||
@@ -196,24 +191,45 @@ export default function ItemInspectionInfoPage() {
|
||||
return Object.values(map);
|
||||
}, [data]);
|
||||
|
||||
// 검사기준 ID → 라벨 resolve
|
||||
// 선택된 품목의 그룹 데이터
|
||||
const selectedGroup = useMemo(() => {
|
||||
if (!selectedItemCode) return null;
|
||||
return groupedData.find(g => g.item_code === selectedItemCode) || null;
|
||||
}, [selectedItemCode, groupedData]);
|
||||
|
||||
// 선택된 탭의 검사항목 행
|
||||
const selectedTabRows = useMemo(() => {
|
||||
if (!selectedGroup || !selectedTypeTab) return [];
|
||||
return selectedGroup.rows.filter((r: any) => r.inspection_type === selectedTypeTab && r.inspection_standard_id);
|
||||
}, [selectedGroup, selectedTypeTab]);
|
||||
|
||||
// 검사기준 ID → 라벨
|
||||
const resolveInspLabel = useCallback((id: string) => {
|
||||
const opt = inspOptions.find(o => o.code === id);
|
||||
return opt?.label || id || "-";
|
||||
return inspOptions.find(o => o.code === id)?.label || id || "-";
|
||||
}, [inspOptions]);
|
||||
|
||||
// 검사방법 코드 → 라벨
|
||||
const resolveMethodLabel = useCallback((code: string) => {
|
||||
return inspMethodCatOptions.find(o => o.code === code)?.label || code || "-";
|
||||
}, [inspMethodCatOptions]);
|
||||
|
||||
/* ═══════════════════ CRUD ═══════════════════ */
|
||||
const openCreate = () => { setForm({}); setEditMode(false); setInspectionRows({}); setCollapsedTypes({}); setModalOpen(true); };
|
||||
const openEdit = async (row: any) => {
|
||||
|
||||
const openEdit = async (itemCode?: string) => {
|
||||
const code = itemCode || selectedItemCode;
|
||||
if (!code) { toast.error("수정할 항목을 선택해주세요"); return; }
|
||||
const group = groupedData.find(g => g.item_code === code);
|
||||
if (!group) return;
|
||||
const row = group.rows[0];
|
||||
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 }] },
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: code }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const allRows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
@@ -222,7 +238,6 @@ export default function ItemInspectionInfoPage() {
|
||||
|
||||
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)))
|
||||
@@ -243,38 +258,22 @@ export default function ItemInspectionInfoPage() {
|
||||
is_required: r.is_required === "true" || r.is_required === true,
|
||||
});
|
||||
}
|
||||
|
||||
setInspectionRows(rowMap);
|
||||
setForm(p => ({ ...p, ...typeFlags }));
|
||||
} catch {
|
||||
setInspectionRows({});
|
||||
}
|
||||
} 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,
|
||||
}],
|
||||
[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),
|
||||
}));
|
||||
setInspectionRows(prev => ({ ...prev, [typeKey]: (prev[typeKey] || []).filter(r => r.id !== rowId) }));
|
||||
};
|
||||
|
||||
const updateInspRow = (typeKey: string, rowId: string, field: string, value: any) => {
|
||||
setInspectionRows(prev => ({
|
||||
...prev,
|
||||
@@ -290,28 +289,19 @@ export default function ItemInspectionInfoPage() {
|
||||
}),
|
||||
}));
|
||||
};
|
||||
|
||||
/** 검사유형 키에 매칭되는 검사기준만 필터링 */
|
||||
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);
|
||||
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 toggleCollapse = (typeKey: string) => { setCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] })); };
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.item_code) { toast.error("품목코드는 필수 입력이에요"); return; }
|
||||
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,
|
||||
@@ -320,54 +310,29 @@ export default function ItemInspectionInfoPage() {
|
||||
});
|
||||
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 })),
|
||||
});
|
||||
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 || "",
|
||||
});
|
||||
rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, 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 || "",
|
||||
id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label,
|
||||
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 ? "품목검사정보를 수정했어요" : "품목검사정보를 등록했어요");
|
||||
for (const row of rows) { await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, row); }
|
||||
toast.success(editMode ? "수정했어요" : "등록했어요");
|
||||
setModalOpen(false);
|
||||
fetchData();
|
||||
} catch { toast.error("저장에 실패했어요"); }
|
||||
@@ -375,157 +340,231 @@ export default function ItemInspectionInfoPage() {
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (checkedIds.length === 0) { toast.error("삭제할 항목을 선택해주세요"); return; }
|
||||
const ok = await confirm("품목검사정보 삭제", {
|
||||
description: `선택한 ${checkedIds.length}건을 삭제할까요? 이 작업은 되돌릴 수 없어요.`,
|
||||
});
|
||||
if (!selectedItemCode) { toast.error("삭제할 품목을 선택해주세요"); return; }
|
||||
const group = groupedData.find(g => g.item_code === selectedItemCode);
|
||||
if (!group) return;
|
||||
const ok = await confirm(`${selectedItemCode} 검사정보 삭제`, { description: "선택한 품목의 검사정보를 모두 삭제할까요?", variant: "destructive", confirmText: "삭제" });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, {
|
||||
data: checkedIds.map(id => ({ id })),
|
||||
});
|
||||
toast.success(`${checkedIds.length}건을 삭제했어요`);
|
||||
setCheckedIds([]);
|
||||
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: group.rows.map((r: any) => ({ id: r.id })) });
|
||||
toast.success("삭제했어요");
|
||||
setSelectedItemCode(null);
|
||||
fetchData();
|
||||
} catch { toast.error("삭제에 실패했어요"); }
|
||||
};
|
||||
|
||||
/* ═══════════════════ JSX ═══════════════════ */
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<div className="flex h-full flex-col">
|
||||
{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 className="shrink-0 px-3 pt-3 pb-2">
|
||||
<DynamicSearchFilter
|
||||
tableName={TABLE_NAME}
|
||||
filterId="c16-item-inspection"
|
||||
onFilterChange={setSearchFilters}
|
||||
externalFilterConfig={ts.filterConfig}
|
||||
dataCount={totalCount}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ═══════════════════ 등록/수정 모달 (품목선택 뷰 포함) ═══════════════════ */}
|
||||
{/* 좌우 분할 패널 */}
|
||||
<div className="flex-1 min-h-0 px-3 pb-3">
|
||||
<ResizablePanelGroup direction="horizontal" className="h-full rounded-lg border">
|
||||
{/* ═══════ 좌측: 품목 목록 ═══════ */}
|
||||
<ResizablePanel defaultSize={50} minSize={30}>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="shrink-0 flex items-center justify-between px-4 py-3 border-b bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardList className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-semibold">품목 목록</span>
|
||||
<Badge variant="secondary" className="text-[10px]">{groupedData.length}건</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button size="sm" className="h-7 text-xs" onClick={openCreate}><Plus className="w-3.5 h-3.5 mr-1" />등록</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" />삭제</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => ts.setOpen(true)}><Settings2 className="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 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">
|
||||
{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>
|
||||
{groupedData.map((group) => (
|
||||
<TableRow
|
||||
key={group.item_code}
|
||||
className={cn("cursor-pointer", selectedItemCode === group.item_code ? "bg-primary/10 border-l-2 border-l-primary" : "hover:bg-muted/50")}
|
||||
onClick={() => {
|
||||
setSelectedItemCode(group.item_code);
|
||||
setSelectedTypeTab(group.types[0] || "");
|
||||
}}
|
||||
>
|
||||
{ts.visibleColumns.map((col) => {
|
||||
switch (col.key) {
|
||||
case "item_code": return <TableCell key={col.key} className="text-sm font-medium text-primary">{group.item_code}</TableCell>;
|
||||
case "item_name": return <TableCell key={col.key} className="text-sm">{group.item_name}</TableCell>;
|
||||
case "inspection_type": return (
|
||||
<TableCell key={col.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 "is_active": return (
|
||||
<TableCell key={col.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={col.key}>{(group as any)[col.key] ?? ""}</TableCell>;
|
||||
}
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 px-4 py-2 border-t text-xs text-muted-foreground">
|
||||
전체 {groupedData.length}건 (품목 기준)
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* ═══════ 우측: 검사유형별 검사항목 ═══════ */}
|
||||
<ResizablePanel defaultSize={50} minSize={30}>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="shrink-0 flex items-center justify-between px-4 py-3 border-b bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold">검사유형별 검사항목</span>
|
||||
</div>
|
||||
{selectedGroup && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" />삭제</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!selectedGroup ? (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center space-y-2">
|
||||
<ClipboardList className="h-8 w-8 mx-auto opacity-30" />
|
||||
<p className="text-sm">좌측에서 품목을 선택해주세요</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
{/* 검사유형 탭 */}
|
||||
<div className="shrink-0 flex items-center gap-2 px-4 py-3">
|
||||
{selectedGroup.types.map((type: string) => {
|
||||
const count = selectedGroup.rows.filter((r: any) => r.inspection_type === type && r.inspection_standard_id).length;
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => setSelectedTypeTab(type)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-colors border",
|
||||
selectedTypeTab === type
|
||||
? "bg-primary text-primary-foreground border-primary"
|
||||
: "bg-muted/50 text-muted-foreground border-border hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
{type}
|
||||
<span className={cn(
|
||||
"inline-flex items-center justify-center h-4 min-w-[16px] rounded-full text-[10px] font-bold px-1",
|
||||
selectedTypeTab === type ? "bg-primary-foreground/20 text-primary-foreground" : "bg-muted-foreground/20 text-muted-foreground"
|
||||
)}>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 검사항목 상세 테이블 */}
|
||||
<div className="flex-1 min-h-0 overflow-auto px-4 pb-4">
|
||||
{selectedTypeTab && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="default" className="text-xs">{selectedTypeTab}</Badge>
|
||||
<span className="text-sm font-medium">검사항목 상세</span>
|
||||
</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 h-8">검사항목</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8">검사기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8">검사방법</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8">적용공정</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8">판단기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8">합격기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8 w-[50px]">필수</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{selectedTabRows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-8 text-xs text-muted-foreground">등록된 검사항목이 없어요</TableCell>
|
||||
</TableRow>
|
||||
) : selectedTabRows.map((row: any) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="text-xs py-2">{row.inspection_item_name || "-"}</TableCell>
|
||||
<TableCell className="text-xs py-2">{resolveInspLabel(row.inspection_standard_id)}</TableCell>
|
||||
<TableCell className="text-xs py-2">{resolveMethodLabel(row.inspection_method)}</TableCell>
|
||||
<TableCell className="text-xs py-2">{row.apply_process || "-"}</TableCell>
|
||||
<TableCell className="text-xs py-2">
|
||||
{row.judgment_criteria ? (
|
||||
<Badge variant="outline" className="text-[10px]">{row.judgment_criteria}</Badge>
|
||||
) : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs py-2 font-mono">{row.pass_criteria || "-"}</TableCell>
|
||||
<TableCell className="text-xs py-2 text-center">
|
||||
{row.is_required === "true" || row.is_required === true ? (
|
||||
<Badge variant="destructive" className="text-[9px]">필수</Badge>
|
||||
) : "-"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</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 ? (
|
||||
@@ -535,16 +574,8 @@ export default function ItemInspectionInfoPage() {
|
||||
<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>
|
||||
<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>
|
||||
@@ -560,11 +591,7 @@ export default function ItemInspectionInfoPage() {
|
||||
{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)}
|
||||
>
|
||||
<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>
|
||||
@@ -574,9 +601,7 @@ export default function ItemInspectionInfoPage() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setItemModalOpen(false)}>취소</Button>
|
||||
</DialogFooter>
|
||||
<DialogFooter><Button variant="outline" onClick={() => setItemModalOpen(false)}>취소</Button></DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -587,14 +612,14 @@ export default function ItemInspectionInfoPage() {
|
||||
<div className="space-y-4">
|
||||
{/* 품목 정보 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold flex items-center gap-1.5">📦 품목 정보</h4>
|
||||
<h4 className="text-sm font-semibold">품목 정보</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>
|
||||
<Label className="text-xs font-semibold text-muted-foreground">품목명</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}>
|
||||
@@ -604,7 +629,7 @@ export default function ItemInspectionInfoPage() {
|
||||
<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" }))}>
|
||||
<Select value={form.is_active === false || form.is_active === "N" ? "N" : "Y"} onValueChange={(v) => setForm(p => ({ ...p, is_active: v === "Y" ? "사용" : "미사용" }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Y">사용</SelectItem>
|
||||
@@ -616,50 +641,32 @@ export default function ItemInspectionInfoPage() {
|
||||
<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>
|
||||
<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>
|
||||
<h4 className="text-sm font-semibold">검사유형 선택</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 }))}
|
||||
/>
|
||||
<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)}
|
||||
>
|
||||
<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")} />
|
||||
<span className="text-xs text-muted-foreground ml-auto">{(inspectionRows[key] || []).length}개</span>
|
||||
</button>
|
||||
{!collapsedTypes[key] && (
|
||||
<div className="space-y-2 pl-1">
|
||||
@@ -674,10 +681,10 @@ export default function ItemInspectionInfoPage() {
|
||||
<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-[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">합격기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[40px]">필수</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[36px]" />
|
||||
</TableRow>
|
||||
@@ -690,46 +697,20 @@ export default function ItemInspectionInfoPage() {
|
||||
<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>
|
||||
<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">
|
||||
<Input className="h-8 text-xs bg-muted" value={row.inspection_detail} readOnly placeholder="검사기준 상세" />
|
||||
<Input className="h-8 text-xs" value={row.apply_process} onChange={(e) => updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input className="h-8 text-xs bg-muted" value={row.inspection_method} readOnly placeholder="선택" />
|
||||
<Input className="h-8 text-xs" value={row.acceptance_criteria} onChange={(e) => updateInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준" 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">
|
||||
<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>
|
||||
<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>
|
||||
))}
|
||||
@@ -744,8 +725,7 @@ export default function ItemInspectionInfoPage() {
|
||||
<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" />}
|
||||
저장
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
@@ -753,14 +733,7 @@ export default function ItemInspectionInfoPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<TableSettingsModal
|
||||
open={ts.open}
|
||||
onOpenChange={ts.setOpen}
|
||||
tableName={ts.tableName}
|
||||
settingsId={ts.settingsId}
|
||||
defaultVisibleKeys={ts.defaultVisibleKeys}
|
||||
onSave={ts.applySettings}
|
||||
/>
|
||||
<TableSettingsModal open={ts.open} onOpenChange={ts.setOpen} tableName={ts.tableName} settingsId={ts.settingsId} defaultVisibleKeys={ts.defaultVisibleKeys} onSave={ts.applySettings} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,8 +9,9 @@ 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 { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import {
|
||||
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ChevronDown,
|
||||
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ClipboardList,
|
||||
} from "lucide-react";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -20,19 +21,17 @@ 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 ITEM_TABLE = "item_info";
|
||||
const INSPECTION_TABLE = "inspection_standard";
|
||||
|
||||
const GRID_COLUMNS = [
|
||||
{ key: "item_code", label: "품목코드" },
|
||||
{ key: "item_name", 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: ["수입검사", "입고검사", "수입", "입고"] },
|
||||
@@ -61,25 +60,29 @@ export default function ItemInspectionInfoPage() {
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
const [checkedIds, setCheckedIds] = useState<string[]>([]);
|
||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
|
||||
|
||||
// 우측 패널: 선택된 품목
|
||||
const [selectedItemCode, setSelectedItemCode] = useState<string | null>(null);
|
||||
const [selectedTypeTab, setSelectedTypeTab] = 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 옵션 */
|
||||
// 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 */
|
||||
// 검사유형별 검사항목 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>([]);
|
||||
@@ -110,7 +113,7 @@ export default function ItemInspectionInfoPage() {
|
||||
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 }[] = [];
|
||||
@@ -119,7 +122,7 @@ export default function ItemInspectionInfoPage() {
|
||||
setInspTypeCatOptions(flatCats);
|
||||
} catch { /* skip */ }
|
||||
|
||||
// 검사방법 카테고리 값 로드 (코드→라벨 매핑용)
|
||||
// 검사방법 카테고리
|
||||
try {
|
||||
const methodRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/inspection_method/values`);
|
||||
const flatMethods: { code: string; label: string }[] = [];
|
||||
@@ -139,20 +142,12 @@ export default function ItemInspectionInfoPage() {
|
||||
}, []);
|
||||
|
||||
/* ═══════════════════ 품목 선택 모달 ═══════════════════ */
|
||||
const openItemModal = () => {
|
||||
setItemSearchKeyword("");
|
||||
setFilteredItems(itemOptions);
|
||||
setItemModalOpen(true);
|
||||
};
|
||||
|
||||
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)
|
||||
));
|
||||
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);
|
||||
@@ -196,24 +191,45 @@ export default function ItemInspectionInfoPage() {
|
||||
return Object.values(map);
|
||||
}, [data]);
|
||||
|
||||
// 검사기준 ID → 라벨 resolve
|
||||
// 선택된 품목의 그룹 데이터
|
||||
const selectedGroup = useMemo(() => {
|
||||
if (!selectedItemCode) return null;
|
||||
return groupedData.find(g => g.item_code === selectedItemCode) || null;
|
||||
}, [selectedItemCode, groupedData]);
|
||||
|
||||
// 선택된 탭의 검사항목 행
|
||||
const selectedTabRows = useMemo(() => {
|
||||
if (!selectedGroup || !selectedTypeTab) return [];
|
||||
return selectedGroup.rows.filter((r: any) => r.inspection_type === selectedTypeTab && r.inspection_standard_id);
|
||||
}, [selectedGroup, selectedTypeTab]);
|
||||
|
||||
// 검사기준 ID → 라벨
|
||||
const resolveInspLabel = useCallback((id: string) => {
|
||||
const opt = inspOptions.find(o => o.code === id);
|
||||
return opt?.label || id || "-";
|
||||
return inspOptions.find(o => o.code === id)?.label || id || "-";
|
||||
}, [inspOptions]);
|
||||
|
||||
// 검사방법 코드 → 라벨
|
||||
const resolveMethodLabel = useCallback((code: string) => {
|
||||
return inspMethodCatOptions.find(o => o.code === code)?.label || code || "-";
|
||||
}, [inspMethodCatOptions]);
|
||||
|
||||
/* ═══════════════════ CRUD ═══════════════════ */
|
||||
const openCreate = () => { setForm({}); setEditMode(false); setInspectionRows({}); setCollapsedTypes({}); setModalOpen(true); };
|
||||
const openEdit = async (row: any) => {
|
||||
|
||||
const openEdit = async (itemCode?: string) => {
|
||||
const code = itemCode || selectedItemCode;
|
||||
if (!code) { toast.error("수정할 항목을 선택해주세요"); return; }
|
||||
const group = groupedData.find(g => g.item_code === code);
|
||||
if (!group) return;
|
||||
const row = group.rows[0];
|
||||
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 }] },
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: code }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const allRows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
@@ -222,7 +238,6 @@ export default function ItemInspectionInfoPage() {
|
||||
|
||||
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)))
|
||||
@@ -243,38 +258,22 @@ export default function ItemInspectionInfoPage() {
|
||||
is_required: r.is_required === "true" || r.is_required === true,
|
||||
});
|
||||
}
|
||||
|
||||
setInspectionRows(rowMap);
|
||||
setForm(p => ({ ...p, ...typeFlags }));
|
||||
} catch {
|
||||
setInspectionRows({});
|
||||
}
|
||||
} 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,
|
||||
}],
|
||||
[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),
|
||||
}));
|
||||
setInspectionRows(prev => ({ ...prev, [typeKey]: (prev[typeKey] || []).filter(r => r.id !== rowId) }));
|
||||
};
|
||||
|
||||
const updateInspRow = (typeKey: string, rowId: string, field: string, value: any) => {
|
||||
setInspectionRows(prev => ({
|
||||
...prev,
|
||||
@@ -290,28 +289,19 @@ export default function ItemInspectionInfoPage() {
|
||||
}),
|
||||
}));
|
||||
};
|
||||
|
||||
/** 검사유형 키에 매칭되는 검사기준만 필터링 */
|
||||
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);
|
||||
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 toggleCollapse = (typeKey: string) => { setCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] })); };
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.item_code) { toast.error("품목코드는 필수 입력이에요"); return; }
|
||||
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,
|
||||
@@ -320,54 +310,29 @@ export default function ItemInspectionInfoPage() {
|
||||
});
|
||||
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 })),
|
||||
});
|
||||
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 || "",
|
||||
});
|
||||
rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, 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 || "",
|
||||
id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label,
|
||||
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 ? "품목검사정보를 수정했어요" : "품목검사정보를 등록했어요");
|
||||
for (const row of rows) { await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, row); }
|
||||
toast.success(editMode ? "수정했어요" : "등록했어요");
|
||||
setModalOpen(false);
|
||||
fetchData();
|
||||
} catch { toast.error("저장에 실패했어요"); }
|
||||
@@ -375,157 +340,231 @@ export default function ItemInspectionInfoPage() {
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (checkedIds.length === 0) { toast.error("삭제할 항목을 선택해주세요"); return; }
|
||||
const ok = await confirm("품목검사정보 삭제", {
|
||||
description: `선택한 ${checkedIds.length}건을 삭제할까요? 이 작업은 되돌릴 수 없어요.`,
|
||||
});
|
||||
if (!selectedItemCode) { toast.error("삭제할 품목을 선택해주세요"); return; }
|
||||
const group = groupedData.find(g => g.item_code === selectedItemCode);
|
||||
if (!group) return;
|
||||
const ok = await confirm(`${selectedItemCode} 검사정보 삭제`, { description: "선택한 품목의 검사정보를 모두 삭제할까요?", variant: "destructive", confirmText: "삭제" });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, {
|
||||
data: checkedIds.map(id => ({ id })),
|
||||
});
|
||||
toast.success(`${checkedIds.length}건을 삭제했어요`);
|
||||
setCheckedIds([]);
|
||||
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: group.rows.map((r: any) => ({ id: r.id })) });
|
||||
toast.success("삭제했어요");
|
||||
setSelectedItemCode(null);
|
||||
fetchData();
|
||||
} catch { toast.error("삭제에 실패했어요"); }
|
||||
};
|
||||
|
||||
/* ═══════════════════ JSX ═══════════════════ */
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<div className="flex h-full flex-col">
|
||||
{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 className="shrink-0 px-3 pt-3 pb-2">
|
||||
<DynamicSearchFilter
|
||||
tableName={TABLE_NAME}
|
||||
filterId="c16-item-inspection"
|
||||
onFilterChange={setSearchFilters}
|
||||
externalFilterConfig={ts.filterConfig}
|
||||
dataCount={totalCount}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ═══════════════════ 등록/수정 모달 (품목선택 뷰 포함) ═══════════════════ */}
|
||||
{/* 좌우 분할 패널 */}
|
||||
<div className="flex-1 min-h-0 px-3 pb-3">
|
||||
<ResizablePanelGroup direction="horizontal" className="h-full rounded-lg border">
|
||||
{/* ═══════ 좌측: 품목 목록 ═══════ */}
|
||||
<ResizablePanel defaultSize={50} minSize={30}>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="shrink-0 flex items-center justify-between px-4 py-3 border-b bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardList className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-semibold">품목 목록</span>
|
||||
<Badge variant="secondary" className="text-[10px]">{groupedData.length}건</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button size="sm" className="h-7 text-xs" onClick={openCreate}><Plus className="w-3.5 h-3.5 mr-1" />등록</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" />삭제</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => ts.setOpen(true)}><Settings2 className="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 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">
|
||||
{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>
|
||||
{groupedData.map((group) => (
|
||||
<TableRow
|
||||
key={group.item_code}
|
||||
className={cn("cursor-pointer", selectedItemCode === group.item_code ? "bg-primary/10 border-l-2 border-l-primary" : "hover:bg-muted/50")}
|
||||
onClick={() => {
|
||||
setSelectedItemCode(group.item_code);
|
||||
setSelectedTypeTab(group.types[0] || "");
|
||||
}}
|
||||
>
|
||||
{ts.visibleColumns.map((col) => {
|
||||
switch (col.key) {
|
||||
case "item_code": return <TableCell key={col.key} className="text-sm font-medium text-primary">{group.item_code}</TableCell>;
|
||||
case "item_name": return <TableCell key={col.key} className="text-sm">{group.item_name}</TableCell>;
|
||||
case "inspection_type": return (
|
||||
<TableCell key={col.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 "is_active": return (
|
||||
<TableCell key={col.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={col.key}>{(group as any)[col.key] ?? ""}</TableCell>;
|
||||
}
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 px-4 py-2 border-t text-xs text-muted-foreground">
|
||||
전체 {groupedData.length}건 (품목 기준)
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* ═══════ 우측: 검사유형별 검사항목 ═══════ */}
|
||||
<ResizablePanel defaultSize={50} minSize={30}>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="shrink-0 flex items-center justify-between px-4 py-3 border-b bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold">검사유형별 검사항목</span>
|
||||
</div>
|
||||
{selectedGroup && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" />삭제</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!selectedGroup ? (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center space-y-2">
|
||||
<ClipboardList className="h-8 w-8 mx-auto opacity-30" />
|
||||
<p className="text-sm">좌측에서 품목을 선택해주세요</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
{/* 검사유형 탭 */}
|
||||
<div className="shrink-0 flex items-center gap-2 px-4 py-3">
|
||||
{selectedGroup.types.map((type: string) => {
|
||||
const count = selectedGroup.rows.filter((r: any) => r.inspection_type === type && r.inspection_standard_id).length;
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => setSelectedTypeTab(type)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-colors border",
|
||||
selectedTypeTab === type
|
||||
? "bg-primary text-primary-foreground border-primary"
|
||||
: "bg-muted/50 text-muted-foreground border-border hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
{type}
|
||||
<span className={cn(
|
||||
"inline-flex items-center justify-center h-4 min-w-[16px] rounded-full text-[10px] font-bold px-1",
|
||||
selectedTypeTab === type ? "bg-primary-foreground/20 text-primary-foreground" : "bg-muted-foreground/20 text-muted-foreground"
|
||||
)}>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 검사항목 상세 테이블 */}
|
||||
<div className="flex-1 min-h-0 overflow-auto px-4 pb-4">
|
||||
{selectedTypeTab && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="default" className="text-xs">{selectedTypeTab}</Badge>
|
||||
<span className="text-sm font-medium">검사항목 상세</span>
|
||||
</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 h-8">검사항목</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8">검사기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8">검사방법</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8">적용공정</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8">판단기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8">합격기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8 w-[50px]">필수</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{selectedTabRows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-8 text-xs text-muted-foreground">등록된 검사항목이 없어요</TableCell>
|
||||
</TableRow>
|
||||
) : selectedTabRows.map((row: any) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="text-xs py-2">{row.inspection_item_name || "-"}</TableCell>
|
||||
<TableCell className="text-xs py-2">{resolveInspLabel(row.inspection_standard_id)}</TableCell>
|
||||
<TableCell className="text-xs py-2">{resolveMethodLabel(row.inspection_method)}</TableCell>
|
||||
<TableCell className="text-xs py-2">{row.apply_process || "-"}</TableCell>
|
||||
<TableCell className="text-xs py-2">
|
||||
{row.judgment_criteria ? (
|
||||
<Badge variant="outline" className="text-[10px]">{row.judgment_criteria}</Badge>
|
||||
) : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs py-2 font-mono">{row.pass_criteria || "-"}</TableCell>
|
||||
<TableCell className="text-xs py-2 text-center">
|
||||
{row.is_required === "true" || row.is_required === true ? (
|
||||
<Badge variant="destructive" className="text-[9px]">필수</Badge>
|
||||
) : "-"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</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 ? (
|
||||
@@ -535,16 +574,8 @@ export default function ItemInspectionInfoPage() {
|
||||
<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>
|
||||
<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>
|
||||
@@ -560,11 +591,7 @@ export default function ItemInspectionInfoPage() {
|
||||
{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)}
|
||||
>
|
||||
<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>
|
||||
@@ -574,9 +601,7 @@ export default function ItemInspectionInfoPage() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setItemModalOpen(false)}>취소</Button>
|
||||
</DialogFooter>
|
||||
<DialogFooter><Button variant="outline" onClick={() => setItemModalOpen(false)}>취소</Button></DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -587,14 +612,14 @@ export default function ItemInspectionInfoPage() {
|
||||
<div className="space-y-4">
|
||||
{/* 품목 정보 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold flex items-center gap-1.5">📦 품목 정보</h4>
|
||||
<h4 className="text-sm font-semibold">품목 정보</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>
|
||||
<Label className="text-xs font-semibold text-muted-foreground">품목명</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}>
|
||||
@@ -604,7 +629,7 @@ export default function ItemInspectionInfoPage() {
|
||||
<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" }))}>
|
||||
<Select value={form.is_active === false || form.is_active === "N" ? "N" : "Y"} onValueChange={(v) => setForm(p => ({ ...p, is_active: v === "Y" ? "사용" : "미사용" }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Y">사용</SelectItem>
|
||||
@@ -616,50 +641,32 @@ export default function ItemInspectionInfoPage() {
|
||||
<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>
|
||||
<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>
|
||||
<h4 className="text-sm font-semibold">검사유형 선택</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 }))}
|
||||
/>
|
||||
<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)}
|
||||
>
|
||||
<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")} />
|
||||
<span className="text-xs text-muted-foreground ml-auto">{(inspectionRows[key] || []).length}개</span>
|
||||
</button>
|
||||
{!collapsedTypes[key] && (
|
||||
<div className="space-y-2 pl-1">
|
||||
@@ -674,10 +681,10 @@ export default function ItemInspectionInfoPage() {
|
||||
<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-[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">합격기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[40px]">필수</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[36px]" />
|
||||
</TableRow>
|
||||
@@ -690,46 +697,20 @@ export default function ItemInspectionInfoPage() {
|
||||
<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>
|
||||
<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">
|
||||
<Input className="h-8 text-xs bg-muted" value={row.inspection_detail} readOnly placeholder="검사기준 상세" />
|
||||
<Input className="h-8 text-xs" value={row.apply_process} onChange={(e) => updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input className="h-8 text-xs bg-muted" value={row.inspection_method} readOnly placeholder="선택" />
|
||||
<Input className="h-8 text-xs" value={row.acceptance_criteria} onChange={(e) => updateInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준" 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">
|
||||
<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>
|
||||
<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>
|
||||
))}
|
||||
@@ -744,8 +725,7 @@ export default function ItemInspectionInfoPage() {
|
||||
<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" />}
|
||||
저장
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
@@ -753,14 +733,7 @@ export default function ItemInspectionInfoPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<TableSettingsModal
|
||||
open={ts.open}
|
||||
onOpenChange={ts.setOpen}
|
||||
tableName={ts.tableName}
|
||||
settingsId={ts.settingsId}
|
||||
defaultVisibleKeys={ts.defaultVisibleKeys}
|
||||
onSave={ts.applySettings}
|
||||
/>
|
||||
<TableSettingsModal open={ts.open} onOpenChange={ts.setOpen} tableName={ts.tableName} settingsId={ts.settingsId} defaultVisibleKeys={ts.defaultVisibleKeys} onSave={ts.applySettings} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -905,24 +905,24 @@ export default function JeilGlassOrderPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto overflow-y-visible">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-background z-10">
|
||||
<Table className="min-w-[1800px]">
|
||||
<TableHeader className="sticky top-0 bg-background z-20">
|
||||
<TableRow>
|
||||
<TableHead className="w-[30px]"></TableHead>
|
||||
<TableHead className="w-[36px]"></TableHead>
|
||||
<TableHead className="w-[40px]">No</TableHead>
|
||||
<TableHead className="min-w-[90px]">구분</TableHead>
|
||||
<TableHead className="min-w-[150px]">품명</TableHead>
|
||||
<TableHead className="min-w-[100px]">규격</TableHead>
|
||||
<TableHead className="min-w-[80px]">가로</TableHead>
|
||||
<TableHead className="min-w-[80px]">세로</TableHead>
|
||||
<TableHead className="min-w-[80px]">두께</TableHead>
|
||||
<TableHead className="min-w-[80px]">면적(㎡)</TableHead>
|
||||
<TableHead className="min-w-[60px]">단위</TableHead>
|
||||
<TableHead className="min-w-[80px]">수량</TableHead>
|
||||
<TableHead className="min-w-[90px]">단가</TableHead>
|
||||
<TableHead className="min-w-[90px]">금액</TableHead>
|
||||
<TableHead className="min-w-[140px]">납기일</TableHead>
|
||||
<TableHead className="w-[36px] sticky left-0 z-20 bg-background"></TableHead>
|
||||
<TableHead className="w-[36px] sticky left-[36px] z-20 bg-background"></TableHead>
|
||||
<TableHead className="w-[45px] sticky left-[72px] z-20 bg-background border-r">No</TableHead>
|
||||
<TableHead className="w-[140px]">구분</TableHead>
|
||||
<TableHead className="w-[200px]">품명</TableHead>
|
||||
<TableHead className="w-[150px]">규격</TableHead>
|
||||
<TableHead className="w-[120px]">가로</TableHead>
|
||||
<TableHead className="w-[120px]">세로</TableHead>
|
||||
<TableHead className="w-[120px]">두께</TableHead>
|
||||
<TableHead className="w-[120px]">면적(㎡)</TableHead>
|
||||
<TableHead className="w-[80px]">단위</TableHead>
|
||||
<TableHead className="w-[120px]">수량</TableHead>
|
||||
<TableHead className="w-[120px]">단가</TableHead>
|
||||
<TableHead className="w-[120px]">금액</TableHead>
|
||||
<TableHead className="w-[160px]">납기일</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -950,26 +950,30 @@ export default function JeilGlassOrderPage() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TableCell className="cursor-grab active:cursor-grabbing">
|
||||
<TableCell className="cursor-grab active:cursor-grabbing sticky left-0 z-10 bg-background">
|
||||
<GripVertical className="w-4 h-4 text-muted-foreground" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCell className="sticky left-[36px] z-10 bg-background">
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-destructive" onClick={() => removeDetailRow(idx)}>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-xs text-muted-foreground">{idx + 1}</TableCell>
|
||||
{/* 구분: 품목검색 → 읽기전용, 행추가 → Select */}
|
||||
<TableCell className="text-center text-xs text-muted-foreground sticky left-[72px] z-10 bg-background border-r">{idx + 1}</TableCell>
|
||||
{/* 구분: 품목검색 → 읽기전용, 행추가 → native select */}
|
||||
<TableCell>
|
||||
{row._fromItemInfo ? (
|
||||
<span className="text-sm px-2">{row._divisionLabel || "-"}</span>
|
||||
) : (
|
||||
<Select value={row.division || ""} onValueChange={(v) => updateDetailRow(idx, "division", v)}>
|
||||
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder="구분" /></SelectTrigger>
|
||||
<SelectContent position="popper" sideOffset={4} className="z-[9999]">
|
||||
{(categoryOptions["item_division"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<select
|
||||
value={row.division || ""}
|
||||
onChange={(e) => updateDetailRow(idx, "division", e.target.value)}
|
||||
className="h-8 w-full rounded-md border border-input bg-transparent px-2 text-sm shadow-xs focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<option value="">구분</option>
|
||||
{(categoryOptions["item_division"] || []).map((o) => (
|
||||
<option key={o.code} value={o.code}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</TableCell>
|
||||
{/* 품명: 품목검색 → 읽기전용, 행추가 → 입력 */}
|
||||
@@ -1033,7 +1037,10 @@ export default function JeilGlassOrderPage() {
|
||||
{/* 합계 행 */}
|
||||
{modalDetailRows.length > 0 && (
|
||||
<TableRow className="bg-muted/30 font-semibold">
|
||||
<TableCell colSpan={11} className="text-right text-sm">합계</TableCell>
|
||||
<TableCell className="sticky left-0 z-10 bg-muted/30"></TableCell>
|
||||
<TableCell className="sticky left-[36px] z-10 bg-muted/30"></TableCell>
|
||||
<TableCell className="sticky left-[72px] z-10 bg-muted/30 border-r"></TableCell>
|
||||
<TableCell colSpan={8} className="text-right text-sm">합계</TableCell>
|
||||
<TableCell className="text-sm text-right">
|
||||
{modalDetailRows.reduce((s, r) => s + (parseFloat(r.qty) || 0), 0).toLocaleString()}
|
||||
</TableCell>
|
||||
|
||||
Reference in New Issue
Block a user