dffa16f3e5
- Introduced a new SmartExcelUploadModal component to facilitate bulk item inspection uploads via Excel. - Implemented logic for downloading templates, validating uploaded files, and parsing data for inspection criteria. - Enhanced the item inspection page to support dynamic loading of item process mappings and reference data for improved user experience. - Added necessary types and utility functions for template generation and parsing, ensuring robust handling of Excel data. - These changes aim to streamline the item inspection process and improve data management across multiple company implementations.
1191 lines
68 KiB
TypeScript
1191 lines
68 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
|
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
|
import {
|
|
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ClipboardList,
|
|
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, FileSpreadsheet,
|
|
} from "lucide-react";
|
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
|
import { cn } from "@/lib/utils";
|
|
import { apiClient } from "@/lib/api/client";
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
import { useTableSettings } from "@/hooks/useTableSettings";
|
|
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
|
import { toast } from "sonner";
|
|
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
|
import { SmartExcelUploadModal } from "@/components/common/SmartExcelUpload";
|
|
import type { SmartExcelUploadConfig, ParsedSheetData } from "@/components/common/SmartExcelUpload";
|
|
|
|
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: "inspection_type", label: "검사유형" },
|
|
{ key: "is_active", label: "사용여부" },
|
|
];
|
|
|
|
const INSPECTION_TYPES = [
|
|
{ key: "incoming_inspection", label: "수입검사", matchLabels: ["수입검사", "입고검사", "수입", "입고"] },
|
|
{ key: "outgoing_inspection", label: "출하검사", matchLabels: ["출하검사", "출고검사", "출하", "출고"] },
|
|
{ key: "process_inspection", label: "공정검사", matchLabels: ["공정검사", "공정"] },
|
|
{ key: "final_inspection", label: "최종검사", matchLabels: ["최종검사", "최종", "완제품검사"] },
|
|
] as const;
|
|
|
|
type InspectionRow = {
|
|
id: string;
|
|
inspection_standard_id: string;
|
|
inspection_detail: string;
|
|
inspection_method: string;
|
|
apply_process: string;
|
|
acceptance_criteria: string;
|
|
is_required: boolean;
|
|
judgment_criteria?: string; // 판단기준 라벨 (수치(범위)/텍스트입력/O·X/선택형)
|
|
selection_options?: string; // 선택형일 때 옵션 (콤마 구분)
|
|
unit?: string; // 검사 단위
|
|
};
|
|
|
|
export default function ItemInspectionInfoPage() {
|
|
const { user } = useAuth();
|
|
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
|
const ts = useTableSettings("c16-item-inspection", TABLE_NAME, GRID_COLUMNS);
|
|
|
|
const [data, setData] = useState<any[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [totalCount, setTotalCount] = useState(0);
|
|
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
|
const [checkedIds, setCheckedIds] = useState<string[]>([]);
|
|
|
|
// 우측 패널: 선택된 품목
|
|
const [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 옵션
|
|
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; judgment_criteria: string; selection_options: string; unit: string; types: string[] }[]>([]);
|
|
const [inspTypeCatOptions, setInspTypeCatOptions] = useState<{ code: string; label: string }[]>([]);
|
|
const [inspMethodCatOptions, setInspMethodCatOptions] = useState<{ code: string; label: string }[]>([]);
|
|
const [judgmentCatOptions, setJudgmentCatOptions] = useState<{ code: string; label: string }[]>([]);
|
|
const [inspUnitCatOptions, setInspUnitCatOptions] = useState<{ code: string; label: string }[]>([]);
|
|
const [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]);
|
|
|
|
// 품목 카테고리 코드→라벨 (type, inventory_unit)
|
|
const [itemCatMap, setItemCatMap] = useState<Record<string, Record<string, string>>>({});
|
|
const itemCatMapRef = React.useRef(itemCatMap);
|
|
itemCatMapRef.current = itemCatMap;
|
|
|
|
// 검사유형별 검사항목 rows (모달용)
|
|
const [inspectionRows, setInspectionRows] = useState<Record<string, InspectionRow[]>>({});
|
|
const [collapsedTypes, setCollapsedTypes] = useState<Record<string, boolean>>({});
|
|
|
|
// 기본 라우팅 공정 목록 (적용공정 Select용)
|
|
const [processOptions, setProcessOptions] = useState<{ code: string; name: string }[]>([]);
|
|
|
|
// 엑셀 업로드 모달
|
|
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
|
|
|
|
// 품목 선택 모달
|
|
const [itemModalOpen, setItemModalOpen] = useState(false);
|
|
const [itemSearchKeyword, setItemSearchKeyword] = useState("");
|
|
const [filteredItems, setFilteredItems] = useState<typeof itemOptions>([]);
|
|
const [itemSearchLoading, setItemSearchLoading] = useState(false);
|
|
const [itemPage, setItemPage] = useState(1);
|
|
const [itemTotal, setItemTotal] = useState(0);
|
|
const itemPageSize = 20;
|
|
const itemTotalPages = Math.max(1, Math.ceil(itemTotal / itemPageSize));
|
|
|
|
/* ═══════════════════ FK 옵션 로드 ═══════════════════ */
|
|
useEffect(() => {
|
|
const loadOptions = async () => {
|
|
try {
|
|
const [itemRes, inspRes, userRes] = await Promise.all([
|
|
apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, { page: 1, size: 500, autoFilter: true }),
|
|
apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, { page: 1, size: 500, autoFilter: true }),
|
|
apiClient.post(`/table-management/tables/user_info/data`, { page: 1, size: 500, autoFilter: true }),
|
|
]);
|
|
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
|
|
setItemOptions(items.map((r: any) => ({
|
|
code: r.item_number || r.item_code || "",
|
|
name: r.item_name || "",
|
|
item_type: r.type || r.item_type || "",
|
|
unit: r.inventory_unit || "",
|
|
})));
|
|
|
|
const insps = inspRes.data?.data?.data || inspRes.data?.data?.rows || [];
|
|
setInspOptions(insps.map((r: any) => ({
|
|
code: r.id,
|
|
label: r.inspection_criteria || r.inspection_standard || r.id,
|
|
detail: r.inspection_item || r.inspection_criteria || "",
|
|
method: r.inspection_method || "",
|
|
judgment_criteria: r.judgment_criteria || "",
|
|
selection_options: r.selection_options || "",
|
|
unit: r.unit || "",
|
|
types: r.inspection_type ? r.inspection_type.split(",").filter(Boolean) : [],
|
|
})));
|
|
|
|
// 품목 카테고리 (type, unit)
|
|
const catMap: Record<string, Record<string, string>> = {};
|
|
for (const col of ["type", "unit", "inventory_unit"]) {
|
|
try {
|
|
const catRes = await apiClient.get(`/table-categories/item_info/${col}/values`);
|
|
if (catRes.data?.success && catRes.data.data?.length) {
|
|
catMap[col] = {};
|
|
const fl = (arr: any[]) => { for (const v of arr) { catMap[col][v.valueCode] = v.valueLabel; if (v.children?.length) fl(v.children); } };
|
|
fl(catRes.data.data);
|
|
}
|
|
} catch { /* skip */ }
|
|
}
|
|
setItemCatMap(catMap);
|
|
|
|
// 검사유형 카테고리
|
|
try {
|
|
const catRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/inspection_type/values`);
|
|
const flatCats: { code: string; label: string }[] = [];
|
|
const flatten = (arr: any[]) => { for (const v of arr) { flatCats.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) flatten(v.children); } };
|
|
if (catRes.data?.data?.length) flatten(catRes.data.data);
|
|
setInspTypeCatOptions(flatCats);
|
|
} catch { /* skip */ }
|
|
|
|
// 검사방법 카테고리
|
|
try {
|
|
const methodRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/inspection_method/values`);
|
|
const flatMethods: { code: string; label: string }[] = [];
|
|
const flattenM = (arr: any[]) => { for (const v of arr) { flatMethods.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) flattenM(v.children); } };
|
|
if (methodRes.data?.data?.length) flattenM(methodRes.data.data);
|
|
setInspMethodCatOptions(flatMethods);
|
|
} catch { /* skip */ }
|
|
|
|
// 판단기준 카테고리
|
|
try {
|
|
const jcRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/judgment_criteria/values`);
|
|
const flatJc: { code: string; label: string }[] = [];
|
|
const flattenJc = (arr: any[]) => { for (const v of arr) { flatJc.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) flattenJc(v.children); } };
|
|
if (jcRes.data?.data?.length) flattenJc(jcRes.data.data);
|
|
setJudgmentCatOptions(flatJc);
|
|
} catch { /* skip */ }
|
|
|
|
// 검사 단위 카테고리
|
|
try {
|
|
const unitRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/unit/values`);
|
|
const flatUnit: { code: string; label: string }[] = [];
|
|
const flattenU = (arr: any[]) => { for (const v of arr) { flatUnit.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) flattenU(v.children); } };
|
|
if (unitRes.data?.data?.length) flattenU(unitRes.data.data);
|
|
setInspUnitCatOptions(flatUnit);
|
|
} catch { /* skip */ }
|
|
|
|
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
|
|
setUserOptions(users.map((u: any) => ({
|
|
code: u.user_id || u.id,
|
|
label: `${u.user_name || u.name || u.user_id}${u.dept_name ? ` (${u.dept_name})` : ""}`,
|
|
})));
|
|
} catch { /* skip */ }
|
|
};
|
|
loadOptions();
|
|
}, []);
|
|
|
|
// 품목별 기본 라우팅 공정 로드
|
|
const loadProcessOptions = async (itemCode: string) => {
|
|
try {
|
|
const res = await apiClient.get(`/work-instruction/__lookup__/routing-versions/${encodeURIComponent(itemCode)}`);
|
|
if (res.data?.success && res.data.data?.length > 0) {
|
|
const defaultVer = res.data.data.find((v: any) => v.is_default) || res.data.data[0];
|
|
const procs = (defaultVer.processes || []).map((p: any) => ({
|
|
code: p.process_code,
|
|
name: p.process_name || p.process_code,
|
|
}));
|
|
setProcessOptions(procs);
|
|
} else {
|
|
setProcessOptions([]);
|
|
}
|
|
} catch { setProcessOptions([]); }
|
|
};
|
|
|
|
/* ═══════════════════ 품목 선택 모달 ═══════════════════ */
|
|
const searchItemServer = async (page?: number) => {
|
|
const p = page ?? itemPage;
|
|
setItemSearchLoading(true);
|
|
try {
|
|
const filters: any[] = [];
|
|
if (itemSearchKeyword.trim()) {
|
|
filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword.trim() });
|
|
}
|
|
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
|
|
page: p, size: itemPageSize,
|
|
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
|
autoFilter: true,
|
|
});
|
|
const resData = res.data?.data;
|
|
const rows = resData?.data || resData?.rows || [];
|
|
const cm = itemCatMapRef.current;
|
|
setFilteredItems(rows.map((r: any) => ({ code: r.item_number, name: r.item_name, item_type: cm["type"]?.[r.type] || r.type || "", unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "" })));
|
|
setItemTotal(resData?.total || resData?.totalCount || rows.length);
|
|
} catch { /* skip */ } finally { setItemSearchLoading(false); }
|
|
};
|
|
const openItemModal = () => { setItemSearchKeyword(""); setItemPage(1); setItemModalOpen(true); searchItemServer(1); };
|
|
const handleItemSearch = () => { setItemPage(1); searchItemServer(1); };
|
|
const selectItem = (item: typeof itemOptions[0]) => {
|
|
if (groupedData.some(g => g.item_code === item.code)) {
|
|
toast.error(`"${item.name}" 은(는) 이미 등록된 품목입니다.`);
|
|
return;
|
|
}
|
|
setForm(p => ({ ...p, item_code: item.code, item_name: item.name }));
|
|
setItemModalOpen(false);
|
|
loadProcessOptions(item.code);
|
|
};
|
|
|
|
/* ═══════════════════ 데이터 조회 ═══════════════════ */
|
|
const fetchData = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
|
|
const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
|
|
page: 1, size: 500,
|
|
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
|
autoFilter: true,
|
|
});
|
|
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
|
setData(rows);
|
|
setTotalCount(rows.length);
|
|
} catch {
|
|
toast.error("품목검사정보 조회에 실패했어요");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [searchFilters]);
|
|
|
|
useEffect(() => { fetchData(); }, [fetchData]);
|
|
|
|
// item_code별 그룹핑
|
|
const groupedData = useMemo(() => {
|
|
const map: Record<string, { item_code: string; item_name: string; is_active: string; types: string[]; rows: any[] }> = {};
|
|
for (const row of data) {
|
|
const key = row.item_code || row.id;
|
|
if (!map[key]) {
|
|
map[key] = { item_code: row.item_code, item_name: row.item_name, is_active: row.is_active || "", types: [], rows: [] };
|
|
}
|
|
map[key].rows.push(row);
|
|
if (row.inspection_type && !map[key].types.includes(row.inspection_type)) {
|
|
map[key].types.push(row.inspection_type);
|
|
}
|
|
}
|
|
return Object.values(map);
|
|
}, [data]);
|
|
|
|
// 선택된 품목의 그룹 데이터
|
|
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) => {
|
|
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 (itemCode?: string) => {
|
|
const code = itemCode || selectedItemCode;
|
|
if (!code) { toast.error("수정할 항목을 선택해주세요"); return; }
|
|
const group = groupedData.find(g => g.item_code === code);
|
|
if (!group) return;
|
|
loadProcessOptions(code);
|
|
const row = group.rows[0];
|
|
setForm({ ...row });
|
|
setEditMode(true);
|
|
setCollapsedTypes({});
|
|
|
|
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: code }] },
|
|
autoFilter: true,
|
|
});
|
|
const allRows = res.data?.data?.data || res.data?.data?.rows || [];
|
|
const rowMap: Record<string, InspectionRow[]> = {};
|
|
const typeFlags: Record<string, boolean> = {};
|
|
|
|
for (const r of allRows) {
|
|
const inspType = r.inspection_type || "";
|
|
const matched = INSPECTION_TYPES.find(t =>
|
|
t.matchLabels.some(ml => inspType.includes(ml)) ||
|
|
inspTypeCatOptions.some(cat => inspType.includes(cat.code) && t.matchLabels.some(ml => cat.label.includes(ml)))
|
|
);
|
|
const typeKey = matched?.key || "";
|
|
if (!typeKey) continue;
|
|
typeFlags[typeKey] = true;
|
|
if (!rowMap[typeKey]) rowMap[typeKey] = [];
|
|
const mCode = r.inspection_method || "";
|
|
const mLabel = inspMethodCatOptions.find(o => o.code === mCode)?.label || mCode;
|
|
// 판단기준/선택옵션/단위 resolve
|
|
const inspOpt = inspOptions.find(o => o.code === r.inspection_standard_id);
|
|
const jcCode = inspOpt?.judgment_criteria || "";
|
|
const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode;
|
|
const unitCode = inspOpt?.unit || "";
|
|
const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode;
|
|
rowMap[typeKey].push({
|
|
id: r.id,
|
|
inspection_standard_id: r.inspection_standard_id || "",
|
|
inspection_detail: r.inspection_item_name || r.inspection_standard || "",
|
|
inspection_method: mLabel,
|
|
apply_process: "",
|
|
acceptance_criteria: r.pass_criteria || "",
|
|
is_required: r.is_required === "true" || r.is_required === true,
|
|
judgment_criteria: jcLabel,
|
|
selection_options: inspOpt?.selection_options || "",
|
|
unit: unitLabel,
|
|
});
|
|
}
|
|
setInspectionRows(rowMap);
|
|
setForm(p => ({ ...p, ...typeFlags }));
|
|
} catch { setInspectionRows({}); }
|
|
setModalOpen(true);
|
|
};
|
|
|
|
/* ═══════════════════ 검사항목 행 관리 (모달) ═══════════════════ */
|
|
const addInspRow = (typeKey: string) => {
|
|
setInspectionRows(prev => ({
|
|
...prev,
|
|
[typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", acceptance_criteria: "", is_required: false }],
|
|
}));
|
|
};
|
|
const removeInspRow = (typeKey: string, rowId: string) => {
|
|
setInspectionRows(prev => ({ ...prev, [typeKey]: (prev[typeKey] || []).filter(r => r.id !== rowId) }));
|
|
};
|
|
const updateInspRow = (typeKey: string, rowId: string, field: string, value: any) => {
|
|
setInspectionRows(prev => ({
|
|
...prev,
|
|
[typeKey]: (prev[typeKey] || []).map(r => {
|
|
if (r.id !== rowId) return r;
|
|
if (field === "inspection_standard_id") {
|
|
const opt = inspOptions.find(o => o.code === value);
|
|
const methodCode = opt?.method || "";
|
|
const methodLabel = inspMethodCatOptions.find(o => o.code === methodCode)?.label || methodCode;
|
|
// 판단기준 라벨 resolve
|
|
const jcCode = opt?.judgment_criteria || "";
|
|
const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode;
|
|
// 단위 라벨 resolve
|
|
const unitCode = opt?.unit || "";
|
|
const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode;
|
|
return {
|
|
...r,
|
|
inspection_standard_id: value,
|
|
inspection_detail: opt?.detail || "",
|
|
inspection_method: methodLabel,
|
|
judgment_criteria: jcLabel,
|
|
selection_options: opt?.selection_options || "",
|
|
unit: unitLabel,
|
|
acceptance_criteria: "", // 판단기준 변경 시 초기화
|
|
};
|
|
}
|
|
return { ...r, [field]: value };
|
|
}),
|
|
}));
|
|
};
|
|
const getFilteredInspOptions = (typeKey: string) => {
|
|
const typeDef = INSPECTION_TYPES.find(t => t.key === typeKey);
|
|
if (!typeDef) return inspOptions;
|
|
const matchCodes = inspTypeCatOptions.filter(cat => typeDef.matchLabels.some(ml => cat.label.includes(ml))).map(cat => cat.code);
|
|
if (matchCodes.length === 0) return inspOptions;
|
|
return inspOptions.filter(opt => opt.types.some(t => matchCodes.includes(t)));
|
|
};
|
|
const toggleCollapse = (typeKey: string) => { setCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] })); };
|
|
|
|
const handleSave = async () => {
|
|
if (!form.item_code) { toast.error("품목코드는 필수예요"); return; }
|
|
setSaving(true);
|
|
try {
|
|
if (editMode) {
|
|
const existRes = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
|
|
page: 1, size: 500,
|
|
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: form.item_code }] },
|
|
autoFilter: true,
|
|
});
|
|
const existing = existRes.data?.data?.data || existRes.data?.data?.rows || [];
|
|
if (existing.length > 0) {
|
|
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: existing.map((r: any) => ({ id: r.id })) });
|
|
}
|
|
}
|
|
const enabledTypes = INSPECTION_TYPES.filter(t => !!form[t.key]);
|
|
const rows: any[] = [];
|
|
for (const t of enabledTypes) {
|
|
const typeRows = inspectionRows[t.key] || [];
|
|
if (typeRows.length === 0) {
|
|
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: 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 ? "수정했어요" : "등록했어요");
|
|
setModalOpen(false);
|
|
fetchData();
|
|
} catch { toast.error("저장에 실패했어요"); }
|
|
finally { setSaving(false); }
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
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: group.rows.map((r: any) => ({ id: r.id })) });
|
|
toast.success("삭제했어요");
|
|
setSelectedItemCode(null);
|
|
fetchData();
|
|
} catch { toast.error("삭제에 실패했어요"); }
|
|
};
|
|
|
|
/* ═══════════════════ 엑셀 업로드 (다건 품목 모드) ═══════════════════ */
|
|
const [excelItemProcessMappings, setExcelItemProcessMappings] = useState<import("@/components/common/SmartExcelUpload").ItemProcessMapping[]>([]);
|
|
const [excelLoading, setExcelLoading] = useState(false);
|
|
const [excelLoadProgress, setExcelLoadProgress] = useState({ loaded: 0, total: 0 });
|
|
|
|
const openExcelUpload = async () => {
|
|
setExcelUploadOpen(true);
|
|
|
|
// 캐시 히트: 이미 로드된 데이터 있으면 재사용
|
|
if (excelItemProcessMappings.length > 0) return;
|
|
|
|
setExcelLoading(true);
|
|
setExcelLoadProgress({ loaded: 0, total: 0 });
|
|
|
|
try {
|
|
// 1. 전체 품목 조회
|
|
const itemRes = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
|
|
page: 1, size: 99999, autoFilter: true,
|
|
});
|
|
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
|
|
setExcelLoadProgress({ loaded: items.length / 2, total: items.length });
|
|
|
|
// 2. 벌크 라우팅 조회 (1회 API 호출)
|
|
const itemCodes = items.map((item: any) => item.item_number || item.item_code || "").filter(Boolean);
|
|
let processMap: Record<string, { code: string; name: string }[]> = {};
|
|
try {
|
|
const bulkRes = await apiClient.post(`/work-instruction/routing-versions-bulk`, { itemCodes });
|
|
if (bulkRes.data?.success) {
|
|
processMap = bulkRes.data.data || {};
|
|
}
|
|
} catch { /* 벌크 API 실패 시 빈 공정으로 진행 */ }
|
|
|
|
// 3. 매핑 구성
|
|
const mappings: import("@/components/common/SmartExcelUpload").ItemProcessMapping[] = items.map((item: any) => {
|
|
const code = item.item_number || item.item_code || "";
|
|
return {
|
|
itemCode: code,
|
|
itemName: item.item_name || "",
|
|
processes: processMap[code] || [],
|
|
};
|
|
});
|
|
|
|
setExcelLoadProgress({ loaded: items.length, total: items.length });
|
|
setExcelItemProcessMappings(mappings);
|
|
toast.success(`${mappings.length}개 품목 로드 완료`);
|
|
} catch {
|
|
toast.error("품목 정보 로드에 실패했습니다");
|
|
} finally {
|
|
setExcelLoading(false);
|
|
}
|
|
};
|
|
|
|
// 엑셀 Config 생성 (다건 품목 모드)
|
|
const excelUploadConfig = useMemo((): SmartExcelUploadConfig => {
|
|
const itemCount = excelItemProcessMappings.length || 9999;
|
|
const makeColumns = () => [
|
|
{ key: "item_name", label: "품목명", required: true, type: "dropdown" as const, dropdown: { source: "custom" as const, values: [] }, width: 22 },
|
|
{ key: "item_code", label: "품목코드", type: "text" as const, readOnly: true, customFormula: `IFERROR(INDEX('_품목목록'!$A$1:$A$${itemCount},MATCH({col:item_name},'_품목목록'!$B$1:$B$${itemCount},0)),"")`, width: 16 },
|
|
{ key: "inspection_standard", label: "검사기준", required: true, type: "dropdown" as const, dropdown: { source: "custom" as const, values: [] }, width: 22 },
|
|
{ key: "inspection_detail", label: "검사기준 상세", type: "text" as const, readOnly: true, autoFill: { lookupColumn: "inspection_standard", referenceColumn: "detail" }, width: 20 },
|
|
{ key: "inspection_method", label: "검사방법", type: "text" as const, readOnly: true, autoFill: { lookupColumn: "inspection_standard", referenceColumn: "method" }, width: 14 },
|
|
{ key: "apply_process", label: "적용공정", type: "dropdown" as const, dropdown: { source: "indirect" as const, indirectKeyColumn: "item_code" }, width: 14 },
|
|
{ key: "judgment_criteria", label: "판단기준", type: "text" as const, readOnly: true, autoFill: { lookupColumn: "inspection_standard", referenceColumn: "judgment_criteria" }, width: 14 },
|
|
{ key: "standard_value", label: "기준값", type: "number" as const, enableWhen: { column: "judgment_criteria", equals: "수치(범위)" }, width: 12 },
|
|
{ key: "tolerance", label: "오차", type: "number" as const, enableWhen: { column: "judgment_criteria", equals: "수치(범위)" }, width: 10 },
|
|
{ key: "unit", label: "단위", type: "text" as const, readOnly: true, autoFill: { lookupColumn: "inspection_standard", referenceColumn: "unit" }, width: 10 },
|
|
{ key: "acceptance_criteria", label: "합격기준", type: "dropdown" as const, dropdown: { source: "indirect" as const, indirectKeyColumn: "inspection_standard", indirectPrefix: "ACC_" }, width: 18 },
|
|
{ key: "is_required", label: "필수", type: "dropdown" as const, dropdown: { source: "custom" as const, values: ["Y", "N"] }, width: 8 },
|
|
];
|
|
|
|
return {
|
|
templateName: "품목검사정보",
|
|
sheets: INSPECTION_TYPES.map(t => ({
|
|
name: t.label,
|
|
typeKey: t.label,
|
|
columns: makeColumns(),
|
|
})),
|
|
referenceSheet: {
|
|
name: "검사기준정보",
|
|
columns: [
|
|
{ key: "label", label: "검사기준명" },
|
|
{ key: "detail", label: "검사기준 상세" },
|
|
{ key: "method", label: "검사방법" },
|
|
{ key: "judgment_criteria", label: "판단기준" },
|
|
{ key: "selection_options", label: "선택옵션" },
|
|
{ key: "unit", label: "단위" },
|
|
{ key: "types", label: "검사유형" },
|
|
],
|
|
},
|
|
conditionalRules: [
|
|
{ when: { column: "judgment_criteria", equals: "수치(범위)" }, require: ["standard_value"], ignore: ["acceptance_criteria"] },
|
|
{ when: { column: "judgment_criteria", equals: "O/X" }, require: ["acceptance_criteria"], ignore: ["standard_value", "tolerance"] },
|
|
{ when: { column: "judgment_criteria", equals: "선택형" }, require: ["acceptance_criteria"], ignore: ["standard_value", "tolerance"] },
|
|
{ when: { column: "judgment_criteria", equals: "텍스트입력" }, require: ["acceptance_criteria"], ignore: ["standard_value", "tolerance"] },
|
|
],
|
|
indirectOptions: {
|
|
conditionColumn: "judgment_criteria",
|
|
optionsByCondition: { "O/X": ["O", "X"] },
|
|
selectionOptionsColumn: "selection_options",
|
|
},
|
|
};
|
|
}, [excelItemProcessMappings]);
|
|
|
|
// 참조 데이터 구성
|
|
const excelReferenceData = useMemo(() => {
|
|
return inspOptions.map(opt => {
|
|
const methodLabel = inspMethodCatOptions.find(o => o.code === opt.method)?.label || opt.method;
|
|
const jcLabel = judgmentCatOptions.find(c => c.code === opt.judgment_criteria)?.label || opt.judgment_criteria;
|
|
const unitLabel = inspUnitCatOptions.find(c => c.code === opt.unit)?.label || opt.unit;
|
|
const typeLabels = opt.types.map(t => inspTypeCatOptions.find(c => c.code === t)?.label || t).join(",");
|
|
return { label: opt.label, detail: opt.detail, method: methodLabel, judgment_criteria: jcLabel, selection_options: opt.selection_options, unit: unitLabel, types: typeLabels };
|
|
});
|
|
}, [inspOptions, inspMethodCatOptions, judgmentCatOptions, inspUnitCatOptions, inspTypeCatOptions]);
|
|
|
|
// 시트별 드롭다운 옵션
|
|
const excelDropdownOptions = useMemo(() => {
|
|
const opts: Record<string, string[]> = {};
|
|
for (const t of INSPECTION_TYPES) {
|
|
const matchCodes = inspTypeCatOptions.filter(cat => t.matchLabels.some(ml => cat.label.includes(ml))).map(cat => cat.code);
|
|
const filtered = matchCodes.length > 0
|
|
? inspOptions.filter(opt => opt.types.some(tp => matchCodes.includes(tp)))
|
|
: inspOptions;
|
|
opts[`${t.label}:inspection_standard`] = filtered.map(o => o.label);
|
|
}
|
|
opts["is_required"] = ["Y", "N"];
|
|
// 품목명 드롭다운
|
|
opts["item_name"] = excelItemProcessMappings.map(m => m.itemName);
|
|
return opts;
|
|
}, [inspOptions, inspTypeCatOptions, excelItemProcessMappings]);
|
|
|
|
// 라벨→코드 매핑
|
|
const excelLabelToCodeMap = useMemo(() => {
|
|
const map: Record<string, Record<string, string>> = {};
|
|
map["inspection_standard"] = {};
|
|
for (const opt of inspOptions) map["inspection_standard"][opt.label] = opt.code;
|
|
// 품목명→품목코드
|
|
map["item_name"] = {};
|
|
for (const m of excelItemProcessMappings) map["item_name"][m.itemName] = m.itemCode;
|
|
// 적용공정 이름→코드 (전체 품목 공정에서)
|
|
map["apply_process"] = {};
|
|
for (const m of excelItemProcessMappings) {
|
|
for (const p of m.processes) map["apply_process"][p.name] = p.code;
|
|
}
|
|
return map;
|
|
}, [inspOptions, excelItemProcessMappings]);
|
|
|
|
// 엑셀 업로드 저장 (다건)
|
|
const handleExcelUpload = async (data: ParsedSheetData[]) => {
|
|
// 품목코드별로 그룹핑
|
|
const itemCodeSet = new Set<string>();
|
|
const rows: any[] = [];
|
|
|
|
for (const sheet of data) {
|
|
for (const row of sheet.rows) {
|
|
// 품목코드: 수식 결과 또는 품목명으로 역매핑
|
|
let itemCode = row.item_code || "";
|
|
const itemName = row.item_name || "";
|
|
if (!itemCode && itemName) {
|
|
const mapping = excelItemProcessMappings.find(m => m.itemName === itemName);
|
|
if (mapping) itemCode = mapping.itemCode;
|
|
}
|
|
if (!itemCode) continue;
|
|
itemCodeSet.add(itemCode);
|
|
|
|
const inspLabel = row.inspection_standard || "";
|
|
const inspId = excelLabelToCodeMap["inspection_standard"]?.[inspLabel] || inspLabel;
|
|
const inspOpt = inspOptions.find(o => o.code === inspId);
|
|
const itemMapping = excelItemProcessMappings.find(m => m.itemCode === itemCode);
|
|
|
|
let passCriteria = "";
|
|
const jcLabel = inspOpt ? (judgmentCatOptions.find(c => c.code === inspOpt.judgment_criteria)?.label || inspOpt.judgment_criteria) : "";
|
|
if (jcLabel === "수치(범위)") {
|
|
passCriteria = `${row.standard_value || ""}|${row.tolerance || ""}`;
|
|
} else {
|
|
passCriteria = row.acceptance_criteria || "";
|
|
}
|
|
|
|
// 적용공정 검증: 해당 품목의 유효 공정인지 확인 (품목 변경 후 공정 미초기화 대응)
|
|
let applyProcess = row.apply_process || "";
|
|
if (applyProcess && itemMapping) {
|
|
const validProcess = itemMapping.processes.find(p => p.code === applyProcess || p.name === applyProcess);
|
|
if (!validProcess) {
|
|
applyProcess = ""; // 유효하지 않은 공정은 비움
|
|
}
|
|
}
|
|
|
|
rows.push({
|
|
id: crypto.randomUUID(),
|
|
item_code: itemCode,
|
|
item_name: itemMapping?.itemName || itemCode,
|
|
inspection_type: sheet.typeKey || sheet.sheetName,
|
|
inspection_standard_id: inspId,
|
|
inspection_item_name: inspOpt?.detail || row.inspection_detail || "",
|
|
inspection_method: inspOpt?.method || "",
|
|
apply_process: applyProcess,
|
|
pass_criteria: passCriteria,
|
|
is_required: row.is_required === "Y" ? "true" : "false",
|
|
is_active: "사용",
|
|
});
|
|
}
|
|
}
|
|
|
|
// 해당 품목들의 기존 데이터 삭제 후 재등록
|
|
for (const itemCode of itemCodeSet) {
|
|
const existRes = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
|
|
page: 1, size: 9999,
|
|
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: itemCode }] },
|
|
autoFilter: true,
|
|
});
|
|
const existing = existRes.data?.data?.data || existRes.data?.data?.rows || [];
|
|
if (existing.length > 0) {
|
|
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: existing.map((r: any) => ({ id: r.id })) });
|
|
}
|
|
}
|
|
|
|
for (const row of rows) {
|
|
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, row);
|
|
}
|
|
fetchData();
|
|
};
|
|
|
|
/* ═══════════════════ JSX ═══════════════════ */
|
|
return (
|
|
<div className="flex h-full flex-col">
|
|
{ConfirmDialogComponent}
|
|
|
|
{/* 검색 필터 */}
|
|
<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={openExcelUpload}>
|
|
<FileSpreadsheet 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) => {
|
|
const label = inspTypeCatOptions.find((o) => o.code === t)?.label || t;
|
|
return <Badge key={t} variant="secondary" className="text-[10px]">{label}</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"
|
|
)}
|
|
>
|
|
{inspTypeCatOptions.find((o) => o.code === type)?.label || 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">{(() => {
|
|
const code = row.apply_process;
|
|
if (!code) return "-";
|
|
// excelItemProcessMappings에서 공정명 찾기
|
|
for (const m of excelItemProcessMappings) {
|
|
const proc = m.processes.find(p => p.code === code);
|
|
if (proc) return proc.name;
|
|
}
|
|
// processOptions (모달용)에서 찾기
|
|
const proc = processOptions.find(p => p.code === code);
|
|
return proc?.name || code;
|
|
})()}</TableCell>
|
|
<TableCell className="text-xs py-2">
|
|
{(() => {
|
|
const insp = inspOptions.find(o => o.code === row.inspection_standard_id);
|
|
const jcCode = insp?.judgment_criteria || "";
|
|
const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode;
|
|
return jcLabel ? <Badge variant="outline" className="text-[10px]">{jcLabel}</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] flex flex-col overflow-hidden", itemModalOpen ? "sm:max-w-3xl" : "sm:max-w-[1400px]")}>
|
|
{itemModalOpen ? (
|
|
<>
|
|
<DialogHeader>
|
|
<DialogTitle>품목 선택</DialogTitle>
|
|
<DialogDescription>품목코드 또는 품목명으로 검색</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="flex gap-2 shrink-0">
|
|
<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} disabled={itemSearchLoading}>
|
|
{itemSearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" />검색</>}
|
|
</Button>
|
|
</div>
|
|
<div className="flex-1 border rounded-lg overflow-auto">
|
|
<Table>
|
|
<TableHeader className="sticky top-0 z-10">
|
|
<TableRow className="bg-muted hover:bg-muted">
|
|
<TableHead className="text-[11px] font-bold w-[140px]">품목코드</TableHead>
|
|
<TableHead className="text-[11px] font-bold">품목명</TableHead>
|
|
<TableHead className="text-[11px] font-bold w-[100px]">품목유형</TableHead>
|
|
<TableHead className="text-[11px] font-bold w-[80px]">단위</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{filteredItems.length === 0 ? (
|
|
<TableRow><TableCell colSpan={4} className="text-center py-8 text-muted-foreground text-sm">{itemSearchLoading ? "검색 중..." : "검색 결과가 없어요"}</TableCell></TableRow>
|
|
) : filteredItems.map((item) => (
|
|
<TableRow key={item.code} className="cursor-pointer hover:bg-primary/5" onClick={() => selectItem(item)}>
|
|
<TableCell className="text-sm font-mono">{item.code}</TableCell>
|
|
<TableCell className="text-sm">{item.name}</TableCell>
|
|
<TableCell className="text-sm">{item.item_type}</TableCell>
|
|
<TableCell className="text-sm">{item.unit}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
{/* 페이지네이션 (EDataTable 스타일) */}
|
|
<div className="shrink-0 flex items-center justify-between border-t px-2 py-2 text-xs text-muted-foreground">
|
|
<span>전체 <span className="font-medium text-foreground">{itemTotal.toLocaleString()}</span>건</span>
|
|
<div className="flex items-center gap-0.5">
|
|
<button onClick={() => { setItemPage(1); searchItemServer(1); }} disabled={itemPage <= 1}
|
|
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsLeft className="h-3.5 w-3.5" /></button>
|
|
<button onClick={() => { const p = itemPage - 1; setItemPage(p); searchItemServer(p); }} disabled={itemPage <= 1}
|
|
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronLeft className="h-3.5 w-3.5" /></button>
|
|
{Array.from({ length: Math.min(5, itemTotalPages) }, (_, i) => {
|
|
const start = Math.max(1, Math.min(itemPage - 2, itemTotalPages - 4));
|
|
const p = start + i;
|
|
if (p > itemTotalPages) return null;
|
|
return (
|
|
<button key={p} onClick={() => { setItemPage(p); searchItemServer(p); }}
|
|
className={cn("h-7 w-7 flex items-center justify-center rounded text-xs",
|
|
p === itemPage ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted")}>{p}</button>
|
|
);
|
|
})}
|
|
<button onClick={() => { const p = itemPage + 1; setItemPage(p); searchItemServer(p); }} disabled={itemPage >= itemTotalPages}
|
|
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronRight className="h-3.5 w-3.5" /></button>
|
|
<button onClick={() => { setItemPage(itemTotalPages); searchItemServer(itemTotalPages); }} disabled={itemPage >= itemTotalPages}
|
|
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsRight className="h-3.5 w-3.5" /></button>
|
|
</div>
|
|
</div>
|
|
<DialogFooter className="shrink-0"><Button variant="outline" onClick={() => setItemModalOpen(false)}>취소</Button></DialogFooter>
|
|
</>
|
|
) : (
|
|
<>
|
|
<DialogHeader>
|
|
<DialogTitle>{editMode ? "품목검사정보 수정" : "품목검사정보 등록"}</DialogTitle>
|
|
<DialogDescription className="sr-only">품목검사정보를 등록합니다</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-4">
|
|
{/* 품목 정보 */}
|
|
<div className="space-y-3">
|
|
<h4 className="text-sm font-semibold">품목 정보</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">품목명</Label>
|
|
<Input className="h-9 bg-muted" value={form.item_name || ""} readOnly placeholder="품목명" />
|
|
</div>
|
|
<Button type="button" variant="outline" size="sm" className="h-9 px-3 shrink-0" onClick={openItemModal}>
|
|
<Search className="w-4 h-4 mr-1" />품목선택
|
|
</Button>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-semibold text-muted-foreground">사용여부</Label>
|
|
<Select value={form.is_active === false || 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>
|
|
<SelectItem value="N">미사용</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-semibold text-muted-foreground">관리자</Label>
|
|
<Select value={form.manager || ""} onValueChange={(v) => setForm(p => ({ ...p, manager: v }))}>
|
|
<SelectTrigger className="h-9"><SelectValue placeholder="관리자 선택" /></SelectTrigger>
|
|
<SelectContent>{userOptions.map(o => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 검사유형 선택 */}
|
|
<div className="space-y-3">
|
|
<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 }))} />
|
|
<Label className="text-sm cursor-pointer">{label}</Label>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 검사유형별 검사항목 설정 */}
|
|
{INSPECTION_TYPES.filter(t => !!form[t.key]).map(({ key, label }) => (
|
|
<div key={key} className="space-y-2">
|
|
<button type="button" className="w-full flex items-center gap-2 py-2 px-3 rounded-md border bg-muted/50 hover:bg-muted text-left" onClick={() => toggleCollapse(key)}>
|
|
<Badge variant="default" className="text-xs">{label}</Badge>
|
|
<span className="text-sm font-medium">검사항목 설정</span>
|
|
<span className="text-xs text-muted-foreground ml-auto">{(inspectionRows[key] || []).length}개</span>
|
|
</button>
|
|
{!collapsedTypes[key] && (
|
|
<div className="space-y-2 pl-1">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs font-semibold text-muted-foreground">검사항목 목록</span>
|
|
<Button type="button" size="sm" variant="outline" className="h-7 text-xs" onClick={() => addInspRow(key)}>
|
|
<Plus className="w-3 h-3 mr-1" />항목추가
|
|
</Button>
|
|
</div>
|
|
<div className="border rounded-lg overflow-hidden">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow className="bg-muted/50 hover:bg-muted/50">
|
|
<TableHead className="text-[10px] font-bold w-[170px]">검사기준 선택</TableHead>
|
|
<TableHead className="text-[10px] font-bold w-[130px]">검사기준 상세</TableHead>
|
|
<TableHead className="text-[10px] font-bold w-[90px]">검사방법</TableHead>
|
|
<TableHead className="text-[10px] font-bold w-[90px]">적용공정</TableHead>
|
|
<TableHead className="text-[10px] font-bold w-[80px]">판단기준</TableHead>
|
|
<TableHead className="text-[10px] font-bold w-[200px]">합격기준 (판단기준별)</TableHead>
|
|
<TableHead className="text-[10px] font-bold w-[40px]">필수</TableHead>
|
|
<TableHead className="text-[10px] font-bold w-[36px]" />
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{(!inspectionRows[key] || inspectionRows[key].length === 0) ? (
|
|
<TableRow><TableCell colSpan={8} className="text-center py-4 text-xs text-muted-foreground">항목추가 버튼으로 검사항목을 추가하세요</TableCell></TableRow>
|
|
) : inspectionRows[key].map((row) => (
|
|
<TableRow key={row.id}>
|
|
<TableCell className="p-1">
|
|
<Select value={row.inspection_standard_id || ""} onValueChange={(v) => updateInspRow(key, row.id, "inspection_standard_id", v)}>
|
|
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="검사기준 선택" /></SelectTrigger>
|
|
<SelectContent>{getFilteredInspOptions(key).map(o => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}</SelectContent>
|
|
</Select>
|
|
</TableCell>
|
|
<TableCell className="p-1"><Input className="h-8 text-xs bg-muted" value={row.inspection_detail} readOnly placeholder="자동" /></TableCell>
|
|
<TableCell className="p-1"><Input className="h-8 text-xs bg-muted" value={row.inspection_method} readOnly placeholder="자동" /></TableCell>
|
|
<TableCell className="p-1">
|
|
{processOptions.length > 0 ? (
|
|
<Select value={row.apply_process || ""} onValueChange={(v) => updateInspRow(key, row.id, "apply_process", v)}>
|
|
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="공정 선택" /></SelectTrigger>
|
|
<SelectContent>
|
|
{processOptions.map((p) => (
|
|
<SelectItem key={p.code} value={p.code} className="text-xs">{p.name}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
) : (
|
|
<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 text-center">
|
|
{row.judgment_criteria ? <Badge variant="outline" className="text-[10px]">{row.judgment_criteria}</Badge> : <span className="text-[10px] text-muted-foreground">-</span>}
|
|
</TableCell>
|
|
<TableCell className="p-1">
|
|
{row.judgment_criteria === "선택형" && row.selection_options ? (
|
|
<Select value={row.acceptance_criteria || ""} onValueChange={(v) => updateInspRow(key, row.id, "acceptance_criteria", v)} disabled={!row.inspection_standard_id}>
|
|
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="선택" /></SelectTrigger>
|
|
<SelectContent>
|
|
{row.selection_options.split(",").filter(Boolean).map((opt) => (
|
|
<SelectItem key={opt} value={opt} className="text-xs">{opt}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
) : row.judgment_criteria === "O/X" ? (
|
|
<Select value={row.acceptance_criteria || ""} onValueChange={(v) => updateInspRow(key, row.id, "acceptance_criteria", v)} disabled={!row.inspection_standard_id}>
|
|
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="선택" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="O" className="text-xs">O (합격)</SelectItem>
|
|
<SelectItem value="X" className="text-xs">X (불합격)</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
) : row.judgment_criteria === "수치(범위)" ? (
|
|
<div className="flex items-center gap-1">
|
|
<Input className="h-8 text-xs w-16" value={row.acceptance_criteria?.split("|")[0] || ""} onChange={(e) => {
|
|
const parts = (row.acceptance_criteria || "||").split("|");
|
|
parts[0] = e.target.value;
|
|
updateInspRow(key, row.id, "acceptance_criteria", parts.join("|"));
|
|
}} placeholder="기준값" disabled={!row.inspection_standard_id} />
|
|
<span className="text-[10px] text-muted-foreground">±</span>
|
|
<Input className="h-8 text-xs w-12" value={row.acceptance_criteria?.split("|")[1] || ""} onChange={(e) => {
|
|
const parts = (row.acceptance_criteria || "||").split("|");
|
|
parts[1] = e.target.value;
|
|
updateInspRow(key, row.id, "acceptance_criteria", parts.join("|"));
|
|
}} placeholder="오차" disabled={!row.inspection_standard_id} />
|
|
{row.unit && <span className="text-[10px] text-muted-foreground shrink-0">{row.unit}</span>}
|
|
</div>
|
|
) : (
|
|
<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">
|
|
<Button type="button" variant="destructive" size="sm" className="h-7 w-7 p-0" onClick={() => removeInspRow(key, row.id)}><Trash2 className="w-3.5 h-3.5" /></Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setModalOpen(false)}>취소</Button>
|
|
<Button onClick={handleSave} disabled={saving}>
|
|
{saving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}저장
|
|
</Button>
|
|
</DialogFooter>
|
|
</>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<TableSettingsModal open={ts.open} onOpenChange={ts.setOpen} tableName={ts.tableName} settingsId={ts.settingsId} defaultVisibleKeys={ts.defaultVisibleKeys} onSave={ts.applySettings} />
|
|
|
|
{/* ═══════ 엑셀 업로드 모달 ═══════ */}
|
|
<SmartExcelUploadModal
|
|
open={excelUploadOpen}
|
|
onOpenChange={setExcelUploadOpen}
|
|
config={excelUploadConfig}
|
|
referenceData={excelReferenceData}
|
|
dropdownOptions={excelDropdownOptions}
|
|
itemProcessMappings={excelItemProcessMappings}
|
|
labelToCodeMap={excelLabelToCodeMap}
|
|
onUpload={handleExcelUpload}
|
|
subtitle={`전체 ${excelItemProcessMappings.length}개 품목`}
|
|
dataLoading={excelLoading}
|
|
loadProgress={excelLoadProgress}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|