2f50d7d809
- Updated fileController to include Cross-Origin-Resource-Policy headers for improved security and file handling. - Added error handling for file streams to ensure robust responses in case of read errors. - Modified materialStatusController to correctly map material IDs to their respective codes for inventory stock queries. - Enhanced moldController to include warranty shot count in mold creation and update processes. - Improved item inspection page by adding inspection method category loading and mapping, ensuring accurate display of method labels in the UI. These changes aim to enhance the overall functionality and user experience across multiple companies by ensuring proper file handling, data mapping, and error management.
767 lines
40 KiB
TypeScript
767 lines
40 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
|
import {
|
|
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ChevronDown,
|
|
} from "lucide-react";
|
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
|
import { cn } from "@/lib/utils";
|
|
import { apiClient } from "@/lib/api/client";
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
import { useTableSettings } from "@/hooks/useTableSettings";
|
|
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
|
import { toast } from "sonner";
|
|
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
|
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
|
|
|
const TABLE_NAME = "item_inspection_info";
|
|
|
|
const GRID_COLUMNS = [
|
|
{ key: "item_code", label: "품목코드" },
|
|
{ key: "item_name", label: "품목명" },
|
|
{ key: "inspection_type", label: "검사유형" },
|
|
{ key: "item_count", label: "항목수" },
|
|
{ key: "is_active", label: "사용여부" },
|
|
];
|
|
const ITEM_TABLE = "item_info";
|
|
const INSPECTION_TABLE = "inspection_standard";
|
|
|
|
const INSPECTION_TYPES = [
|
|
{ key: "incoming_inspection", label: "수입검사", matchLabels: ["수입검사", "입고검사", "수입", "입고"] },
|
|
{ key: "outgoing_inspection", label: "출하검사", matchLabels: ["출하검사", "출고검사", "출하", "출고"] },
|
|
{ key: "process_inspection", label: "공정검사", matchLabels: ["공정검사", "공정"] },
|
|
{ key: "final_inspection", label: "최종검사", matchLabels: ["최종검사", "최종", "완제품검사"] },
|
|
] as const;
|
|
|
|
type InspectionRow = {
|
|
id: string;
|
|
inspection_standard_id: string;
|
|
inspection_detail: string;
|
|
inspection_method: string;
|
|
apply_process: string;
|
|
acceptance_criteria: string;
|
|
is_required: boolean;
|
|
};
|
|
|
|
export default function ItemInspectionInfoPage() {
|
|
const { user } = useAuth();
|
|
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
|
const ts = useTableSettings("c16-item-inspection", TABLE_NAME, GRID_COLUMNS);
|
|
|
|
const [data, setData] = useState<any[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [totalCount, setTotalCount] = useState(0);
|
|
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
|
const [checkedIds, setCheckedIds] = useState<string[]>([]);
|
|
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
|
|
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
const [editMode, setEditMode] = useState(false);
|
|
const [form, setForm] = useState<Record<string, any>>({});
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
/* FK 옵션 */
|
|
const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; unit: string }[]>([]);
|
|
const [inspOptions, setInspOptions] = useState<{ code: string; label: string; detail: string; method: string; types: string[] }[]>([]);
|
|
const [inspTypeCatOptions, setInspTypeCatOptions] = useState<{ code: string; label: string }[]>([]);
|
|
const [inspMethodCatOptions, setInspMethodCatOptions] = useState<{ code: string; label: string }[]>([]);
|
|
const [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]);
|
|
|
|
/* 검사유형별 검사항목 rows */
|
|
const [inspectionRows, setInspectionRows] = useState<Record<string, InspectionRow[]>>({});
|
|
const [collapsedTypes, setCollapsedTypes] = useState<Record<string, boolean>>({});
|
|
|
|
/* 품목 선택 모달 */
|
|
const [itemModalOpen, setItemModalOpen] = useState(false);
|
|
const [itemSearchKeyword, setItemSearchKeyword] = useState("");
|
|
const [filteredItems, setFilteredItems] = useState<typeof itemOptions>([]);
|
|
|
|
/* ═══════════════════ FK 옵션 로드 ═══════════════════ */
|
|
useEffect(() => {
|
|
const loadOptions = async () => {
|
|
try {
|
|
const [itemRes, inspRes, userRes] = await Promise.all([
|
|
apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, { page: 1, size: 500, autoFilter: true }),
|
|
apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, { page: 1, size: 500, autoFilter: true }),
|
|
apiClient.post(`/table-management/tables/user_info/data`, { page: 1, size: 500, autoFilter: true }),
|
|
]);
|
|
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
|
|
setItemOptions(items.map((r: any) => ({
|
|
code: r.item_number || r.item_code || "",
|
|
name: r.item_name || "",
|
|
item_type: r.type || r.item_type || "",
|
|
unit: r.unit || "",
|
|
})));
|
|
|
|
const insps = inspRes.data?.data?.data || inspRes.data?.data?.rows || [];
|
|
setInspOptions(insps.map((r: any) => ({
|
|
code: r.id,
|
|
label: r.inspection_criteria || r.inspection_standard || r.id,
|
|
detail: r.inspection_item || r.inspection_criteria || "",
|
|
method: r.inspection_method || "",
|
|
types: r.inspection_type ? r.inspection_type.split(",").filter(Boolean) : [],
|
|
})));
|
|
|
|
// 검사유형 카테고리 값 로드 (코드→라벨 매핑용)
|
|
try {
|
|
const catRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/inspection_type/values`);
|
|
const flatCats: { code: string; label: string }[] = [];
|
|
const flatten = (arr: any[]) => { for (const v of arr) { flatCats.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) flatten(v.children); } };
|
|
if (catRes.data?.data?.length) flatten(catRes.data.data);
|
|
setInspTypeCatOptions(flatCats);
|
|
} catch { /* skip */ }
|
|
|
|
// 검사방법 카테고리 값 로드 (코드→라벨 매핑용)
|
|
try {
|
|
const methodRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/inspection_method/values`);
|
|
const flatMethods: { code: string; label: string }[] = [];
|
|
const flattenM = (arr: any[]) => { for (const v of arr) { flatMethods.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) flattenM(v.children); } };
|
|
if (methodRes.data?.data?.length) flattenM(methodRes.data.data);
|
|
setInspMethodCatOptions(flatMethods);
|
|
} catch { /* skip */ }
|
|
|
|
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
|
|
setUserOptions(users.map((u: any) => ({
|
|
code: u.user_id || u.id,
|
|
label: `${u.user_name || u.name || u.user_id}${u.dept_name ? ` (${u.dept_name})` : ""}`,
|
|
})));
|
|
} catch { /* skip */ }
|
|
};
|
|
loadOptions();
|
|
}, []);
|
|
|
|
/* ═══════════════════ 품목 선택 모달 ═══════════════════ */
|
|
const openItemModal = () => {
|
|
setItemSearchKeyword("");
|
|
setFilteredItems(itemOptions);
|
|
setItemModalOpen(true);
|
|
};
|
|
|
|
const handleItemSearch = () => {
|
|
const kw = itemSearchKeyword.trim().toLowerCase();
|
|
if (!kw) { setFilteredItems(itemOptions); return; }
|
|
setFilteredItems(itemOptions.filter(o =>
|
|
o.code.toLowerCase().includes(kw) || o.name.toLowerCase().includes(kw)
|
|
));
|
|
};
|
|
|
|
const selectItem = (item: typeof itemOptions[0]) => {
|
|
setForm(p => ({ ...p, item_code: item.code, item_name: item.name }));
|
|
setItemModalOpen(false);
|
|
};
|
|
|
|
/* ═══════════════════ 데이터 조회 ═══════════════════ */
|
|
const fetchData = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
|
|
const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
|
|
page: 1, size: 500,
|
|
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
|
autoFilter: true,
|
|
});
|
|
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
|
setData(rows);
|
|
setTotalCount(rows.length);
|
|
} catch {
|
|
toast.error("품목검사정보 조회에 실패했어요");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [searchFilters]);
|
|
|
|
useEffect(() => { fetchData(); }, [fetchData]);
|
|
|
|
// item_code별 그룹핑
|
|
const groupedData = useMemo(() => {
|
|
const map: Record<string, { item_code: string; item_name: string; is_active: string; types: string[]; rows: any[] }> = {};
|
|
for (const row of data) {
|
|
const key = row.item_code || row.id;
|
|
if (!map[key]) {
|
|
map[key] = { item_code: row.item_code, item_name: row.item_name, is_active: row.is_active || "", types: [], rows: [] };
|
|
}
|
|
map[key].rows.push(row);
|
|
if (row.inspection_type && !map[key].types.includes(row.inspection_type)) {
|
|
map[key].types.push(row.inspection_type);
|
|
}
|
|
}
|
|
return Object.values(map);
|
|
}, [data]);
|
|
|
|
// 검사기준 ID → 라벨 resolve
|
|
const resolveInspLabel = useCallback((id: string) => {
|
|
const opt = inspOptions.find(o => o.code === id);
|
|
return opt?.label || id || "-";
|
|
}, [inspOptions]);
|
|
|
|
/* ═══════════════════ CRUD ═══════════════════ */
|
|
const openCreate = () => { setForm({}); setEditMode(false); setInspectionRows({}); setCollapsedTypes({}); setModalOpen(true); };
|
|
const openEdit = async (row: any) => {
|
|
setForm({ ...row });
|
|
setEditMode(true);
|
|
setCollapsedTypes({});
|
|
|
|
// 같은 item_code의 모든 검사항목 행을 조회하여 유형별로 분류
|
|
try {
|
|
const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
|
|
page: 1, size: 500,
|
|
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: row.item_code }] },
|
|
autoFilter: true,
|
|
});
|
|
const allRows = res.data?.data?.data || res.data?.data?.rows || [];
|
|
const rowMap: Record<string, InspectionRow[]> = {};
|
|
const typeFlags: Record<string, boolean> = {};
|
|
|
|
for (const r of allRows) {
|
|
const inspType = r.inspection_type || "";
|
|
// 카테고리 코드/라벨로 INSPECTION_TYPES 키 매칭
|
|
const matched = INSPECTION_TYPES.find(t =>
|
|
t.matchLabels.some(ml => inspType.includes(ml)) ||
|
|
inspTypeCatOptions.some(cat => inspType.includes(cat.code) && t.matchLabels.some(ml => cat.label.includes(ml)))
|
|
);
|
|
const typeKey = matched?.key || "";
|
|
if (!typeKey) continue;
|
|
typeFlags[typeKey] = true;
|
|
if (!rowMap[typeKey]) rowMap[typeKey] = [];
|
|
const mCode = r.inspection_method || "";
|
|
const mLabel = inspMethodCatOptions.find(o => o.code === mCode)?.label || mCode;
|
|
rowMap[typeKey].push({
|
|
id: r.id,
|
|
inspection_standard_id: r.inspection_standard_id || "",
|
|
inspection_detail: r.inspection_item_name || r.inspection_standard || "",
|
|
inspection_method: mLabel,
|
|
apply_process: "",
|
|
acceptance_criteria: r.pass_criteria || "",
|
|
is_required: r.is_required === "true" || r.is_required === true,
|
|
});
|
|
}
|
|
|
|
setInspectionRows(rowMap);
|
|
setForm(p => ({ ...p, ...typeFlags }));
|
|
} catch {
|
|
setInspectionRows({});
|
|
}
|
|
setModalOpen(true);
|
|
};
|
|
|
|
/* ═══════════════════ 검사항목 행 관리 ═══════════════════ */
|
|
const addInspRow = (typeKey: string) => {
|
|
setInspectionRows(prev => ({
|
|
...prev,
|
|
[typeKey]: [...(prev[typeKey] || []), {
|
|
id: crypto.randomUUID(),
|
|
inspection_standard_id: "",
|
|
inspection_detail: "",
|
|
inspection_method: "",
|
|
apply_process: "",
|
|
acceptance_criteria: "",
|
|
is_required: false,
|
|
}],
|
|
}));
|
|
};
|
|
|
|
const removeInspRow = (typeKey: string, rowId: string) => {
|
|
setInspectionRows(prev => ({
|
|
...prev,
|
|
[typeKey]: (prev[typeKey] || []).filter(r => r.id !== rowId),
|
|
}));
|
|
};
|
|
|
|
const updateInspRow = (typeKey: string, rowId: string, field: string, value: any) => {
|
|
setInspectionRows(prev => ({
|
|
...prev,
|
|
[typeKey]: (prev[typeKey] || []).map(r => {
|
|
if (r.id !== rowId) return r;
|
|
if (field === "inspection_standard_id") {
|
|
const opt = inspOptions.find(o => o.code === value);
|
|
const methodCode = opt?.method || "";
|
|
const methodLabel = inspMethodCatOptions.find(o => o.code === methodCode)?.label || methodCode;
|
|
return { ...r, inspection_standard_id: value, inspection_detail: opt?.detail || "", inspection_method: methodLabel };
|
|
}
|
|
return { ...r, [field]: value };
|
|
}),
|
|
}));
|
|
};
|
|
|
|
/** 검사유형 키에 매칭되는 검사기준만 필터링 */
|
|
const getFilteredInspOptions = (typeKey: string) => {
|
|
const typeDef = INSPECTION_TYPES.find(t => t.key === typeKey);
|
|
if (!typeDef) return inspOptions;
|
|
// matchLabels와 카테고리 라벨을 비교하여 해당 카테고리 코드를 찾음
|
|
const matchCodes = inspTypeCatOptions
|
|
.filter(cat => typeDef.matchLabels.some(ml => cat.label.includes(ml)))
|
|
.map(cat => cat.code);
|
|
if (matchCodes.length === 0) return inspOptions;
|
|
return inspOptions.filter(opt => opt.types.some(t => matchCodes.includes(t)));
|
|
};
|
|
|
|
const toggleCollapse = (typeKey: string) => {
|
|
setCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] }));
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
if (!form.item_code) { toast.error("품목코드는 필수 입력이에요"); return; }
|
|
setSaving(true);
|
|
try {
|
|
// 기존 행 삭제 (수정 모드)
|
|
if (editMode) {
|
|
const existRes = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
|
|
page: 1, size: 500,
|
|
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: form.item_code }] },
|
|
autoFilter: true,
|
|
});
|
|
const existing = existRes.data?.data?.data || existRes.data?.data?.rows || [];
|
|
if (existing.length > 0) {
|
|
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, {
|
|
data: existing.map((r: any) => ({ id: r.id })),
|
|
});
|
|
}
|
|
}
|
|
|
|
// 검사유형별 항목을 개별 행으로 INSERT
|
|
const enabledTypes = INSPECTION_TYPES.filter(t => !!form[t.key]);
|
|
const rows: any[] = [];
|
|
for (const t of enabledTypes) {
|
|
const typeLabel = t.label;
|
|
const typeRows = inspectionRows[t.key] || [];
|
|
if (typeRows.length === 0) {
|
|
// 유형만 체크하고 항목 없는 경우에도 1행 생성
|
|
rows.push({
|
|
id: crypto.randomUUID(),
|
|
item_code: form.item_code,
|
|
item_name: form.item_name,
|
|
inspection_type: typeLabel,
|
|
is_active: form.is_active || "사용",
|
|
manager_id: form.manager_id || "",
|
|
memo: form.remarks || "",
|
|
});
|
|
} else {
|
|
for (const r of typeRows) {
|
|
rows.push({
|
|
id: crypto.randomUUID(),
|
|
item_code: form.item_code,
|
|
item_name: form.item_name,
|
|
inspection_type: typeLabel,
|
|
inspection_standard_id: r.inspection_standard_id || "",
|
|
inspection_item_name: r.inspection_detail || "",
|
|
inspection_method: r.inspection_method || "",
|
|
pass_criteria: r.acceptance_criteria || "",
|
|
is_required: r.is_required ? "true" : "false",
|
|
is_active: form.is_active || "사용",
|
|
manager_id: form.manager_id || "",
|
|
memo: form.remarks || "",
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const row of rows) {
|
|
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, row);
|
|
}
|
|
|
|
toast.success(editMode ? "품목검사정보를 수정했어요" : "품목검사정보를 등록했어요");
|
|
setModalOpen(false);
|
|
fetchData();
|
|
} catch { toast.error("저장에 실패했어요"); }
|
|
finally { setSaving(false); }
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
if (checkedIds.length === 0) { toast.error("삭제할 항목을 선택해주세요"); return; }
|
|
const ok = await confirm("품목검사정보 삭제", {
|
|
description: `선택한 ${checkedIds.length}건을 삭제할까요? 이 작업은 되돌릴 수 없어요.`,
|
|
});
|
|
if (!ok) return;
|
|
try {
|
|
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, {
|
|
data: checkedIds.map(id => ({ id })),
|
|
});
|
|
toast.success(`${checkedIds.length}건을 삭제했어요`);
|
|
setCheckedIds([]);
|
|
fetchData();
|
|
} catch { toast.error("삭제에 실패했어요"); }
|
|
};
|
|
|
|
/* ═══════════════════ JSX ═══════════════════ */
|
|
return (
|
|
<div className="flex flex-col gap-3 p-3">
|
|
{ConfirmDialogComponent}
|
|
|
|
<div className="rounded-lg border bg-card">
|
|
<div className="px-3 py-2.5 border-b bg-muted/50">
|
|
<DynamicSearchFilter
|
|
tableName={TABLE_NAME}
|
|
filterId="c16-item-inspection"
|
|
onFilterChange={setSearchFilters}
|
|
externalFilterConfig={ts.filterConfig}
|
|
dataCount={totalCount}
|
|
extraActions={
|
|
<div className="flex items-center gap-2">
|
|
<Button size="sm" onClick={openCreate}><Plus className="w-4 h-4 mr-1" />등록</Button>
|
|
<Button size="sm" variant="outline" onClick={() => {
|
|
const sel = data.find(r => checkedIds.includes(r.id));
|
|
if (sel) {
|
|
const group = groupedData.find(g => g.item_code === sel.item_code);
|
|
openEdit(group?.rows[0] || sel);
|
|
} else toast.error("수정할 항목을 선택해주세요");
|
|
}}><Pencil className="w-4 h-4 mr-1" />수정</Button>
|
|
<Button size="sm" variant="destructive" onClick={handleDelete}><Trash2 className="w-4 h-4 mr-1" />삭제</Button>
|
|
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
|
|
<Settings2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="p-3 overflow-auto">
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-16"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
|
|
) : groupedData.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
|
<Inbox className="h-10 w-10 mb-2 opacity-30" />
|
|
<p className="text-sm">등록된 품목검사정보가 없어요</p>
|
|
</div>
|
|
) : (
|
|
<Table>
|
|
<TableHeader className="sticky top-0 z-10">
|
|
<TableRow className="bg-muted hover:bg-muted">
|
|
<TableHead className="w-10" />
|
|
<TableHead className="w-10"><Checkbox checked={groupedData.length > 0 && checkedIds.length === data.length} onCheckedChange={(v) => setCheckedIds(v ? data.map(r => r.id) : [])} /></TableHead>
|
|
{ts.visibleColumns.map((col) => (
|
|
<TableHead key={col.key} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground" style={ts.thStyle(col.key)}>
|
|
{col.label}
|
|
</TableHead>
|
|
))}
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{ts.groupData(groupedData).map((group) => {
|
|
if ((group as any)._isGroupSummary || (group as any)._isGroupHeader) return null;
|
|
const isExpanded = expandedItems.has(group.item_code);
|
|
const groupIds = group.rows.map((r: any) => r.id);
|
|
const allChecked = groupIds.every((id: string) => checkedIds.includes(id));
|
|
const renderCell = (key: string) => {
|
|
switch (key) {
|
|
case "item_code": return <TableCell key={key} className="text-sm font-medium text-primary">{group.item_code}</TableCell>;
|
|
case "item_name": return <TableCell key={key} className="text-sm">{group.item_name}</TableCell>;
|
|
case "inspection_type": return (
|
|
<TableCell key={key}>
|
|
<div className="flex gap-1 flex-wrap">
|
|
{group.types.map((t: string) => <Badge key={t} variant="secondary" className="text-[10px]">{t}</Badge>)}
|
|
</div>
|
|
</TableCell>
|
|
);
|
|
case "item_count": return <TableCell key={key} className="text-sm text-center">{group.rows.filter((r: any) => r.inspection_standard_id).length}</TableCell>;
|
|
case "is_active": return (
|
|
<TableCell key={key}>
|
|
<Badge variant={group.is_active === "사용" || group.is_active === "true" ? "default" : "secondary"} className="text-[10px]">
|
|
{group.is_active === "사용" || group.is_active === "true" ? "사용" : "미사용"}
|
|
</Badge>
|
|
</TableCell>
|
|
);
|
|
default: return <TableCell key={key}>{(group as any)[key] ?? ""}</TableCell>;
|
|
}
|
|
};
|
|
return (
|
|
<React.Fragment key={group.item_code}>
|
|
<TableRow
|
|
className={cn("cursor-pointer border-l-2 border-l-transparent", allChecked && "border-l-primary bg-primary/5")}
|
|
onClick={() => setExpandedItems(prev => { const next = new Set(prev); if (next.has(group.item_code)) next.delete(group.item_code); else next.add(group.item_code); return next; })}
|
|
onDoubleClick={() => openEdit(group.rows[0])}
|
|
>
|
|
<TableCell className="text-center p-2">
|
|
<ChevronDown className={cn("w-4 h-4 transition-transform text-muted-foreground", !isExpanded && "-rotate-90")} />
|
|
</TableCell>
|
|
<TableCell className="text-center p-2" onClick={(e) => { e.stopPropagation(); setCheckedIds(prev => allChecked ? prev.filter(id => !groupIds.includes(id)) : [...new Set([...prev, ...groupIds])]); }}>
|
|
<Checkbox checked={allChecked} onCheckedChange={() => {}} />
|
|
</TableCell>
|
|
{ts.visibleColumns.map((col) => renderCell(col.key))}
|
|
</TableRow>
|
|
{isExpanded && (
|
|
<TableRow className="bg-muted/30">
|
|
<TableCell />
|
|
<TableCell />
|
|
<TableCell colSpan={ts.visibleColumns.length} className="p-0">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow className="bg-muted/50">
|
|
<TableHead className="text-[10px] font-bold h-7">검사유형</TableHead>
|
|
<TableHead className="text-[10px] font-bold h-7">검사기준</TableHead>
|
|
<TableHead className="text-[10px] font-bold h-7">검사항목</TableHead>
|
|
<TableHead className="text-[10px] font-bold h-7">검사방법</TableHead>
|
|
<TableHead className="text-[10px] font-bold h-7">합격기준</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{group.rows.filter((r: any) => r.inspection_standard_id).map((row: any) => (
|
|
<TableRow key={row.id} className="text-xs">
|
|
<TableCell className="py-1.5 text-muted-foreground">{row.inspection_type}</TableCell>
|
|
<TableCell className="py-1.5">{resolveInspLabel(row.inspection_standard_id)}</TableCell>
|
|
<TableCell className="py-1.5">{row.inspection_item_name || "-"}</TableCell>
|
|
<TableCell className="py-1.5">{row.inspection_method || "-"}</TableCell>
|
|
<TableCell className="py-1.5">{row.pass_criteria || "-"}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
<div className="mt-2 text-xs text-muted-foreground">전체 {groupedData.length}건 (품목 기준)</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ═══════════════════ 등록/수정 모달 (품목선택 뷰 포함) ═══════════════════ */}
|
|
<Dialog open={modalOpen} onOpenChange={(open) => { if (!open) setItemModalOpen(false); setModalOpen(open); }}>
|
|
<DialogContent className={cn("max-w-[95vw] max-h-[85vh] overflow-y-auto", itemModalOpen ? "sm:max-w-xl" : "sm:max-w-4xl")}>
|
|
{itemModalOpen ? (
|
|
<>
|
|
<DialogHeader>
|
|
<DialogTitle>품목 선택</DialogTitle>
|
|
<DialogDescription>품목코드 또는 품목명으로 검색</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
className="h-9 flex-1"
|
|
placeholder="품목코드 또는 품목명으로 검색"
|
|
value={itemSearchKeyword}
|
|
onChange={(e) => setItemSearchKeyword(e.target.value)}
|
|
onKeyDown={(e) => { if (e.key === "Enter") handleItemSearch(); }}
|
|
/>
|
|
<Button size="sm" className="h-9" onClick={handleItemSearch}>
|
|
<Search className="w-4 h-4 mr-1" />검색
|
|
</Button>
|
|
</div>
|
|
<div className="border rounded-lg overflow-auto max-h-[50vh]">
|
|
<Table>
|
|
<TableHeader className="sticky top-0 z-10">
|
|
<TableRow className="bg-muted hover:bg-muted">
|
|
<TableHead className="text-[11px] font-bold">품목코드</TableHead>
|
|
<TableHead className="text-[11px] font-bold">품목명</TableHead>
|
|
<TableHead className="text-[11px] font-bold">품목유형</TableHead>
|
|
<TableHead className="text-[11px] font-bold">단위</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{filteredItems.length === 0 ? (
|
|
<TableRow><TableCell colSpan={4} className="text-center py-8 text-muted-foreground text-sm">검색 결과가 없어요</TableCell></TableRow>
|
|
) : filteredItems.map((item) => (
|
|
<TableRow
|
|
key={item.code}
|
|
className="cursor-pointer hover:bg-primary/5"
|
|
onClick={() => selectItem(item)}
|
|
>
|
|
<TableCell className="text-sm">{item.code}</TableCell>
|
|
<TableCell className="text-sm">{item.name}</TableCell>
|
|
<TableCell className="text-sm">{item.item_type}</TableCell>
|
|
<TableCell className="text-sm">{item.unit}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setItemModalOpen(false)}>취소</Button>
|
|
</DialogFooter>
|
|
</>
|
|
) : (
|
|
<>
|
|
<DialogHeader>
|
|
<DialogTitle>{editMode ? "품목검사정보 수정" : "품목검사정보 등록"}</DialogTitle>
|
|
<DialogDescription className="sr-only">품목검사정보를 등록합니다</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-4">
|
|
{/* 품목 정보 */}
|
|
<div className="space-y-3">
|
|
<h4 className="text-sm font-semibold flex items-center gap-1.5">📦 품목 정보</h4>
|
|
<div className="flex items-end gap-2">
|
|
<div className="space-y-1.5 flex-1">
|
|
<Label className="text-xs font-semibold text-muted-foreground">품목코드 <span className="text-destructive">*</span></Label>
|
|
<Input className="h-9 bg-muted" value={form.item_code || ""} readOnly placeholder="품목코드" />
|
|
</div>
|
|
<div className="space-y-1.5 flex-1">
|
|
<Label className="text-xs font-semibold text-muted-foreground">품목명 <span className="text-destructive">*</span></Label>
|
|
<Input className="h-9 bg-muted" value={form.item_name || ""} readOnly placeholder="품목명" />
|
|
</div>
|
|
<Button type="button" variant="outline" size="sm" className="h-9 px-3 shrink-0" onClick={openItemModal}>
|
|
<Search className="w-4 h-4 mr-1" />품목선택
|
|
</Button>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-semibold text-muted-foreground">사용여부</Label>
|
|
<Select value={form.is_active === false ? "N" : "Y"} onValueChange={(v) => setForm(p => ({ ...p, is_active: v === "Y" }))}>
|
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="Y">사용</SelectItem>
|
|
<SelectItem value="N">미사용</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-semibold text-muted-foreground">관리자</Label>
|
|
<Select value={form.manager || ""} onValueChange={(v) => setForm(p => ({ ...p, manager: v }))}>
|
|
<SelectTrigger className="h-9"><SelectValue placeholder="관리자 선택" /></SelectTrigger>
|
|
<SelectContent>
|
|
{userOptions.map(o => (
|
|
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-semibold text-muted-foreground">비고</Label>
|
|
<textarea
|
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm min-h-[70px] resize-none focus:outline-none focus:ring-2 focus:ring-ring"
|
|
value={form.remarks || ""}
|
|
onChange={(e) => setForm(p => ({ ...p, remarks: e.target.value }))}
|
|
placeholder="비고 사항"
|
|
/>
|
|
</div>
|
|
</div>
|
|
{/* 검사유형 선택 */}
|
|
<div className="space-y-3">
|
|
<h4 className="text-sm font-semibold flex items-center gap-1.5">✅ 검사유형 선택</h4>
|
|
<div className="flex flex-wrap gap-4">
|
|
{INSPECTION_TYPES.map(({ key, label }) => (
|
|
<div key={key} className="flex items-center gap-1.5">
|
|
<Checkbox
|
|
checked={!!form[key]}
|
|
onCheckedChange={(v) => setForm(p => ({ ...p, [key]: !!v }))}
|
|
/>
|
|
<Label className="text-sm cursor-pointer">{label}</Label>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
{/* 검사유형별 검사항목 설정 */}
|
|
{INSPECTION_TYPES.filter(t => !!form[t.key]).map(({ key, label }) => (
|
|
<div key={key} className="space-y-2">
|
|
<button
|
|
type="button"
|
|
className="w-full flex items-center gap-2 py-2 px-3 rounded-md border bg-muted/50 hover:bg-muted text-left"
|
|
onClick={() => toggleCollapse(key)}
|
|
>
|
|
<Badge variant="default" className="text-xs">{label}</Badge>
|
|
<span className="text-sm font-medium">검사항목 설정</span>
|
|
<ChevronDown className={cn("w-4 h-4 ml-auto transition-transform", collapsedTypes[key] && "-rotate-90")} />
|
|
</button>
|
|
{!collapsedTypes[key] && (
|
|
<div className="space-y-2 pl-1">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs font-semibold text-muted-foreground">검사항목 목록</span>
|
|
<Button type="button" size="sm" variant="outline" className="h-7 text-xs" onClick={() => addInspRow(key)}>
|
|
<Plus className="w-3 h-3 mr-1" />항목추가
|
|
</Button>
|
|
</div>
|
|
<div className="border rounded-lg overflow-hidden">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow className="bg-muted/50 hover:bg-muted/50">
|
|
<TableHead className="text-[10px] font-bold w-[170px]">검사기준 선택</TableHead>
|
|
<TableHead className="text-[10px] font-bold w-[130px]">검사기준 상세</TableHead>
|
|
<TableHead className="text-[10px] font-bold w-[90px]">검사방법</TableHead>
|
|
<TableHead className="text-[10px] font-bold w-[100px]">적용공정</TableHead>
|
|
<TableHead className="text-[10px] font-bold">합격기준 (판단기준별)</TableHead>
|
|
<TableHead className="text-[10px] font-bold w-[40px]">필수</TableHead>
|
|
<TableHead className="text-[10px] font-bold w-[36px]" />
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{(!inspectionRows[key] || inspectionRows[key].length === 0) ? (
|
|
<TableRow><TableCell colSpan={7} className="text-center py-4 text-xs text-muted-foreground">항목추가 버튼으로 검사항목을 추가하세요</TableCell></TableRow>
|
|
) : inspectionRows[key].map((row) => (
|
|
<TableRow key={row.id}>
|
|
<TableCell className="p-1">
|
|
<Select value={row.inspection_standard_id || ""} onValueChange={(v) => updateInspRow(key, row.id, "inspection_standard_id", v)}>
|
|
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="검사기준 선택" /></SelectTrigger>
|
|
<SelectContent>
|
|
{getFilteredInspOptions(key).map(o => (
|
|
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</TableCell>
|
|
<TableCell className="p-1">
|
|
<Input className="h-8 text-xs bg-muted" value={row.inspection_detail} readOnly placeholder="검사기준 상세" />
|
|
</TableCell>
|
|
<TableCell className="p-1">
|
|
<Input className="h-8 text-xs bg-muted" value={row.inspection_method} readOnly placeholder="선택" />
|
|
</TableCell>
|
|
<TableCell className="p-1">
|
|
<Select value={row.apply_process || ""} onValueChange={(v) => updateInspRow(key, row.id, "apply_process", v)}>
|
|
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="공정 선택" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="incoming">입고</SelectItem>
|
|
<SelectItem value="process">공정</SelectItem>
|
|
<SelectItem value="outgoing">출고</SelectItem>
|
|
<SelectItem value="final">최종</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</TableCell>
|
|
<TableCell className="p-1">
|
|
<Input
|
|
className="h-8 text-xs"
|
|
value={row.acceptance_criteria}
|
|
onChange={(e) => updateInspRow(key, row.id, "acceptance_criteria", e.target.value)}
|
|
placeholder={row.inspection_standard_id ? "합격기준 입력" : "검사기준을 먼저 선택하세요"}
|
|
disabled={!row.inspection_standard_id}
|
|
/>
|
|
</TableCell>
|
|
<TableCell className="p-1 text-center">
|
|
<Checkbox checked={row.is_required} onCheckedChange={(v) => updateInspRow(key, row.id, "is_required", !!v)} />
|
|
</TableCell>
|
|
<TableCell className="p-1">
|
|
<Button type="button" variant="destructive" size="sm" className="h-7 w-7 p-0" onClick={() => removeInspRow(key, row.id)}>
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setModalOpen(false)}>취소</Button>
|
|
<Button onClick={handleSave} disabled={saving}>
|
|
{saving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}
|
|
저장
|
|
</Button>
|
|
</DialogFooter>
|
|
</>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<TableSettingsModal
|
|
open={ts.open}
|
|
onOpenChange={ts.setOpen}
|
|
tableName={ts.tableName}
|
|
settingsId={ts.settingsId}
|
|
defaultVisibleKeys={ts.defaultVisibleKeys}
|
|
onSave={ts.applySettings}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|