b497db9987
- playwright, @playwright/test 제거 (cmux browser만 사용) - inspection page.tsx: 판단기준(judgment_criteria) Select UI 포함 버전으로 업데이트
1704 lines
74 KiB
TypeScript
1704 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]);
|
|
}
|
|
return base;
|
|
});
|
|
}, [ts.visibleColumns, catOptions]); // 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({});
|
|
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={
|
|
getCatLabel(DEFECT_TABLE, "is_active", row.is_active) === "사용"
|
|
? "default"
|
|
: "secondary"
|
|
}
|
|
className="text-[10px]"
|
|
>
|
|
{getCatLabel(DEFECT_TABLE, "is_active", row.is_active) || "-"}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell className="text-muted-foreground">
|
|
{row.reg_date || (row.created_date ? row.created_date.slice(0, 10) : "-")}
|
|
</TableCell>
|
|
<TableCell>{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 || "__none__"}
|
|
onValueChange={(v) => setDefForm((p) => ({ ...p, is_active: v === "__none__" ? "" : v }))}
|
|
>
|
|
<SelectTrigger className="h-9">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__none__">선택 안함</SelectItem>
|
|
{(catOptions[`${DEFECT_TABLE}.is_active`] || []).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={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>
|
|
);
|
|
}
|