d03f92947d
- Integrated DynamicSearchFilter component to manage search filters. - Removed individual search state variables and replaced with a single searchFilters state. - Updated fetchData function to handle new filter structure. - Refactored search filter UI to utilize DynamicSearchFilter. - Adjusted table header styles for better visibility and consistency. style: Update global styles for improved UI consistency - Unified font size across the application to 16px, excluding buttons. - Adjusted header padding and font size for better readability. - Enhanced dark mode styles for checkboxes to ensure visibility. feat: Add Options Setting page for category and numbering configurations - Created a new OptionsSettingPage component with tabs for category and numbering settings. - Implemented drag-to-resize functionality for the category column list. - Integrated CategoryColumnList and CategoryValueManager components for managing categories. feat: Introduce useTableSettings hook for table configuration management - Developed useTableSettings hook to manage column visibility, order, and width. - Implemented localStorage persistence for table settings. - Enhanced TableSettingsModal to accept defaultVisibleKeys for initial column visibility. chore: Update AdminPageRenderer to include new COMPANY_16 routes - Added new routes for COMPANY_16 master-data options and other pages.
288 lines
14 KiB
TypeScript
288 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useCallback } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
|
import {
|
|
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2,
|
|
} from "lucide-react";
|
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
|
import { cn } from "@/lib/utils";
|
|
import { apiClient } from "@/lib/api/client";
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
import { useTableSettings } from "@/hooks/useTableSettings";
|
|
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
|
import { toast } from "sonner";
|
|
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
|
|
|
const TABLE_NAME = "item_inspection_info";
|
|
|
|
const GRID_COLUMNS = [
|
|
{ key: "item_code", label: "품목코드" },
|
|
{ key: "item_name", label: "품목명" },
|
|
{ key: "inspection_standard_id", label: "검사기준ID" },
|
|
{ key: "inspection_standard_name", label: "검사기준명" },
|
|
{ key: "inspection_level", label: "검사수준" },
|
|
{ key: "sampling_method", label: "샘플링방법" },
|
|
{ key: "is_active", label: "사용여부" },
|
|
];
|
|
const ITEM_TABLE = "item_info";
|
|
const INSPECTION_TABLE = "inspection_standard";
|
|
|
|
export default function ItemInspectionInfoPage() {
|
|
const { user } = useAuth();
|
|
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
|
const ts = useTableSettings("c16-item-inspection", TABLE_NAME, GRID_COLUMNS);
|
|
|
|
const [data, setData] = useState<any[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [totalCount, setTotalCount] = useState(0);
|
|
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
|
const [checkedIds, setCheckedIds] = useState<string[]>([]);
|
|
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
const [editMode, setEditMode] = useState(false);
|
|
const [form, setForm] = useState<Record<string, any>>({});
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
/* FK 옵션 */
|
|
const [itemOptions, setItemOptions] = useState<{ code: string; label: string }[]>([]);
|
|
const [inspOptions, setInspOptions] = useState<{ code: string; label: string }[]>([]);
|
|
|
|
/* ═══════════════════ FK 옵션 로드 ═══════════════════ */
|
|
useEffect(() => {
|
|
const loadOptions = async () => {
|
|
try {
|
|
const [itemRes, inspRes] = 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 }),
|
|
]);
|
|
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
|
|
setItemOptions(items.map((r: any) => ({ code: r.item_code, label: `${r.item_code} - ${r.item_name || ""}` })));
|
|
|
|
const insps = inspRes.data?.data?.data || inspRes.data?.data?.rows || [];
|
|
setInspOptions(insps.map((r: any) => ({ code: r.id, label: r.inspection_standard || r.id })));
|
|
} catch { /* skip */ }
|
|
};
|
|
loadOptions();
|
|
}, []);
|
|
|
|
/* ═══════════════════ 데이터 조회 ═══════════════════ */
|
|
const fetchData = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
|
|
const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
|
|
page: 1, size: 500,
|
|
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
|
autoFilter: true,
|
|
});
|
|
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
|
setData(rows);
|
|
setTotalCount(rows.length);
|
|
} catch {
|
|
toast.error("품목검사정보 조회에 실패했어요");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [searchFilters]);
|
|
|
|
useEffect(() => { fetchData(); }, [fetchData]);
|
|
|
|
/* ═══════════════════ CRUD ═══════════════════ */
|
|
const openCreate = () => { setForm({}); setEditMode(false); setModalOpen(true); };
|
|
const openEdit = (row: any) => { setForm({ ...row }); setEditMode(true); setModalOpen(true); };
|
|
|
|
const handleSave = async () => {
|
|
if (!form.item_code) { toast.error("품목코드는 필수 입력이에요"); return; }
|
|
setSaving(true);
|
|
try {
|
|
if (editMode) {
|
|
await apiClient.put(`/table-management/tables/${TABLE_NAME}/edit`, {
|
|
originalData: { id: form.id }, updatedData: form,
|
|
});
|
|
toast.success("품목검사정보를 수정했어요");
|
|
} else {
|
|
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, { id: crypto.randomUUID(), ...form });
|
|
toast.success("품목검사정보를 등록했어요");
|
|
}
|
|
setModalOpen(false);
|
|
fetchData();
|
|
} catch { toast.error("저장에 실패했어요"); }
|
|
finally { setSaving(false); }
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
if (checkedIds.length === 0) { toast.error("삭제할 항목을 선택해주세요"); return; }
|
|
const ok = await confirm("품목검사정보 삭제", {
|
|
description: `선택한 ${checkedIds.length}건을 삭제할까요? 이 작업은 되돌릴 수 없어요.`,
|
|
});
|
|
if (!ok) return;
|
|
try {
|
|
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, {
|
|
data: checkedIds.map(id => ({ id })),
|
|
});
|
|
toast.success(`${checkedIds.length}건을 삭제했어요`);
|
|
setCheckedIds([]);
|
|
fetchData();
|
|
} catch { toast.error("삭제에 실패했어요"); }
|
|
};
|
|
|
|
/* ═══════════════════ JSX ═══════════════════ */
|
|
return (
|
|
<div className="flex flex-col gap-3 p-3">
|
|
{ConfirmDialogComponent}
|
|
|
|
<div className="rounded-lg border bg-card">
|
|
<div className="px-3 py-2.5 border-b bg-muted/50">
|
|
<DynamicSearchFilter
|
|
tableName={TABLE_NAME}
|
|
filterId="c16-item-inspection"
|
|
onFilterChange={setSearchFilters}
|
|
externalFilterConfig={ts.filterConfig}
|
|
dataCount={totalCount}
|
|
extraActions={
|
|
<div className="flex items-center gap-2">
|
|
<Button size="sm" onClick={openCreate}><Plus className="w-4 h-4 mr-1" />등록</Button>
|
|
<Button size="sm" variant="outline" onClick={() => {
|
|
const sel = data.find(r => checkedIds.includes(r.id));
|
|
if (sel) openEdit(sel);
|
|
else toast.error("수정할 항목을 선택해주세요");
|
|
}}><Pencil className="w-4 h-4 mr-1" />수정</Button>
|
|
<Button size="sm" variant="destructive" onClick={handleDelete}><Trash2 className="w-4 h-4 mr-1" />삭제</Button>
|
|
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
|
|
<Settings2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="p-3">
|
|
<div className="border rounded-lg overflow-hidden">
|
|
<Table>
|
|
<TableHeader className="sticky top-0 z-10">
|
|
<TableRow className="bg-muted hover:bg-muted">
|
|
<TableHead className="w-10">
|
|
<Checkbox
|
|
checked={data.length > 0 && checkedIds.length === data.length}
|
|
onCheckedChange={(v) => setCheckedIds(v ? data.map(r => r.id) : [])}
|
|
/>
|
|
</TableHead>
|
|
{ts.visibleColumns.map((col) => (
|
|
<TableHead key={col.key} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">{col.label}</TableHead>
|
|
))}
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{loading ? (
|
|
<TableRow><TableCell colSpan={ts.visibleColumns.length + 1} className="text-center py-8"><Loader2 className="w-5 h-5 animate-spin mx-auto text-muted-foreground" /></TableCell></TableRow>
|
|
) : data.length === 0 ? (
|
|
<TableRow><TableCell colSpan={ts.visibleColumns.length + 1} className="text-center py-10 text-muted-foreground"><Inbox className="w-8 h-8 mx-auto mb-2 opacity-40" /><p className="text-sm">등록된 품목검사정보가 없어요</p></TableCell></TableRow>
|
|
) : data.map((row) => (
|
|
<TableRow
|
|
key={row.id}
|
|
className={cn("cursor-pointer", checkedIds.includes(row.id) && "bg-primary/5")}
|
|
onClick={() => setCheckedIds(prev => prev.includes(row.id) ? prev.filter(id => id !== row.id) : [...prev, row.id])}
|
|
onDoubleClick={() => openEdit(row)}
|
|
>
|
|
<TableCell onClick={(e) => e.stopPropagation()}>
|
|
<Checkbox checked={checkedIds.includes(row.id)} onCheckedChange={(v) => setCheckedIds(prev => v ? [...prev, row.id] : prev.filter(id => id !== row.id))} />
|
|
</TableCell>
|
|
{ts.visibleColumns.map((col) => (
|
|
<TableCell key={col.key}>
|
|
{col.key === "is_active"
|
|
? <Badge variant={row.is_active ? "default" : "secondary"} className="text-xs">{row.is_active ? "사용" : "미사용"}</Badge>
|
|
: row[col.key] ?? ""}
|
|
</TableCell>
|
|
))}
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ═══════════════════ 등록/수정 모달 ═══════════════════ */}
|
|
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
|
<DialogContent className="max-w-[95vw] sm:max-w-lg max-h-[85vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>{editMode ? "품목검사정보 수정" : "품목검사정보 등록"}</DialogTitle>
|
|
<DialogDescription>품목과 검사기준을 연결해주세요</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1.5 col-span-2">
|
|
<Label className="text-xs font-semibold text-muted-foreground">품목코드 <span className="text-destructive">*</span></Label>
|
|
<Select value={form.item_code || ""} onValueChange={(v) => {
|
|
const opt = itemOptions.find(o => o.code === v);
|
|
const name = opt ? opt.label.split(" - ").slice(1).join(" - ") : "";
|
|
setForm(p => ({ ...p, item_code: v, item_name: name }));
|
|
}}>
|
|
<SelectTrigger className="h-9"><SelectValue placeholder="품목을 선택해주세요" /></SelectTrigger>
|
|
<SelectContent>
|
|
{itemOptions.map(o => (
|
|
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-1.5 col-span-2">
|
|
<Label className="text-xs font-semibold text-muted-foreground">품목명</Label>
|
|
<Input className="h-9 bg-muted" value={form.item_name || ""} readOnly placeholder="품목 선택 시 자동입력" />
|
|
</div>
|
|
<div className="space-y-1.5 col-span-2">
|
|
<Label className="text-xs font-semibold text-muted-foreground">검사기준</Label>
|
|
<Select value={form.inspection_standard_id || ""} onValueChange={(v) => {
|
|
const opt = inspOptions.find(o => o.code === v);
|
|
setForm(p => ({ ...p, inspection_standard_id: v, inspection_standard_name: opt?.label || "" }));
|
|
}}>
|
|
<SelectTrigger className="h-9"><SelectValue placeholder="검사기준을 선택해주세요" /></SelectTrigger>
|
|
<SelectContent>
|
|
{inspOptions.map(o => (
|
|
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-semibold text-muted-foreground">검사수준</Label>
|
|
<Input className="h-9" value={form.inspection_level || ""} onChange={(e) => setForm(p => ({ ...p, inspection_level: e.target.value }))} placeholder="검사수준" />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-semibold text-muted-foreground">샘플링방법</Label>
|
|
<Input className="h-9" value={form.sampling_method || ""} onChange={(e) => setForm(p => ({ ...p, sampling_method: e.target.value }))} placeholder="샘플링방법" />
|
|
</div>
|
|
<div className="space-y-1.5 col-span-2">
|
|
<div className="flex items-center gap-2">
|
|
<Checkbox checked={form.is_active ?? true} onCheckedChange={(v) => setForm(p => ({ ...p, is_active: !!v }))} />
|
|
<Label className="text-sm">사용</Label>
|
|
</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>
|
|
);
|
|
}
|