Files
pipeline/frontend/app/(main)/COMPANY_30/quality/inspection/page.tsx
T
kjs 58faa1f759 refactor: Improve inspection management page functionality and UI
- Enhanced the rendering of the manager column to display user labels instead of IDs for better clarity.
- Updated the default state for the defect form to set is_active to "사용" upon creation.
- Simplified the badge rendering logic for the is_active status to improve readability.
- Adjusted the select component for active status to remove unnecessary options and streamline user interaction.

These changes aim to enhance the user experience and data representation in the inspection management process across multiple companies.
2026-04-10 17:32:33 +09:00

1699 lines
74 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
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,
ClipboardCheck,
AlertTriangle,
Wrench,
Search,
Inbox,
Settings2,
} from "lucide-react";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
/* ───── 테이블명 ───── */
const INSPECTION_TABLE = "inspection_standard";
const INSPECTION_COLUMNS = [
{ key: "inspection_code", label: "검사코드" },
{ key: "inspection_type", label: "검사유형" },
{ key: "inspection_criteria", label: "검사기준" },
{ key: "inspection_item", label: "검사항목" },
{ key: "inspection_method", label: "검사방법" },
{ key: "judgment_criteria", label: "판단기준" },
{ key: "unit", label: "단위" },
{ key: "apply_type", label: "적용구분" },
{ key: "manager", label: "관리자" },
];
const DEFECT_TABLE = "defect_standard_mng";
const EQUIPMENT_TABLE = "inspection_equipment_mng";
/* ───── 카테고리 flatten ───── */
const flattenCategories = (vals: any[]): { code: string; label: string }[] => {
const result: { code: string; label: string }[] = [];
for (const v of vals) {
result.push({ code: v.valueCode, label: v.valueLabel });
if (v.children?.length) result.push(...flattenCategories(v.children));
}
return result;
};
export default function InspectionManagementPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
const ts = useTableSettings("c16-inspection", INSPECTION_TABLE, INSPECTION_COLUMNS);
const [activeTab, setActiveTab] = useState("inspection");
/* ───── 검사기준 ───── */
const [inspections, setInspections] = useState<any[]>([]);
const [inspLoading, setInspLoading] = useState(false);
const [inspCount, setInspCount] = useState(0);
const [inspChecked, setInspChecked] = useState<string[]>([]);
const [inspModalOpen, setInspModalOpen] = useState(false);
const [inspEditMode, setInspEditMode] = useState(false);
const [inspForm, setInspForm] = useState<Record<string, any>>({});
const [inspSaving, setInspSaving] = useState(false);
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
/* ───── 불량관리 ───── */
const [defects, setDefects] = useState<any[]>([]);
const [defLoading, setDefLoading] = useState(false);
const [defCount, setDefCount] = useState(0);
const [defChecked, setDefChecked] = useState<string[]>([]);
const [defModalOpen, setDefModalOpen] = useState(false);
const [defEditMode, setDefEditMode] = useState(false);
const [defForm, setDefForm] = useState<Record<string, any>>({});
const [defSaving, setDefSaving] = useState(false);
const [defKeyword, setDefKeyword] = useState("");
/* ───── 검사장비 ───── */
const [equipments, setEquipments] = useState<any[]>([]);
const [eqLoading, setEqLoading] = useState(false);
const [eqCount, setEqCount] = useState(0);
const [eqChecked, setEqChecked] = useState<string[]>([]);
const [eqModalOpen, setEqModalOpen] = useState(false);
const [eqEditMode, setEqEditMode] = useState(false);
const [eqForm, setEqForm] = useState<Record<string, any>>({});
const [eqSaving, setEqSaving] = useState(false);
const [eqKeyword, setEqKeyword] = useState("");
/* ───── 채번 ───── */
const [numberingRuleId, setNumberingRuleId] = useState<string | null>(null);
const [previewCode, setPreviewCode] = useState<string | null>(null);
/* ───── 카테고리 옵션 ───── */
const [catOptions, setCatOptions] = useState<Record<string, { code: string; label: string }[]>>({});
const [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]);
/* ═══════════════════ 카테고리 로드 ═══════════════════ */
useEffect(() => {
const load = async () => {
const optMap: Record<string, { code: string; label: string }[]> = {};
const catList = [
{ table: INSPECTION_TABLE, col: "inspection_type" },
{ table: INSPECTION_TABLE, col: "apply_type" },
{ table: INSPECTION_TABLE, col: "inspection_method" },
{ table: INSPECTION_TABLE, col: "judgment_criteria" },
{ table: INSPECTION_TABLE, col: "unit" },
{ table: DEFECT_TABLE, col: "defect_type" },
{ table: DEFECT_TABLE, col: "severity" },
{ table: DEFECT_TABLE, col: "inspection_type" },
{ table: DEFECT_TABLE, col: "is_active" },
{ table: EQUIPMENT_TABLE, col: "equipment_type" },
{ table: EQUIPMENT_TABLE, col: "equipment_status" },
];
await Promise.all(
catList.map(async ({ table, col }) => {
try {
const res = await apiClient.get(`/table-categories/${table}/${col}/values`);
if (res.data?.data?.length > 0) {
optMap[`${table}.${col}`] = flattenCategories(res.data.data);
}
} catch {
/* skip */
}
}),
);
setCatOptions(optMap);
// 사용자 목록 로드
try {
const userRes = await apiClient.post(`/table-management/tables/user_info/data`, {
page: 1,
size: 500,
autoFilter: true,
});
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 */
}
};
load();
}, []);
const getCatLabel = (table: string, col: string, code: string) => {
if (!code) return "";
const opts = catOptions[`${table}.${col}`];
if (!opts) return code;
// 쉼표 구분 다중 코드 지원
if (code.includes(",")) {
return code
.split(",")
.filter(Boolean)
.map((c) => opts.find((o) => o.code === c)?.label || c)
.join(", ");
}
return opts.find((o) => o.code === code)?.label || code;
};
const inspTableColumns = useMemo<EDataTableColumn[]>(() => {
return ts.visibleColumns.map((col) => {
const base: EDataTableColumn = { key: col.key, label: col.label };
if (["inspection_type", "inspection_method", "judgment_criteria", "unit", "apply_type"].includes(col.key)) {
base.render = (v: any, row: any) => getCatLabel(INSPECTION_TABLE, col.key, row[col.key]);
}
if (col.key === "manager") {
base.render = (v: any, row: any) => userOptions.find((u) => u.code === row.manager)?.label || row.manager || "-";
}
return base;
});
}, [ts.visibleColumns, catOptions, userOptions]); // eslint-disable-line react-hooks/exhaustive-deps
/* ═══════════════════ 데이터 조회 ═══════════════════ */
// 다중값 컬럼 (쉼표 구분 저장) — 서버 equals 대신 contains 사용
const MULTI_VALUE_COLUMNS = ["inspection_type"];
const fetchInspections = useCallback(async () => {
setInspLoading(true);
try {
const filters = searchFilters.map((f) => ({
columnName: f.columnName,
operator: MULTI_VALUE_COLUMNS.includes(f.columnName) ? "contains" : f.operator,
value: f.value,
}));
const res = await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/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 || [];
setInspections(rows);
setInspCount(rows.length);
} catch {
toast.error("검사기준 조회에 실패했어요");
} finally {
setInspLoading(false);
}
}, [searchFilters]);
const fetchDefects = useCallback(async () => {
setDefLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${DEFECT_TABLE}/data`, {
page: 1,
size: 500,
autoFilter: true,
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
setDefects(rows);
setDefCount(rows.length);
} catch {
toast.error("불량관리 조회에 실패했어요");
} finally {
setDefLoading(false);
}
}, []);
const fetchEquipments = useCallback(async () => {
setEqLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${EQUIPMENT_TABLE}/data`, {
page: 1,
size: 500,
autoFilter: true,
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
setEquipments(rows);
setEqCount(rows.length);
} catch {
toast.error("검사장비 조회에 실패했어요");
} finally {
setEqLoading(false);
}
}, []);
useEffect(() => {
fetchInspections();
}, [fetchInspections]);
useEffect(() => {
fetchDefects();
fetchEquipments();
}, []);
/* ───── 클라이언트 필터 ───── */
const filteredDefects = defKeyword.trim()
? defects.filter(
(r) =>
(r.defect_name || "").toLowerCase().includes(defKeyword.toLowerCase()) ||
(r.defect_type || "").toLowerCase().includes(defKeyword.toLowerCase()),
)
: defects;
const filteredEquipments = eqKeyword.trim()
? equipments.filter(
(r) =>
(r.equipment_name || "").toLowerCase().includes(eqKeyword.toLowerCase()) ||
(r.model_name || "").toLowerCase().includes(eqKeyword.toLowerCase()),
)
: equipments;
/* ═══════════════════ 검사기준 CRUD ═══════════════════ */
const openInspCreate = async () => {
setInspForm({});
setInspEditMode(false);
setNumberingRuleId(null);
setPreviewCode(null);
setInspModalOpen(true);
try {
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${INSPECTION_TABLE}/inspection_code`);
const ruleData = ruleRes.data;
if (ruleData?.success && ruleData?.data?.ruleId) {
const ruleId = ruleData.data.ruleId;
setNumberingRuleId(ruleId);
const prev = await previewNumberingCode(ruleId);
if (prev.success && prev.data?.generatedCode) {
setPreviewCode(prev.data.generatedCode);
}
}
} catch { /* 채번 규칙 없으면 무시 */ }
};
const openInspEdit = (row: any) => {
setInspForm({ ...row });
setInspEditMode(true);
setInspModalOpen(true);
};
const saveInspection = async () => {
if (!numberingRuleId && !inspForm.inspection_code) {
toast.error("검사코드는 필수예요");
return;
}
if (!inspForm.inspection_type) {
toast.error("유형을 1개 이상 선택해주세요");
return;
}
if (!inspForm.inspection_criteria) {
toast.error("검사기준은 필수예요");
return;
}
if (!inspForm.inspection_item) {
toast.error("검사항목은 필수예요");
return;
}
if (!inspForm.judgment_criteria) {
toast.error("판단기준은 필수예요");
return;
}
setInspSaving(true);
try {
let finalCode = inspForm.inspection_code || "";
if (!inspEditMode && numberingRuleId) {
const allocRes = await allocateNumberingCode(numberingRuleId);
if (allocRes.success && allocRes.data?.generatedCode) {
finalCode = allocRes.data.generatedCode;
} else {
toast.error("채번 코드 할당에 실패했습니다.");
setInspSaving(false);
return;
}
}
if (inspEditMode) {
await apiClient.put(`/table-management/tables/${INSPECTION_TABLE}/edit`, {
originalData: { id: inspForm.id },
updatedData: inspForm,
});
toast.success("검사기준을 수정했어요");
} else {
await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/add`, {
id: crypto.randomUUID(),
...inspForm,
inspection_code: finalCode,
});
toast.success("검사기준을 등록했어요");
}
setInspModalOpen(false);
fetchInspections();
} catch {
toast.error("저장에 실패했어요");
} finally {
setInspSaving(false);
}
};
const deleteInspections = async () => {
if (inspChecked.length === 0) {
toast.error("삭제할 항목을 선택해주세요");
return;
}
const ok = await confirm("검사기준 삭제", {
description: `선택한 ${inspChecked.length}건을 삭제할까요? 이 작업은 되돌릴 수 없어요.`,
});
if (!ok) return;
try {
await apiClient.delete(`/table-management/tables/${INSPECTION_TABLE}/delete`, {
data: inspChecked.map((id) => ({ id })),
});
toast.success(`${inspChecked.length}건을 삭제했어요`);
setInspChecked([]);
fetchInspections();
} catch {
toast.error("삭제에 실패했어요");
}
};
/* ═══════════════════ 불량관리 CRUD ═══════════════════ */
const openDefCreate = async () => {
setDefForm({ is_active: "사용" });
setDefEditMode(false);
setNumberingRuleId(null);
setPreviewCode(null);
setDefModalOpen(true);
try {
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${DEFECT_TABLE}/defect_code`);
const ruleData = ruleRes.data;
if (ruleData?.success && ruleData?.data?.ruleId) {
const ruleId = ruleData.data.ruleId;
setNumberingRuleId(ruleId);
const prev = await previewNumberingCode(ruleId);
if (prev.success && prev.data?.generatedCode) {
setPreviewCode(prev.data.generatedCode);
}
}
} catch { /* 채번 규칙 없으면 무시 */ }
};
const openDefEdit = (row: any) => {
setDefForm({ ...row });
setDefEditMode(true);
setDefModalOpen(true);
};
const saveDefect = async () => {
if (!numberingRuleId && !defForm.defect_code) {
toast.error("불량코드는 필수예요");
return;
}
if (!defForm.defect_type) {
toast.error("불량유형은 필수예요");
return;
}
if (!defForm.defect_name) {
toast.error("불량명은 필수예요");
return;
}
if (!defForm.severity) {
toast.error("심각도는 필수예요");
return;
}
if (!defForm.defect_content) {
toast.error("불량내용은 필수예요");
return;
}
if (!defForm.inspection_type) {
toast.error("검사유형을 1개 이상 선택해주세요");
return;
}
setDefSaving(true);
try {
let finalCode = defForm.defect_code || "";
if (!defEditMode && numberingRuleId) {
const allocRes = await allocateNumberingCode(numberingRuleId);
if (allocRes.success && allocRes.data?.generatedCode) {
finalCode = allocRes.data.generatedCode;
} else {
toast.error("채번 코드 할당에 실패했습니다.");
setDefSaving(false);
return;
}
}
if (defEditMode) {
await apiClient.put(`/table-management/tables/${DEFECT_TABLE}/edit`, {
originalData: { id: defForm.id },
updatedData: defForm,
});
toast.success("불량유형을 수정했어요");
} else {
await apiClient.post(`/table-management/tables/${DEFECT_TABLE}/add`, { id: crypto.randomUUID(), ...defForm, defect_code: finalCode });
toast.success("불량유형을 등록했어요");
}
setDefModalOpen(false);
fetchDefects();
} catch {
toast.error("저장에 실패했어요");
} finally {
setDefSaving(false);
}
};
const deleteDefects = async () => {
if (defChecked.length === 0) {
toast.error("삭제할 항목을 선택해주세요");
return;
}
const ok = await confirm("불량유형 삭제", {
description: `선택한 ${defChecked.length}건을 삭제할까요? 이 작업은 되돌릴 수 없어요.`,
});
if (!ok) return;
try {
await apiClient.delete(`/table-management/tables/${DEFECT_TABLE}/delete`, {
data: defChecked.map((id) => ({ id })),
});
toast.success(`${defChecked.length}건을 삭제했어요`);
setDefChecked([]);
fetchDefects();
} catch {
toast.error("삭제에 실패했어요");
}
};
/* ═══════════════════ 검사장비 CRUD ═══════════════════ */
const openEqCreate = async () => {
setEqForm({
calibration_period: "12",
equipment_status: "NORMAL",
});
setEqEditMode(false);
setNumberingRuleId(null);
setPreviewCode(null);
setEqModalOpen(true);
try {
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${EQUIPMENT_TABLE}/equipment_code`);
const ruleData = ruleRes.data;
if (ruleData?.success && ruleData?.data?.ruleId) {
const ruleId = ruleData.data.ruleId;
setNumberingRuleId(ruleId);
const prev = await previewNumberingCode(ruleId);
if (prev.success && prev.data?.generatedCode) {
setPreviewCode(prev.data.generatedCode);
}
} else {
// 채번 규칙 없으면 기존 수동 채번 fallback
const maxNum =
equipments
.map((e: any) => e.equipment_code || "")
.filter((c: string) => /^EQP-\d+$/.test(c))
.map((c: string) => parseInt(c.replace("EQP-", ""), 10))
.sort((a: number, b: number) => b - a)[0] || 0;
setEqForm((p) => ({ ...p, equipment_code: `EQP-${String(maxNum + 1).padStart(3, "0")}` }));
}
} catch {
// 채번 규칙 조회 실패 시 기존 수동 채번 fallback
const maxNum =
equipments
.map((e: any) => e.equipment_code || "")
.filter((c: string) => /^EQP-\d+$/.test(c))
.map((c: string) => parseInt(c.replace("EQP-", ""), 10))
.sort((a: number, b: number) => b - a)[0] || 0;
setEqForm((p) => ({ ...p, equipment_code: `EQP-${String(maxNum + 1).padStart(3, "0")}` }));
}
};
const openEqEdit = (row: any) => {
setEqForm({ ...row });
setEqEditMode(true);
setEqModalOpen(true);
};
const saveEquipment = async () => {
if (!numberingRuleId && !eqForm.equipment_code) {
toast.error("장비코드는 필수예요");
return;
}
if (!eqForm.equipment_name) {
toast.error("장비명은 필수예요");
return;
}
if (!eqForm.equipment_type) {
toast.error("장비유형은 필수예요");
return;
}
setEqSaving(true);
try {
let finalCode = eqForm.equipment_code || "";
if (!eqEditMode && numberingRuleId) {
const allocRes = await allocateNumberingCode(numberingRuleId);
if (allocRes.success && allocRes.data?.generatedCode) {
finalCode = allocRes.data.generatedCode;
} else {
toast.error("채번 코드 할당에 실패했습니다.");
setEqSaving(false);
return;
}
}
if (eqEditMode) {
await apiClient.put(`/table-management/tables/${EQUIPMENT_TABLE}/edit`, {
originalData: { id: eqForm.id },
updatedData: eqForm,
});
toast.success("검사장비를 수정했어요");
} else {
await apiClient.post(`/table-management/tables/${EQUIPMENT_TABLE}/add`, { id: crypto.randomUUID(), ...eqForm, equipment_code: finalCode });
toast.success("검사장비를 등록했어요");
}
setEqModalOpen(false);
fetchEquipments();
} catch {
toast.error("저장에 실패했어요");
} finally {
setEqSaving(false);
}
};
const deleteEquipments = async () => {
if (eqChecked.length === 0) {
toast.error("삭제할 항목을 선택해주세요");
return;
}
const ok = await confirm("검사장비 삭제", {
description: `선택한 ${eqChecked.length}건을 삭제할까요? 이 작업은 되돌릴 수 없어요.`,
});
if (!ok) return;
try {
await apiClient.delete(`/table-management/tables/${EQUIPMENT_TABLE}/delete`, {
data: eqChecked.map((id) => ({ id })),
});
toast.success(`${eqChecked.length}건을 삭제했어요`);
setEqChecked([]);
fetchEquipments();
} catch {
toast.error("삭제에 실패했어요");
}
};
/* ═══════════════════ JSX ═══════════════════ */
return (
<div className="flex flex-col gap-3 p-3">
{ConfirmDialogComponent}
<div className="bg-card rounded-lg border">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<div className="border-b px-3">
<TabsList className="h-auto gap-0 bg-transparent p-0">
<TabsTrigger
value="inspection"
className="data-[state=active]:border-primary rounded-none border-b-2 border-transparent px-4 py-3 text-sm font-medium data-[state=active]:bg-transparent data-[state=active]:shadow-none"
>
<ClipboardCheck className="mr-2 h-4 w-4" />
<Badge variant="secondary" className="bg-primary/10 text-primary ml-2 text-xs">
{inspCount}
</Badge>
</TabsTrigger>
<TabsTrigger
value="defect"
className="data-[state=active]:border-primary rounded-none border-b-2 border-transparent px-4 py-3 text-sm font-medium data-[state=active]:bg-transparent data-[state=active]:shadow-none"
>
<AlertTriangle className="mr-2 h-4 w-4" />
<Badge variant="secondary" className="bg-primary/10 text-primary ml-2 text-xs">
{defCount}
</Badge>
</TabsTrigger>
<TabsTrigger
value="equipment"
className="data-[state=active]:border-primary rounded-none border-b-2 border-transparent px-4 py-3 text-sm font-medium data-[state=active]:bg-transparent data-[state=active]:shadow-none"
>
<Wrench className="mr-2 h-4 w-4" />
<Badge variant="secondary" className="bg-primary/10 text-primary ml-2 text-xs">
{eqCount}
</Badge>
</TabsTrigger>
</TabsList>
</div>
{/* ──── 검사기준 탭 ──── */}
<TabsContent value="inspection" className="mt-0 p-3">
<div className="mb-3">
<DynamicSearchFilter
tableName={INSPECTION_TABLE}
filterId="c16-inspection"
onFilterChange={setSearchFilters}
externalFilterConfig={ts.filterConfig}
dataCount={inspCount}
extraActions={
<div className="flex items-center gap-2">
<Button size="sm" onClick={openInspCreate}>
<Plus className="mr-1 h-4 w-4" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
const sel = inspections.find((r) => inspChecked.includes(r.id));
if (sel) openInspEdit(sel);
else toast.error("수정할 항목을 선택해주세요");
}}
>
<Pencil className="mr-1 h-4 w-4" />
</Button>
<Button size="sm" variant="destructive" onClick={deleteInspections}>
<Trash2 className="mr-1 h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
<Settings2 className="h-4 w-4" />
</Button>
</div>
}
/>
</div>
<div className="overflow-hidden rounded-lg border">
<EDataTable
columns={inspTableColumns}
data={ts.groupData(inspections)}
loading={inspLoading}
emptyMessage="등록된 검사기준이 없어요"
showCheckbox={true}
checkedIds={inspChecked}
onCheckedChange={setInspChecked}
onRowDoubleClick={(row) => openInspEdit(row)}
showPagination={true}
draggableColumns={false}
columnOrderKey="c16-inspection-main"
/>
</div>
</TabsContent>
{/* ──── 불량관리 탭 ──── */}
<TabsContent value="defect" className="mt-0 p-3">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="relative">
<Search className="text-muted-foreground absolute top-2 left-2.5 h-3.5 w-3.5" />
<Input
className="h-8 w-56 pl-8 text-sm"
placeholder="불량명 검색..."
value={defKeyword}
onChange={(e) => setDefKeyword(e.target.value)}
/>
</div>
<Badge variant="secondary" className="bg-primary/10 text-primary">
{filteredDefects.length}
</Badge>
</div>
<div className="flex items-center gap-2">
<Button size="sm" onClick={openDefCreate}>
<Plus className="mr-1 h-4 w-4" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
const sel = defects.find((r) => defChecked.includes(r.id));
if (sel) openDefEdit(sel);
else toast.error("수정할 항목을 선택해주세요");
}}
>
<Pencil className="mr-1 h-4 w-4" />
</Button>
<Button size="sm" variant="destructive" onClick={deleteDefects}>
<Trash2 className="mr-1 h-4 w-4" />
</Button>
</div>
</div>
<div className="overflow-hidden rounded-lg border">
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-10">
<Checkbox
checked={filteredDefects.length > 0 && defChecked.length === filteredDefects.length}
onCheckedChange={(v) => setDefChecked(v ? filteredDefects.map((r) => r.id) : [])}
/>
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{defLoading ? (
<TableRow>
<TableCell colSpan={12} className="py-8 text-center">
<Loader2 className="text-muted-foreground mx-auto h-5 w-5 animate-spin" />
</TableCell>
</TableRow>
) : filteredDefects.length === 0 ? (
<TableRow>
<TableCell colSpan={12} className="text-muted-foreground py-10 text-center">
<Inbox className="mx-auto mb-2 h-8 w-8 opacity-40" />
<p className="text-sm"> </p>
</TableCell>
</TableRow>
) : (
filteredDefects.map((row) => {
const severityLabel = getCatLabel(DEFECT_TABLE, "severity", row.severity);
const severityColor =
severityLabel === "치명적"
? "destructive"
: severityLabel === "심각"
? "destructive"
: severityLabel === "보통"
? "secondary"
: "outline";
return (
<TableRow
key={row.id}
className={cn("cursor-pointer", defChecked.includes(row.id) && "bg-primary/5")}
onClick={() =>
setDefChecked((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id],
)
}
onDoubleClick={() => openDefEdit(row)}
>
<TableCell onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={defChecked.includes(row.id)}
onCheckedChange={(v) =>
setDefChecked((prev) => (v ? [...prev, row.id] : prev.filter((id) => id !== row.id)))
}
/>
</TableCell>
<TableCell className="font-semibold">{row.defect_code || "-"}</TableCell>
<TableCell>
<Badge variant="secondary" className="text-[10px]">
{getCatLabel(DEFECT_TABLE, "defect_type", row.defect_type)}
</Badge>
</TableCell>
<TableCell>{row.defect_name || "-"}</TableCell>
<TableCell className="text-muted-foreground max-w-[200px] truncate">
{row.defect_content || "-"}
</TableCell>
<TableCell>
<Badge variant={severityColor as any} className="text-[10px]">
{severityLabel || "-"}
</Badge>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{row.inspection_type
? row.inspection_type
.split(",")
.filter(Boolean)
.map((c: string) => (
<Badge key={c} variant="outline" className="text-[10px]">
{getCatLabel(DEFECT_TABLE, "inspection_type", c)}
</Badge>
))
: "-"}
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{row.apply_target
? row.apply_target
.split(",")
.filter(Boolean)
.map((t: string) => (
<Badge key={t} variant="outline" className="text-[10px]">
{t}
</Badge>
))
: "-"}
</div>
</TableCell>
<TableCell>
<Badge
variant={row.is_active === "사용" || row.is_active === "true" ? "default" : "secondary"}
className="text-[10px]"
>
{row.is_active === "사용" || row.is_active === "true" ? "사용" : row.is_active || "미사용"}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground">
{row.reg_date || (row.created_date ? row.created_date.slice(0, 10) : "-")}
</TableCell>
<TableCell>{userOptions.find((u) => u.code === row.manager_id)?.label || row.manager_id || "-"}</TableCell>
<TableCell className="text-muted-foreground">{row.remarks || "-"}</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
</TabsContent>
{/* ──── 검사장비 탭 ──── */}
<TabsContent value="equipment" className="mt-0 p-3">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="relative">
<Search className="text-muted-foreground absolute top-2 left-2.5 h-3.5 w-3.5" />
<Input
className="h-8 w-56 pl-8 text-sm"
placeholder="장비명 검색..."
value={eqKeyword}
onChange={(e) => setEqKeyword(e.target.value)}
/>
</div>
<Badge variant="secondary" className="bg-primary/10 text-primary">
{filteredEquipments.length}
</Badge>
</div>
<div className="flex items-center gap-2">
<Button size="sm" onClick={openEqCreate}>
<Plus className="mr-1 h-4 w-4" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
const sel = equipments.find((r) => eqChecked.includes(r.id));
if (sel) openEqEdit(sel);
else toast.error("수정할 항목을 선택해주세요");
}}
>
<Pencil className="mr-1 h-4 w-4" />
</Button>
<Button size="sm" variant="destructive" onClick={deleteEquipments}>
<Trash2 className="mr-1 h-4 w-4" />
</Button>
</div>
</div>
<div className="overflow-hidden rounded-lg border">
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-10">
<Checkbox
checked={filteredEquipments.length > 0 && eqChecked.length === filteredEquipments.length}
onCheckedChange={(v) => setEqChecked(v ? filteredEquipments.map((r) => r.id) : [])}
/>
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
()
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{eqLoading ? (
<TableRow>
<TableCell colSpan={11} className="py-8 text-center">
<Loader2 className="text-muted-foreground mx-auto h-5 w-5 animate-spin" />
</TableCell>
</TableRow>
) : filteredEquipments.length === 0 ? (
<TableRow>
<TableCell colSpan={11} className="text-muted-foreground py-10 text-center">
<Inbox className="mx-auto mb-2 h-8 w-8 opacity-40" />
<p className="text-sm"> </p>
</TableCell>
</TableRow>
) : (
filteredEquipments.map((row) => {
const statusLabel = getCatLabel(EQUIPMENT_TABLE, "equipment_status", row.equipment_status);
const statusColor =
statusLabel === "정상" ? "default" : statusLabel === "폐기" ? "destructive" : "secondary";
return (
<TableRow
key={row.id}
className={cn("cursor-pointer", eqChecked.includes(row.id) && "bg-primary/5")}
onClick={() =>
setEqChecked((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id],
)
}
onDoubleClick={() => openEqEdit(row)}
>
<TableCell onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={eqChecked.includes(row.id)}
onCheckedChange={(v) =>
setEqChecked((prev) => (v ? [...prev, row.id] : prev.filter((id) => id !== row.id)))
}
/>
</TableCell>
<TableCell className="text-primary font-semibold">{row.equipment_code || "-"}</TableCell>
<TableCell>{row.equipment_name || "-"}</TableCell>
<TableCell>
<Badge variant="secondary" className="text-[10px]">
{getCatLabel(EQUIPMENT_TABLE, "equipment_type", row.equipment_type) || "-"}
</Badge>
</TableCell>
<TableCell>{row.model_name || "-"}</TableCell>
<TableCell>{row.manufacturer || "-"}</TableCell>
<TableCell>{row.installation_location || "-"}</TableCell>
<TableCell>{row.last_calibration_date || "-"}</TableCell>
<TableCell>{row.calibration_period ? `${row.calibration_period}개월` : "-"}</TableCell>
<TableCell>
<Badge variant={statusColor as any} className="text-[10px]">
{statusLabel || "-"}
</Badge>
</TableCell>
<TableCell>
{userOptions.find((u) => u.code === row.manager_id)?.label || row.manager_id || "-"}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
</TabsContent>
</Tabs>
</div>
{/* ═══════════════════ 검사기준 모달 ═══════════════════ */}
<Dialog open={inspModalOpen} onOpenChange={setInspModalOpen}>
<DialogContent className="max-h-[85vh] max-w-[95vw] overflow-y-auto sm:max-w-[640px]">
<DialogHeader>
<DialogTitle>{inspEditMode ? "검사기준 수정" : "검사기준 등록"}</DialogTitle>
<DialogDescription> </DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-4">
{/* 검사코드 */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold">
<span className="text-destructive">*</span>
</Label>
{!inspEditMode && numberingRuleId ? (
<Input
className="h-9 bg-muted"
value={previewCode || ""}
readOnly
placeholder="채번 조회 중..."
/>
) : inspEditMode ? (
<Input
className="h-9 bg-muted"
value={inspForm.inspection_code || ""}
disabled
/>
) : (
<Input
className="h-9"
value={inspForm.inspection_code || ""}
onChange={(e) => setInspForm((p) => ({ ...p, inspection_code: e.target.value }))}
placeholder="검사코드 입력"
/>
)}
</div>
{/* 유형 (다중선택) */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold">
<span className="text-destructive">*</span> ()
</Label>
<div className="flex flex-wrap gap-3 pt-1">
{(catOptions[`${INSPECTION_TABLE}.inspection_type`] || []).map((o) => {
const types: string[] = inspForm.inspection_type
? inspForm.inspection_type.split(",").filter(Boolean)
: [];
const checked = types.includes(o.code);
return (
<div key={o.code} className="flex items-center gap-1.5">
<Checkbox
checked={checked}
onCheckedChange={(v) => {
const next = v ? [...types, o.code] : types.filter((t) => t !== o.code);
setInspForm((p) => ({ ...p, inspection_type: next.join(",") }));
}}
/>
<Label className="cursor-pointer text-sm">{o.label}</Label>
</div>
);
})}
</div>
</div>
{/* 검사기준 */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold">
<span className="text-destructive">*</span>
</Label>
<Input
className="h-9"
value={inspForm.inspection_criteria || ""}
onChange={(e) => setInspForm((p) => ({ ...p, inspection_criteria: e.target.value }))}
placeholder="검사기준 입력"
/>
</div>
{/* 기준상세 */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold"></Label>
<Input
className="h-9"
value={inspForm.criteria_detail || ""}
onChange={(e) => setInspForm((p) => ({ ...p, criteria_detail: e.target.value }))}
placeholder="기준상세 입력"
/>
</div>
{/* 검사항목 */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold">
<span className="text-destructive">*</span>
</Label>
<Input
className="h-9"
value={inspForm.inspection_item || ""}
onChange={(e) => setInspForm((p) => ({ ...p, inspection_item: e.target.value }))}
placeholder="검사항목 입력"
/>
</div>
{/* 검사방법 */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold"></Label>
<Select
value={inspForm.inspection_method || "__none__"}
onValueChange={(v) => setInspForm((p) => ({ ...p, inspection_method: v === "__none__" ? "" : v }))}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{(catOptions[`${INSPECTION_TABLE}.inspection_method`] || []).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">
<span className="text-destructive">*</span>
</Label>
<Select
value={inspForm.judgment_criteria || "__none__"}
onValueChange={(v) => setInspForm((p) => ({ ...p, judgment_criteria: v === "__none__" ? "" : v }))}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{(catOptions[`${INSPECTION_TABLE}.judgment_criteria`] || []).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"></Label>
<Select
value={inspForm.unit || "__none__"}
onValueChange={(v) => setInspForm((p) => ({ ...p, unit: v === "__none__" ? "" : v }))}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{(catOptions[`${INSPECTION_TABLE}.unit`] || []).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"></Label>
<Select
value={inspForm.apply_type || "__none__"}
onValueChange={(v) => setInspForm((p) => ({ ...p, apply_type: v === "__none__" ? "" : v }))}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{(catOptions[`${INSPECTION_TABLE}.apply_type`] || []).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"></Label>
<Select
value={inspForm.manager || "__none__"}
onValueChange={(v) => setInspForm((p) => ({ ...p, manager: v === "__none__" ? "" : v }))}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{userOptions.map((u) => (
<SelectItem key={u.code} value={u.code}>
{u.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 비고 */}
<div className="col-span-2 space-y-1.5">
<Label className="text-xs font-semibold"></Label>
<textarea
className="border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 flex min-h-[80px] w-full resize-y rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs focus-visible:ring-[3px] focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
value={inspForm.remark || ""}
onChange={(e) => setInspForm((p) => ({ ...p, remark: e.target.value }))}
placeholder="비고 입력"
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setInspModalOpen(false)}>
</Button>
<Button onClick={saveInspection} disabled={inspSaving}>
{inspSaving ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Save className="mr-1 h-4 w-4" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ═══════════════════ 불량관리 모달 ═══════════════════ */}
<Dialog open={defModalOpen} onOpenChange={setDefModalOpen}>
<DialogContent className="max-h-[85vh] max-w-[95vw] overflow-y-auto sm:max-w-[640px]">
<DialogHeader>
<DialogTitle>{defEditMode ? "불량유형 수정" : "불량유형 등록"}</DialogTitle>
<DialogDescription> </DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-4">
{/* 불량코드 */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold">
<span className="text-destructive">*</span>
</Label>
{!defEditMode && numberingRuleId ? (
<Input
className="h-9 bg-muted"
value={previewCode || ""}
readOnly
placeholder="채번 조회 중..."
/>
) : defEditMode ? (
<Input
className="h-9 bg-muted"
value={defForm.defect_code || ""}
disabled
/>
) : (
<Input
className="h-9"
value={defForm.defect_code || ""}
onChange={(e) => setDefForm((p) => ({ ...p, defect_code: e.target.value }))}
placeholder="불량코드 입력"
/>
)}
</div>
{/* 불량유형 */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold">
<span className="text-destructive">*</span>
</Label>
<Select
value={defForm.defect_type || "__none__"}
onValueChange={(v) => setDefForm((p) => ({ ...p, defect_type: v === "__none__" ? "" : v }))}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{(catOptions[`${DEFECT_TABLE}.defect_type`] || []).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">
<span className="text-destructive">*</span>
</Label>
<Input
className="h-9"
value={defForm.defect_name || ""}
onChange={(e) => setDefForm((p) => ({ ...p, defect_name: e.target.value }))}
placeholder="불량명 입력"
/>
</div>
{/* 심각도 */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold">
<span className="text-destructive">*</span>
</Label>
<Select
value={defForm.severity || "__none__"}
onValueChange={(v) => setDefForm((p) => ({ ...p, severity: v === "__none__" ? "" : v }))}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{(catOptions[`${DEFECT_TABLE}.severity`] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 불량내용 */}
<div className="col-span-2 space-y-1.5">
<Label className="text-xs font-semibold">
<span className="text-destructive">*</span>
</Label>
<textarea
className="border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 flex min-h-[80px] w-full resize-y rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs focus-visible:ring-[3px] focus-visible:outline-none"
value={defForm.defect_content || ""}
onChange={(e) => setDefForm((p) => ({ ...p, defect_content: e.target.value }))}
placeholder="불량 상세 내용 및 정의를 입력하세요"
rows={3}
/>
</div>
{/* 검사유형 (다중선택) */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold">
<span className="text-destructive">*</span> ()
</Label>
<div className="flex flex-wrap gap-3 rounded-md border p-3">
{(catOptions[`${DEFECT_TABLE}.inspection_type`] || []).map((o) => {
const types: string[] = defForm.inspection_type
? defForm.inspection_type.split(",").filter(Boolean)
: [];
const checked = types.includes(o.code);
return (
<div key={o.code} className="flex items-center gap-1.5">
<Checkbox
checked={checked}
onCheckedChange={(v) => {
const next = v ? [...types, o.code] : types.filter((t) => t !== o.code);
setDefForm((p) => ({ ...p, inspection_type: next.join(","), apply_target: "" }));
}}
/>
<Label className="cursor-pointer text-sm">{o.label}</Label>
</div>
);
})}
</div>
</div>
{/* 적용대상 (다중선택, 검사유형별 동적) */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold"> ()</Label>
<div className="min-h-[60px] rounded-md border p-3">
{(() => {
const selectedTypes = defForm.inspection_type
? defForm.inspection_type.split(",").filter(Boolean)
: [];
if (selectedTypes.length === 0)
return <p className="text-muted-foreground text-xs"> </p>;
const typeTargetMap: Record<string, string[]> = {};
const defInspOpts = catOptions[`${DEFECT_TABLE}.inspection_type`] || [];
for (const code of selectedTypes) {
const label = defInspOpts.find((o) => o.code === code)?.label || "";
if (label.includes("수입"))
typeTargetMap[label] = ["구매입고", "외주입고", "반품입고", "무상입고", "기타입고"];
else if (label.includes("공정"))
typeTargetMap[label] = ["가공", "조립", "도장", "열처리", "표면처리", "용접"];
else if (label.includes("출하"))
typeTargetMap[label] = ["국내출하", "수출출하", "반품출하", "샘플출하"];
else if (label.includes("최종")) typeTargetMap[label] = ["완제품", "반제품", "부품"];
}
const targets: string[] = defForm.apply_target ? defForm.apply_target.split(",").filter(Boolean) : [];
return Object.entries(typeTargetMap).map(([typeName, opts]) => (
<div key={typeName} className="mb-2 last:mb-0">
<p className="mb-2 border-b pb-1 text-xs font-semibold">{typeName}</p>
<div className="flex flex-wrap gap-3">
{opts.map((t) => (
<div key={t} className="flex items-center gap-1.5">
<Checkbox
checked={targets.includes(t)}
onCheckedChange={(v) => {
const next = v ? [...targets, t] : targets.filter((x) => x !== t);
setDefForm((p) => ({ ...p, apply_target: next.join(",") }));
}}
/>
<Label className="cursor-pointer text-sm">{t}</Label>
</div>
))}
</div>
</div>
));
})()}
</div>
</div>
{/* 사용여부 */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold"></Label>
<Select
value={defForm.is_active || "사용"}
onValueChange={(v) => setDefForm((p) => ({ ...p, is_active: v }))}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="사용"></SelectItem>
<SelectItem value="미사용"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 관리자 */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold"></Label>
<Select
value={defForm.manager_id || "__none__"}
onValueChange={(v) => setDefForm((p) => ({ ...p, manager_id: v === "__none__" ? "" : v }))}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{userOptions.map((u) => (
<SelectItem key={u.code} value={u.code}>
{u.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 비고 */}
<div className="col-span-2 space-y-1.5">
<Label className="text-xs font-semibold"></Label>
<textarea
className="border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 flex min-h-[80px] w-full resize-y rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs focus-visible:ring-[3px] focus-visible:outline-none"
value={defForm.remarks || ""}
onChange={(e) => setDefForm((p) => ({ ...p, remarks: e.target.value }))}
placeholder="비고 입력"
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDefModalOpen(false)}>
</Button>
<Button onClick={saveDefect} disabled={defSaving}>
{defSaving ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Save className="mr-1 h-4 w-4" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ═══════════════════ 검사장비 모달 ═══════════════════ */}
<Dialog open={eqModalOpen} onOpenChange={setEqModalOpen}>
<DialogContent className="max-h-[85vh] max-w-[95vw] overflow-y-auto sm:max-w-[720px]">
<DialogHeader>
<DialogTitle>{eqEditMode ? "장비 수정" : "장비 등록"}</DialogTitle>
<DialogDescription> </DialogDescription>
</DialogHeader>
<div className="grid grid-cols-3 gap-4">
{/* Row 1: 장비코드, 장비명, 장비유형 */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold">
<span className="text-destructive">*</span>
</Label>
{!eqEditMode && numberingRuleId ? (
<Input
className="h-9 bg-muted"
value={previewCode || ""}
readOnly
placeholder="채번 조회 중..."
/>
) : eqEditMode ? (
<Input
className="h-9 bg-muted"
value={eqForm.equipment_code || ""}
disabled
/>
) : (
<Input
className="h-9"
value={eqForm.equipment_code || ""}
onChange={(e) => setEqForm((p) => ({ ...p, equipment_code: e.target.value }))}
placeholder="장비코드"
/>
)}
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold">
<span className="text-destructive">*</span>
</Label>
<Input
className="h-9"
value={eqForm.equipment_name || ""}
onChange={(e) => setEqForm((p) => ({ ...p, equipment_name: e.target.value }))}
placeholder="장비명 입력"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold">
<span className="text-destructive">*</span>
</Label>
<Select
value={eqForm.equipment_type || "__none__"}
onValueChange={(v) => setEqForm((p) => ({ ...p, equipment_type: v === "__none__" ? "" : v }))}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="장비유형 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{(catOptions[`${EQUIPMENT_TABLE}.equipment_type`] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Row 2: 모델명, 제조사, 시리얼번호 */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold"></Label>
<Input
className="h-9"
value={eqForm.model_name || ""}
onChange={(e) => setEqForm((p) => ({ ...p, model_name: e.target.value }))}
placeholder="모델명 입력"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold"></Label>
<Input
className="h-9"
value={eqForm.manufacturer || ""}
onChange={(e) => setEqForm((p) => ({ ...p, manufacturer: e.target.value }))}
placeholder="제조사 입력"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold"></Label>
<Input
className="h-9"
value={eqForm.serial_number || ""}
onChange={(e) => setEqForm((p) => ({ ...p, serial_number: e.target.value }))}
placeholder="시리얼번호 입력"
/>
</div>
{/* Row 3: 설치장소, 최근교정일, 교정주기 */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold"></Label>
<Input
className="h-9"
value={eqForm.installation_location || ""}
onChange={(e) => setEqForm((p) => ({ ...p, installation_location: e.target.value }))}
placeholder="설치장소 입력"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold"></Label>
<Input
type="date"
className="h-9"
value={eqForm.last_calibration_date || ""}
onChange={(e) => setEqForm((p) => ({ ...p, last_calibration_date: e.target.value }))}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold"> ()</Label>
<Input
type="number"
className="h-9"
value={eqForm.calibration_period || ""}
onChange={(e) => setEqForm((p) => ({ ...p, calibration_period: e.target.value }))}
placeholder="12"
/>
</div>
{/* Row 4: 장비상태, 담당자 */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold"></Label>
<Select
value={eqForm.equipment_status || "__none__"}
onValueChange={(v) => setEqForm((p) => ({ ...p, equipment_status: v === "__none__" ? "" : v }))}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="장비상태 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{(catOptions[`${EQUIPMENT_TABLE}.equipment_status`] || []).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"></Label>
<Select
value={eqForm.manager_id || "__none__"}
onValueChange={(v) => setEqForm((p) => ({ ...p, manager_id: v === "__none__" ? "" : v }))}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="담당자 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{userOptions.map((u) => (
<SelectItem key={u.code} value={u.code}>
{u.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div />
{/* Row 5: 비고 (full width) */}
<div className="col-span-3 space-y-1.5">
<Label className="text-xs font-semibold"></Label>
<textarea
className="border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 flex min-h-[80px] w-full resize-y rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs focus-visible:ring-[3px] focus-visible:outline-none"
value={eqForm.remarks || ""}
onChange={(e) => setEqForm((p) => ({ ...p, remarks: e.target.value }))}
placeholder="비고 입력"
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEqModalOpen(false)}>
</Button>
<Button onClick={saveEquipment} disabled={eqSaving}>
{eqSaving ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Save className="mr-1 h-4 w-4" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<TableSettingsModal
open={ts.open}
onOpenChange={ts.setOpen}
tableName={ts.tableName}
settingsId={ts.settingsId}
defaultVisibleKeys={ts.defaultVisibleKeys}
onSave={ts.applySettings}
/>
</div>
);
}