58faa1f759
- 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.
1699 lines
74 KiB
TypeScript
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>
|
|
);
|
|
}
|