946 lines
55 KiB
TypeScript
946 lines
55 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* 설비정보 — 하드코딩 페이지
|
|
*
|
|
* 좌측: 설비 목록 (equipment_mng)
|
|
* 우측: 탭 (기본정보 / 점검항목 / 소모품)
|
|
* 점검항목 복사 기능 포함
|
|
*/
|
|
|
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
|
import {
|
|
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil,
|
|
Inbox, ClipboardCheck, Package, Copy, Info, Settings2,
|
|
} from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
import { apiClient } from "@/lib/api/client";
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
import { toast } from "sonner";
|
|
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
|
import { exportToExcel } from "@/lib/utils/excelExport";
|
|
import { autoDetectMultiTableConfig, TableChainConfig } from "@/lib/api/multiTableExcel";
|
|
import { MultiTableExcelUploadModal } from "@/components/common/MultiTableExcelUploadModal";
|
|
import { ImageUpload } from "@/components/common/ImageUpload";
|
|
import { useTableSettings } from "@/hooks/useTableSettings";
|
|
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
|
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
|
|
|
const EQUIP_TABLE = "equipment_mng";
|
|
const INSPECTION_TABLE = "equipment_inspection_item";
|
|
const CONSUMABLE_TABLE = "equipment_consumable";
|
|
|
|
const GRID_COLUMNS_CONFIG = [
|
|
{ key: "equipment_code", label: "설비코드" },
|
|
{ key: "equipment_name", label: "설비명" },
|
|
{ key: "equipment_type", label: "설비유형" },
|
|
{ key: "manufacturer", label: "제조사" },
|
|
{ key: "installation_location", label: "설치장소" },
|
|
{ key: "operation_status", label: "가동상태" },
|
|
];
|
|
export default function EquipmentInfoPage() {
|
|
const { user } = useAuth();
|
|
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
|
|
|
// 좌측
|
|
const [equipments, setEquipments] = useState<any[]>([]);
|
|
const [equipLoading, setEquipLoading] = useState(false);
|
|
const [equipCount, setEquipCount] = useState(0);
|
|
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
|
const [selectedEquipId, setSelectedEquipId] = useState<string | null>(null);
|
|
|
|
// 우측 탭
|
|
const [rightTab, setRightTab] = useState<"info" | "inspection" | "consumable">("info");
|
|
const [inspections, setInspections] = useState<any[]>([]);
|
|
const [inspectionLoading, setInspectionLoading] = useState(false);
|
|
const [consumables, setConsumables] = useState<any[]>([]);
|
|
const [consumableLoading, setConsumableLoading] = useState(false);
|
|
|
|
// 카테고리
|
|
const [catOptions, setCatOptions] = useState<Record<string, { code: string; label: string }[]>>({});
|
|
|
|
// 설비 등록/수정 모달
|
|
const [equipModalOpen, setEquipModalOpen] = useState(false);
|
|
const [equipEditMode, setEquipEditMode] = useState(false);
|
|
const [equipForm, setEquipForm] = useState<Record<string, any>>({});
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
// 기본정보 탭 편집 폼
|
|
const [infoForm, setInfoForm] = useState<Record<string, any>>({});
|
|
const [infoSaving, setInfoSaving] = useState(false);
|
|
|
|
// 점검항목 추가/수정 모달
|
|
const [inspectionModalOpen, setInspectionModalOpen] = useState(false);
|
|
const [inspectionForm, setInspectionForm] = useState<Record<string, any>>({});
|
|
const [inspectionContinuous, setInspectionContinuous] = useState(false);
|
|
const [inspectionEditMode, setInspectionEditMode] = useState(false);
|
|
|
|
// 소모품 추가/수정 모달
|
|
const [consumableModalOpen, setConsumableModalOpen] = useState(false);
|
|
const [consumableForm, setConsumableForm] = useState<Record<string, any>>({});
|
|
const [consumableContinuous, setConsumableContinuous] = useState(false);
|
|
const [consumableEditMode, setConsumableEditMode] = useState(false);
|
|
const [consumableItemOptions, setConsumableItemOptions] = useState<any[]>([]);
|
|
|
|
// 점검항목 복사
|
|
const [copyModalOpen, setCopyModalOpen] = useState(false);
|
|
const [copySourceEquip, setCopySourceEquip] = useState("");
|
|
const [copyItems, setCopyItems] = useState<any[]>([]);
|
|
const [copyChecked, setCopyChecked] = useState<Set<string>>(new Set());
|
|
const [copyLoading, setCopyLoading] = useState(false);
|
|
|
|
// 엑셀
|
|
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
|
|
const [excelChainConfig, setExcelChainConfig] = useState<TableChainConfig | null>(null);
|
|
const [excelDetecting, setExcelDetecting] = useState(false);
|
|
|
|
// 테이블 설정
|
|
const ts = useTableSettings("c16-equipment-info", EQUIP_TABLE, GRID_COLUMNS_CONFIG);
|
|
|
|
// 카테고리 로드
|
|
useEffect(() => {
|
|
const load = async () => {
|
|
const optMap: Record<string, { code: string; label: string }[]> = {};
|
|
const flatten = (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(...flatten(v.children));
|
|
}
|
|
return result;
|
|
};
|
|
for (const col of ["equipment_type", "operation_status"]) {
|
|
try {
|
|
const res = await apiClient.get(`/table-categories/${EQUIP_TABLE}/${col}/values`);
|
|
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
|
|
} catch { /* skip */ }
|
|
}
|
|
for (const col of ["inspection_cycle", "inspection_method"]) {
|
|
try {
|
|
const res = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/${col}/values`);
|
|
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
|
|
} catch { /* skip */ }
|
|
}
|
|
setCatOptions(optMap);
|
|
};
|
|
load();
|
|
}, []);
|
|
|
|
const resolve = (col: string, code: string) => {
|
|
if (!code) return "";
|
|
return catOptions[col]?.find((o) => o.code === code)?.label || code;
|
|
};
|
|
|
|
const mainTableColumns = useMemo<EDataTableColumn[]>(() => {
|
|
const cols: EDataTableColumn[] = [];
|
|
if (ts.isVisible("equipment_code")) cols.push({ key: "equipment_code", label: "설비코드", width: "w-[110px]" });
|
|
if (ts.isVisible("equipment_name")) cols.push({ key: "equipment_name", label: "설비명", minWidth: "min-w-[130px]", truncate: true, render: (v) => v || "-" });
|
|
if (ts.isVisible("equipment_type")) cols.push({ key: "equipment_type", label: "설비유형", width: "w-[90px]", render: (v) => v || "-" });
|
|
if (ts.isVisible("manufacturer")) cols.push({ key: "manufacturer", label: "제조사", width: "w-[100px]", render: (v) => v || "-" });
|
|
if (ts.isVisible("installation_location")) cols.push({ key: "installation_location", label: "설치장소", width: "w-[100px]", render: (v) => v || "-" });
|
|
if (ts.isVisible("operation_status")) cols.push({ key: "operation_status", label: "가동상태", width: "w-[80px]", render: (v) => v || "-" });
|
|
return cols;
|
|
}, [ts.visibleColumns]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// 설비 조회
|
|
const fetchEquipments = useCallback(async () => {
|
|
setEquipLoading(true);
|
|
try {
|
|
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
|
|
const res = await apiClient.post(`/table-management/tables/${EQUIP_TABLE}/data`, {
|
|
page: 1, size: 500,
|
|
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
|
autoFilter: true,
|
|
});
|
|
const raw = res.data?.data?.data || res.data?.data?.rows || [];
|
|
setEquipments(raw.map((r: any) => ({
|
|
...r,
|
|
equipment_type: resolve("equipment_type", r.equipment_type),
|
|
operation_status: resolve("operation_status", r.operation_status),
|
|
})));
|
|
setEquipCount(res.data?.data?.total || raw.length);
|
|
} catch { toast.error("설비 목록 조회 실패"); } finally { setEquipLoading(false); }
|
|
}, [searchFilters, catOptions]);
|
|
|
|
useEffect(() => { fetchEquipments(); }, [fetchEquipments]);
|
|
|
|
const selectedEquip = equipments.find((e) => e.id === selectedEquipId);
|
|
|
|
// 기본정보 탭 폼 초기화 (설비 선택 변경 시)
|
|
useEffect(() => {
|
|
if (selectedEquip) setInfoForm({ ...selectedEquip });
|
|
else setInfoForm({});
|
|
}, [selectedEquipId]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// 기본정보 저장
|
|
const handleInfoSave = async () => {
|
|
if (!infoForm.id) return;
|
|
setInfoSaving(true);
|
|
try {
|
|
const { id, created_date, updated_date, writer, company_code, ...fields } = infoForm;
|
|
await apiClient.put(`/table-management/tables/${EQUIP_TABLE}/edit`, { originalData: { id }, updatedData: fields });
|
|
toast.success("저장되었습니다.");
|
|
fetchEquipments();
|
|
} catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); }
|
|
finally { setInfoSaving(false); }
|
|
};
|
|
|
|
// 우측: 점검항목 조회
|
|
useEffect(() => {
|
|
if (!selectedEquip?.equipment_code) { setInspections([]); return; }
|
|
const fetchData = async () => {
|
|
setInspectionLoading(true);
|
|
try {
|
|
const res = await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, {
|
|
page: 1, size: 500,
|
|
dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "equals", value: selectedEquip.equipment_code }] },
|
|
autoFilter: true,
|
|
});
|
|
setInspections(res.data?.data?.data || res.data?.data?.rows || []);
|
|
} catch { setInspections([]); } finally { setInspectionLoading(false); }
|
|
};
|
|
fetchData();
|
|
}, [selectedEquip?.equipment_code]);
|
|
|
|
// 우측: 소모품 조회
|
|
useEffect(() => {
|
|
if (!selectedEquip?.equipment_code) { setConsumables([]); return; }
|
|
const fetchData = async () => {
|
|
setConsumableLoading(true);
|
|
try {
|
|
const res = await apiClient.post(`/table-management/tables/${CONSUMABLE_TABLE}/data`, {
|
|
page: 1, size: 500,
|
|
dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "equals", value: selectedEquip.equipment_code }] },
|
|
autoFilter: true,
|
|
});
|
|
setConsumables(res.data?.data?.data || res.data?.data?.rows || []);
|
|
} catch { setConsumables([]); } finally { setConsumableLoading(false); }
|
|
};
|
|
fetchData();
|
|
}, [selectedEquip?.equipment_code]);
|
|
|
|
// 새로고침 헬퍼
|
|
const refreshRight = () => {
|
|
const eid = selectedEquipId;
|
|
setSelectedEquipId(null);
|
|
setTimeout(() => setSelectedEquipId(eid), 50);
|
|
};
|
|
|
|
// 설비 등록/수정
|
|
const openEquipRegister = () => { setEquipForm({}); setEquipEditMode(false); setEquipModalOpen(true); };
|
|
const openEquipEdit = () => { if (!selectedEquip) return; setEquipForm({ ...selectedEquip }); setEquipEditMode(true); setEquipModalOpen(true); };
|
|
|
|
const handleEquipSave = async () => {
|
|
if (!equipForm.equipment_name) { toast.error("설비명은 필수입니다."); return; }
|
|
setSaving(true);
|
|
try {
|
|
const { id, created_date, updated_date, writer, company_code, ...fields } = equipForm;
|
|
if (equipEditMode && id) {
|
|
await apiClient.put(`/table-management/tables/${EQUIP_TABLE}/edit`, { originalData: { id }, updatedData: fields });
|
|
toast.success("수정되었습니다.");
|
|
} else {
|
|
await apiClient.post(`/table-management/tables/${EQUIP_TABLE}/add`, { id: crypto.randomUUID(), ...fields });
|
|
toast.success("등록되었습니다.");
|
|
}
|
|
setEquipModalOpen(false); fetchEquipments();
|
|
} catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); } finally { setSaving(false); }
|
|
};
|
|
|
|
const handleEquipDelete = async () => {
|
|
if (!selectedEquipId) return;
|
|
const ok = await confirm("설비를 삭제하시겠습니까?", { description: "관련 점검항목, 소모품도 함께 삭제됩니다.", variant: "destructive", confirmText: "삭제" });
|
|
if (!ok) return;
|
|
try {
|
|
await apiClient.delete(`/table-management/tables/${EQUIP_TABLE}/delete`, { data: [{ id: selectedEquipId }] });
|
|
toast.success("삭제되었습니다."); setSelectedEquipId(null); fetchEquipments();
|
|
} catch { toast.error("삭제 실패"); }
|
|
};
|
|
|
|
// 점검항목 추가
|
|
const handleInspectionSave = async () => {
|
|
if (!inspectionForm.inspection_item) { toast.error("점검항목명은 필수입니다."); return; }
|
|
if (!inspectionForm.inspection_cycle) { toast.error("점검주기는 필수입니다."); return; }
|
|
if (!inspectionForm.inspection_method) { toast.error("점검방법은 필수입니다."); return; }
|
|
const methodLabel = resolve("inspection_method", inspectionForm.inspection_method);
|
|
const isNumeric = methodLabel === "숫자" || inspectionForm.inspection_method === "숫자";
|
|
if (isNumeric && !inspectionForm.unit) { toast.error("숫자 점검방법은 측정단위가 필수입니다."); return; }
|
|
// 기준값/오차범위 → 하한치/상한치 자동 계산
|
|
const saveData = { ...inspectionForm };
|
|
if (isNumeric && saveData.standard_value) {
|
|
const std = Number(saveData.standard_value) || 0;
|
|
const tol = Number(saveData.tolerance) || 0;
|
|
saveData.lower_limit = String(std - tol);
|
|
saveData.upper_limit = String(std + tol);
|
|
}
|
|
if (!isNumeric) {
|
|
saveData.unit = "";
|
|
saveData.standard_value = "";
|
|
saveData.tolerance = "";
|
|
saveData.lower_limit = "";
|
|
saveData.upper_limit = "";
|
|
}
|
|
setSaving(true);
|
|
try {
|
|
if (inspectionEditMode) {
|
|
await apiClient.put(`/table-management/tables/${INSPECTION_TABLE}/edit`, {
|
|
originalData: { id: saveData.id }, updatedData: { ...saveData, equipment_code: selectedEquip?.equipment_code },
|
|
});
|
|
toast.success("수정되었습니다.");
|
|
setInspectionModalOpen(false);
|
|
} else {
|
|
await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/add`, {
|
|
id: crypto.randomUUID(), ...saveData, equipment_code: selectedEquip?.equipment_code,
|
|
});
|
|
toast.success("추가되었습니다.");
|
|
if (inspectionContinuous) {
|
|
setInspectionForm({});
|
|
} else {
|
|
setInspectionModalOpen(false);
|
|
}
|
|
}
|
|
refreshRight();
|
|
} catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); } finally { setSaving(false); }
|
|
};
|
|
|
|
// 소모품 품목 로드
|
|
const loadConsumableItems = async () => {
|
|
try {
|
|
const flatten = (vals: any[]): any[] => {
|
|
const r: any[] = [];
|
|
for (const v of vals) { r.push(v); if (v.children?.length) r.push(...flatten(v.children)); }
|
|
return r;
|
|
};
|
|
const [typeRes, divRes] = await Promise.all([
|
|
apiClient.get(`/table-categories/item_info/type/values`),
|
|
apiClient.get(`/table-categories/item_info/division/values`),
|
|
]);
|
|
const consumableType = flatten(typeRes.data?.data || []).find((t: any) => t.valueLabel === "소모품");
|
|
const consumableDiv = flatten(divRes.data?.data || []).find((t: any) => t.valueLabel === "소모품");
|
|
if (!consumableType && !consumableDiv) { setConsumableItemOptions([]); return; }
|
|
const filters: any[] = [];
|
|
if (consumableType) filters.push({ columnName: "type", operator: "equals", value: consumableType.valueCode });
|
|
if (consumableDiv) filters.push({ columnName: "division", operator: "equals", value: consumableDiv.valueCode });
|
|
const results = await Promise.all(filters.map((f) =>
|
|
apiClient.post(`/table-management/tables/item_info/data`, {
|
|
page: 1, size: 500,
|
|
dataFilter: { enabled: true, filters: [f] },
|
|
autoFilter: true,
|
|
})
|
|
));
|
|
const allItems = new Map<string, any>();
|
|
for (const res of results) {
|
|
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
|
for (const row of rows) allItems.set(row.id, row);
|
|
}
|
|
setConsumableItemOptions(Array.from(allItems.values()));
|
|
} catch { setConsumableItemOptions([]); }
|
|
};
|
|
|
|
const handleConsumableSave = async () => {
|
|
if (!consumableForm.consumable_name) { toast.error("소모품명은 필수입니다."); return; }
|
|
setSaving(true);
|
|
try {
|
|
if (consumableEditMode) {
|
|
await apiClient.put(`/table-management/tables/${CONSUMABLE_TABLE}/edit`, {
|
|
originalData: { id: consumableForm.id }, updatedData: { ...consumableForm, equipment_code: selectedEquip?.equipment_code },
|
|
});
|
|
toast.success("수정되었습니다.");
|
|
setConsumableModalOpen(false);
|
|
} else {
|
|
await apiClient.post(`/table-management/tables/${CONSUMABLE_TABLE}/add`, {
|
|
id: crypto.randomUUID(), ...consumableForm, equipment_code: selectedEquip?.equipment_code,
|
|
});
|
|
toast.success("추가되었습니다.");
|
|
if (consumableContinuous) {
|
|
setConsumableForm({});
|
|
} else {
|
|
setConsumableModalOpen(false);
|
|
}
|
|
}
|
|
refreshRight();
|
|
} catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); } finally { setSaving(false); }
|
|
};
|
|
|
|
// 점검항목 복사
|
|
const loadCopyItems = async (equipCode: string) => {
|
|
setCopySourceEquip(equipCode);
|
|
setCopyChecked(new Set());
|
|
if (!equipCode) { setCopyItems([]); return; }
|
|
setCopyLoading(true);
|
|
try {
|
|
const res = await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, {
|
|
page: 1, size: 500,
|
|
dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "equals", value: equipCode }] },
|
|
autoFilter: true,
|
|
});
|
|
setCopyItems(res.data?.data?.data || res.data?.data?.rows || []);
|
|
} catch { setCopyItems([]); } finally { setCopyLoading(false); }
|
|
};
|
|
|
|
const handleCopyApply = async () => {
|
|
const selected = copyItems.filter((i) => copyChecked.has(i.id));
|
|
if (selected.length === 0) { toast.error("복사할 항목을 선택해주세요."); return; }
|
|
setSaving(true);
|
|
try {
|
|
for (const item of selected) {
|
|
const { id, created_date, updated_date, writer, company_code, equipment_code, ...fields } = item;
|
|
await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/add`, {
|
|
...fields, equipment_code: selectedEquip?.equipment_code,
|
|
});
|
|
}
|
|
toast.success(`${selected.length}개 점검항목이 복사되었습니다.`);
|
|
setCopyModalOpen(false); refreshRight();
|
|
} catch { toast.error("복사 실패"); } finally { setSaving(false); }
|
|
};
|
|
|
|
// 엑셀
|
|
const handleExcelDownload = async () => {
|
|
if (equipments.length === 0) return;
|
|
await exportToExcel(equipments.map((e) => ({
|
|
설비코드: e.equipment_code, 설비명: e.equipment_name, 설비유형: e.equipment_type,
|
|
제조사: e.manufacturer, 모델명: e.model_name, 설치장소: e.installation_location,
|
|
도입일자: e.introduction_date, 가동상태: e.operation_status,
|
|
})), "설비정보.xlsx", "설비");
|
|
toast.success("다운로드 완료");
|
|
};
|
|
|
|
// 셀렉트 렌더링 헬퍼
|
|
const catSelect = (key: string, value: string, onChange: (v: string) => void, placeholder: string) => (
|
|
<Select value={value || ""} onValueChange={onChange}>
|
|
<SelectTrigger className="h-9"><SelectValue placeholder={placeholder} /></SelectTrigger>
|
|
<SelectContent>
|
|
{(catOptions[key] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
|
</SelectContent>
|
|
</Select>
|
|
);
|
|
|
|
return (
|
|
<div className="flex h-full flex-col gap-3 p-3">
|
|
{/* 브레드크럼 */}
|
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground shrink-0">
|
|
<span>설비관리</span>
|
|
<span className="text-muted-foreground/50">/</span>
|
|
<span className="text-foreground font-medium">설비정보</span>
|
|
</div>
|
|
|
|
{/* 검색 바 */}
|
|
<DynamicSearchFilter
|
|
tableName={EQUIP_TABLE}
|
|
filterId="c16-equipment-info"
|
|
onFilterChange={setSearchFilters}
|
|
dataCount={equipCount}
|
|
externalFilterConfig={ts.filterConfig}
|
|
extraActions={
|
|
<div className="flex gap-1.5">
|
|
<Button variant="outline" size="sm" className="h-9" disabled={excelDetecting}
|
|
onClick={async () => {
|
|
setExcelDetecting(true);
|
|
try {
|
|
const r = await autoDetectMultiTableConfig(EQUIP_TABLE);
|
|
if (r.success && r.data) { setExcelChainConfig(r.data); setExcelUploadOpen(true); }
|
|
else toast.error("테이블 구조 분석 실패");
|
|
} catch { toast.error("오류"); } finally { setExcelDetecting(false); }
|
|
}}>
|
|
{excelDetecting ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <FileSpreadsheet className="w-3.5 h-3.5 mr-1" />} 엑셀 업로드
|
|
</Button>
|
|
<Button variant="outline" size="sm" className="h-9" onClick={handleExcelDownload}>
|
|
<Download className="w-3.5 h-3.5 mr-1" /> 엑셀 다운로드
|
|
</Button>
|
|
</div>
|
|
}
|
|
/>
|
|
|
|
<div className="flex-1 overflow-hidden border rounded-lg bg-card">
|
|
<ResizablePanelGroup direction="horizontal">
|
|
{/* 좌측: 설비 목록 */}
|
|
<ResizablePanel defaultSize={40} minSize={25}>
|
|
<div className="flex flex-col h-full">
|
|
<div className="flex items-center justify-between p-3 border-b bg-muted/50 shrink-0">
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="text-[13px] font-bold">설비 목록</h3>
|
|
<span className="text-[11px] font-semibold text-primary bg-primary/10 border border-primary/15 px-2 py-0.5 rounded-full font-mono">{equipCount}건</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
<Button size="sm" onClick={openEquipRegister}><Plus className="w-3.5 h-3.5 mr-1" /> 등록</Button>
|
|
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={openEquipEdit}><Pencil className="w-3.5 h-3.5 mr-1" /> 수정</Button>
|
|
<div className="h-4 w-px bg-border" />
|
|
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={handleEquipDelete} className="text-destructive border-destructive/20 bg-destructive/5 hover:bg-destructive/10"><Trash2 className="w-3.5 h-3.5 mr-1" /> 삭제</Button>
|
|
<div className="h-4 w-px bg-border" />
|
|
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)}>
|
|
<Settings2 className="w-3.5 h-3.5" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<EDataTable
|
|
columns={mainTableColumns}
|
|
data={ts.groupData(equipments)}
|
|
loading={equipLoading}
|
|
emptyMessage="등록된 설비가 없어요"
|
|
selectedId={selectedEquipId}
|
|
onSelect={(id) => setSelectedEquipId(id)}
|
|
onRowDoubleClick={() => openEquipEdit()}
|
|
showPagination={true}
|
|
draggableColumns={false}
|
|
columnOrderKey="c16-equipment-info-main"
|
|
/>
|
|
</div>
|
|
</ResizablePanel>
|
|
|
|
<ResizableHandle withHandle />
|
|
|
|
{/* 우측: 탭 */}
|
|
<ResizablePanel defaultSize={60} minSize={30}>
|
|
<div className="flex flex-col h-full">
|
|
<div className="flex items-center justify-between p-2 border-b bg-muted/50 shrink-0">
|
|
<div className="flex items-center gap-1">
|
|
{([["info", "기본정보", Info], ["inspection", "점검항목", ClipboardCheck], ["consumable", "소모품", Package]] as const).map(([tab, label, Icon]) => (
|
|
<button key={tab} onClick={() => setRightTab(tab)}
|
|
className={cn("px-3 py-1.5 text-sm rounded-md transition-colors flex items-center gap-1",
|
|
rightTab === tab ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted text-muted-foreground")}>
|
|
<Icon className="w-3.5 h-3.5" />{label}
|
|
{tab === "inspection" && inspections.length > 0 && <Badge variant="secondary" className="ml-1 text-[10px] px-1">{inspections.length}</Badge>}
|
|
{tab === "consumable" && consumables.length > 0 && <Badge variant="secondary" className="ml-1 text-[10px] px-1">{consumables.length}</Badge>}
|
|
</button>
|
|
))}
|
|
{selectedEquip && <Badge variant="outline" className="font-normal ml-2 text-xs">{selectedEquip.equipment_name}</Badge>}
|
|
</div>
|
|
<div className="flex gap-1.5">
|
|
{rightTab === "inspection" && (
|
|
<>
|
|
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setInspectionForm({}); setInspectionEditMode(false); setInspectionModalOpen(true); }}>
|
|
<Plus className="w-3.5 h-3.5 mr-1" /> 추가
|
|
</Button>
|
|
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setCopySourceEquip(""); setCopyItems([]); setCopyChecked(new Set()); setCopyModalOpen(true); }}>
|
|
<Copy className="w-3.5 h-3.5 mr-1" /> 복사
|
|
</Button>
|
|
</>
|
|
)}
|
|
{rightTab === "consumable" && (
|
|
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setConsumableForm({}); setConsumableEditMode(false); loadConsumableItems(); setConsumableModalOpen(true); }}>
|
|
<Plus className="w-3.5 h-3.5 mr-1" /> 추가
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{!selectedEquipId ? (
|
|
<div className="flex-1 flex flex-col items-center justify-center gap-3 m-3 border-2 border-dashed rounded-lg text-center">
|
|
<Inbox className="w-12 h-12 text-muted-foreground/40" />
|
|
<div>
|
|
<p className="text-sm font-semibold text-muted-foreground">설비를 선택해주세요</p>
|
|
<p className="text-xs text-muted-foreground mt-1">좌측에서 설비를 선택하면 상세 정보가 표시돼요</p>
|
|
</div>
|
|
</div>
|
|
) : rightTab === "info" ? (
|
|
<div className="p-4 overflow-auto">
|
|
<div className="flex justify-end mb-3">
|
|
<Button size="sm" onClick={handleInfoSave} disabled={infoSaving}>
|
|
{infoSaving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} 저장
|
|
</Button>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-1.5">
|
|
<Label className="text-sm text-muted-foreground">설비코드</Label>
|
|
<Input value={infoForm.equipment_code || ""} className="h-9 bg-muted/50" disabled />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-sm">설비명</Label>
|
|
<Input value={infoForm.equipment_name || ""} onChange={(e) => setInfoForm((p) => ({ ...p, equipment_name: e.target.value }))} className="h-9" />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-sm">설비유형</Label>
|
|
{catSelect("equipment_type", infoForm.equipment_type, (v) => setInfoForm((p) => ({ ...p, equipment_type: v })), "설비유형")}
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-sm">설치장소</Label>
|
|
<Input value={infoForm.installation_location || ""} onChange={(e) => setInfoForm((p) => ({ ...p, installation_location: e.target.value }))} className="h-9" />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-sm">제조사</Label>
|
|
<Input value={infoForm.manufacturer || ""} onChange={(e) => setInfoForm((p) => ({ ...p, manufacturer: e.target.value }))} className="h-9" />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-sm">모델명</Label>
|
|
<Input value={infoForm.model_name || ""} onChange={(e) => setInfoForm((p) => ({ ...p, model_name: e.target.value }))} className="h-9" />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-sm">도입일자</Label>
|
|
<Input type="date" value={infoForm.introduction_date || ""} onChange={(e) => setInfoForm((p) => ({ ...p, introduction_date: e.target.value }))} className="h-9" />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-sm">가동상태</Label>
|
|
{catSelect("operation_status", infoForm.operation_status, (v) => setInfoForm((p) => ({ ...p, operation_status: v })), "가동상태")}
|
|
</div>
|
|
<div className="space-y-1.5 col-span-2">
|
|
<Label className="text-sm">비고</Label>
|
|
<Input value={infoForm.remarks || ""} onChange={(e) => setInfoForm((p) => ({ ...p, remarks: e.target.value }))} className="h-9" />
|
|
</div>
|
|
<div className="space-y-1.5 col-span-2">
|
|
<Label className="text-sm">이미지</Label>
|
|
<ImageUpload value={infoForm.image_path} onChange={(v) => setInfoForm((p) => ({ ...p, image_path: v }))}
|
|
tableName={EQUIP_TABLE} recordId={infoForm.id} columnName="image_path" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : rightTab === "inspection" ? (
|
|
<div className="flex-1 overflow-auto">
|
|
{inspectionLoading ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
|
</div>
|
|
) : inspections.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
|
<ClipboardCheck className="w-8 h-8 mb-2 opacity-40" />
|
|
<p className="text-sm">점검항목이 없어요</p>
|
|
</div>
|
|
) : (
|
|
<Table noWrapper>
|
|
<thead className="sticky top-0 z-10 bg-card">
|
|
<TableRow>
|
|
<TableHead className="min-w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">점검항목</TableHead>
|
|
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">점검주기</TableHead>
|
|
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">점검방법</TableHead>
|
|
<TableHead className="w-[70px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">하한치</TableHead>
|
|
<TableHead className="w-[70px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">상한치</TableHead>
|
|
<TableHead className="w-[60px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단위</TableHead>
|
|
<TableHead className="min-w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">점검내용</TableHead>
|
|
</TableRow>
|
|
</thead>
|
|
<TableBody>
|
|
{inspections.map((item) => (
|
|
<TableRow key={item.id} className="cursor-pointer hover:bg-primary/5" onDoubleClick={() => {
|
|
const std = item.standard_value || "";
|
|
const tol = item.tolerance || "";
|
|
setInspectionForm({ ...item, standard_value: std, tolerance: tol });
|
|
setInspectionEditMode(true);
|
|
setInspectionModalOpen(true);
|
|
}}>
|
|
<TableCell className="text-sm">{item.inspection_item || "-"}</TableCell>
|
|
<TableCell className="text-[13px]">{resolve("inspection_cycle", item.inspection_cycle)}</TableCell>
|
|
<TableCell className="text-[13px]">{resolve("inspection_method", item.inspection_method)}</TableCell>
|
|
<TableCell className="text-[13px]">{item.lower_limit || "-"}</TableCell>
|
|
<TableCell className="text-[13px]">{item.upper_limit || "-"}</TableCell>
|
|
<TableCell className="text-[13px]">{item.unit || "-"}</TableCell>
|
|
<TableCell className="text-[13px]">{item.inspection_content || "-"}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="flex-1 overflow-auto">
|
|
{consumableLoading ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
|
</div>
|
|
) : consumables.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
|
<Package className="w-8 h-8 mb-2 opacity-40" />
|
|
<p className="text-sm">소모품이 없어요</p>
|
|
</div>
|
|
) : (
|
|
<Table noWrapper>
|
|
<thead className="sticky top-0 z-10 bg-card">
|
|
<TableRow>
|
|
<TableHead className="min-w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">소모품명</TableHead>
|
|
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">교체주기</TableHead>
|
|
<TableHead className="w-[60px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단위</TableHead>
|
|
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">규격</TableHead>
|
|
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">제조사</TableHead>
|
|
</TableRow>
|
|
</thead>
|
|
<TableBody>
|
|
{consumables.map((item) => (
|
|
<TableRow key={item.id} className="cursor-pointer hover:bg-primary/5" onDoubleClick={() => {
|
|
setConsumableForm({ ...item });
|
|
setConsumableEditMode(true);
|
|
loadConsumableItems();
|
|
setConsumableModalOpen(true);
|
|
}}>
|
|
<TableCell className="text-sm">{item.consumable_name || "-"}</TableCell>
|
|
<TableCell className="text-[13px]">{item.replacement_cycle || "-"}</TableCell>
|
|
<TableCell className="text-[13px]">{item.unit || "-"}</TableCell>
|
|
<TableCell className="text-[13px]">{item.specification || "-"}</TableCell>
|
|
<TableCell className="text-[13px]">{item.manufacturer || "-"}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</ResizablePanel>
|
|
</ResizablePanelGroup>
|
|
</div>
|
|
|
|
{/* 설비 등록/수정 모달 */}
|
|
<Dialog open={equipModalOpen} onOpenChange={setEquipModalOpen}>
|
|
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col overflow-hidden">
|
|
<DialogHeader>
|
|
<DialogTitle>{equipEditMode ? "설비 수정" : "설비 등록"}</DialogTitle>
|
|
<DialogDescription>{equipEditMode ? "설비 정보를 수정합니다." : "새로운 설비를 등록합니다."}</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="flex-1 overflow-y-auto">
|
|
<div className="grid grid-cols-2 gap-4 py-4">
|
|
<div className="space-y-1.5"><Label className="text-sm">설비코드</Label>
|
|
<Input value={equipForm.equipment_code || ""} onChange={(e) => setEquipForm((p) => ({ ...p, equipment_code: e.target.value }))} placeholder="설비코드" className="h-9" disabled={equipEditMode} /></div>
|
|
<div className="space-y-1.5"><Label className="text-sm">설비명 <span className="text-destructive">*</span></Label>
|
|
<Input value={equipForm.equipment_name || ""} onChange={(e) => setEquipForm((p) => ({ ...p, equipment_name: e.target.value }))} placeholder="설비명" className="h-9" /></div>
|
|
<div className="space-y-1.5"><Label className="text-sm">설비유형</Label>
|
|
{catSelect("equipment_type", equipForm.equipment_type, (v) => setEquipForm((p) => ({ ...p, equipment_type: v })), "설비유형")}</div>
|
|
<div className="space-y-1.5"><Label className="text-sm">가동상태</Label>
|
|
{catSelect("operation_status", equipForm.operation_status, (v) => setEquipForm((p) => ({ ...p, operation_status: v })), "가동상태")}</div>
|
|
<div className="space-y-1.5"><Label className="text-sm">설치장소</Label>
|
|
<Input value={equipForm.installation_location || ""} onChange={(e) => setEquipForm((p) => ({ ...p, installation_location: e.target.value }))} placeholder="설치장소" className="h-9" /></div>
|
|
<div className="space-y-1.5"><Label className="text-sm">제조사</Label>
|
|
<Input value={equipForm.manufacturer || ""} onChange={(e) => setEquipForm((p) => ({ ...p, manufacturer: e.target.value }))} placeholder="제조사" className="h-9" /></div>
|
|
<div className="space-y-1.5"><Label className="text-sm">모델명</Label>
|
|
<Input value={equipForm.model_name || ""} onChange={(e) => setEquipForm((p) => ({ ...p, model_name: e.target.value }))} placeholder="모델명" className="h-9" /></div>
|
|
<div className="space-y-1.5"><Label className="text-sm">도입일자</Label>
|
|
<Input type="date" value={equipForm.introduction_date || ""} onChange={(e) => setEquipForm((p) => ({ ...p, introduction_date: e.target.value }))} className="h-9" /></div>
|
|
<div className="space-y-1.5 col-span-2"><Label className="text-sm">비고</Label>
|
|
<Input value={equipForm.remarks || ""} onChange={(e) => setEquipForm((p) => ({ ...p, remarks: e.target.value }))} placeholder="비고" className="h-9" /></div>
|
|
<div className="space-y-1.5 col-span-2"><Label className="text-sm">이미지</Label>
|
|
<ImageUpload value={equipForm.image_path} onChange={(v) => setEquipForm((p) => ({ ...p, image_path: v }))}
|
|
tableName={EQUIP_TABLE} recordId={equipForm.id} columnName="image_path" /></div>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setEquipModalOpen(false)}>취소</Button>
|
|
<Button onClick={handleEquipSave} disabled={saving}>{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} 저장</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* 점검항목 추가 모달 */}
|
|
<Dialog open={inspectionModalOpen} onOpenChange={setInspectionModalOpen}>
|
|
<DialogContent className="max-w-lg">
|
|
<DialogHeader><DialogTitle>{inspectionEditMode ? "점검항목 수정" : "점검항목 추가"}</DialogTitle><DialogDescription>{selectedEquip?.equipment_name}에 점검항목을 {inspectionEditMode ? "수정" : "추가"}합니다.</DialogDescription></DialogHeader>
|
|
<div className="space-y-4 py-4">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-1.5"><Label className="text-sm">점검항목명 <span className="text-destructive">*</span></Label>
|
|
<Input value={inspectionForm.inspection_item || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, inspection_item: e.target.value }))} placeholder="예: 온도점검, 진동점검" className="h-9" /></div>
|
|
<div className="space-y-1.5"><Label className="text-sm">점검주기 <span className="text-destructive">*</span></Label>
|
|
{catSelect("inspection_cycle", inspectionForm.inspection_cycle, (v) => setInspectionForm((p) => ({ ...p, inspection_cycle: v })), "점검주기")}</div>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-1.5"><Label className="text-sm">점검방법 <span className="text-destructive">*</span></Label>
|
|
{catSelect("inspection_method", inspectionForm.inspection_method, (v) => {
|
|
const label = resolve("inspection_method", v);
|
|
const isNum = label === "숫자" || v === "숫자";
|
|
if (!isNum) {
|
|
setInspectionForm((p) => ({ ...p, inspection_method: v, unit: "", standard_value: "", tolerance: "", lower_limit: "", upper_limit: "" }));
|
|
} else {
|
|
setInspectionForm((p) => ({ ...p, inspection_method: v }));
|
|
}
|
|
}, "점검방법")}</div>
|
|
{(() => {
|
|
const methodLabel = resolve("inspection_method", inspectionForm.inspection_method);
|
|
const isNumeric = methodLabel === "숫자" || inspectionForm.inspection_method === "숫자";
|
|
if (!isNumeric) return null;
|
|
return (
|
|
<div className="space-y-1.5"><Label className="text-sm">측정 단위 <span className="text-destructive">*</span></Label>
|
|
<Input value={inspectionForm.unit || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, unit: e.target.value }))} placeholder="예: ℃, mm, V" className="h-9" /></div>
|
|
);
|
|
})()}
|
|
</div>
|
|
{(() => {
|
|
const methodLabel = resolve("inspection_method", inspectionForm.inspection_method);
|
|
const isNumeric = methodLabel === "숫자" || inspectionForm.inspection_method === "숫자";
|
|
if (!isNumeric) return null;
|
|
return (
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-1.5"><Label className="text-sm">기준값</Label>
|
|
<Input value={inspectionForm.standard_value || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, standard_value: e.target.value }))} placeholder="기준값 입력" className="h-9" type="number" /></div>
|
|
<div className="space-y-1.5"><Label className="text-sm">±오차범위</Label>
|
|
<Input value={inspectionForm.tolerance || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, tolerance: e.target.value }))} placeholder="허용 오차범위" className="h-9" type="number" /></div>
|
|
</div>
|
|
);
|
|
})()}
|
|
<div className="space-y-1.5"><Label className="text-sm">점검내용</Label>
|
|
<textarea
|
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm min-h-[70px] resize-none focus:outline-none focus:ring-2 focus:ring-ring"
|
|
value={inspectionForm.inspection_content || ""}
|
|
onChange={(e) => setInspectionForm((p) => ({ ...p, inspection_content: e.target.value }))}
|
|
placeholder="점검 항목 및 내용 입력"
|
|
/></div>
|
|
<div className="space-y-1.5"><Label className="text-sm">체크리스트 (선택사항)</Label>
|
|
<textarea
|
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm min-h-[70px] resize-none focus:outline-none focus:ring-2 focus:ring-ring"
|
|
value={inspectionForm.checklist || ""}
|
|
onChange={(e) => setInspectionForm((p) => ({ ...p, checklist: e.target.value }))}
|
|
placeholder="점검 체크리스트 입력 (줄바꿈으로 구분)"
|
|
/></div>
|
|
</div>
|
|
<DialogFooter className="flex items-center justify-between sm:justify-between">
|
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
|
<Checkbox checked={inspectionContinuous} onCheckedChange={(c) => setInspectionContinuous(!!c)} />
|
|
저장 후 계속 입력
|
|
</label>
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" onClick={() => setInspectionModalOpen(false)}>취소</Button>
|
|
<Button onClick={handleInspectionSave} disabled={saving}><Save className="w-4 h-4 mr-1.5" /> 저장</Button>
|
|
</div>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* 소모품 추가 모달 */}
|
|
<Dialog open={consumableModalOpen} onOpenChange={setConsumableModalOpen}>
|
|
<DialogContent className="max-w-lg">
|
|
<DialogHeader><DialogTitle>{consumableEditMode ? "소모품 수정" : "소모품 추가"}</DialogTitle><DialogDescription>{selectedEquip?.equipment_name}에 소모품을 {consumableEditMode ? "수정" : "추가"}합니다.</DialogDescription></DialogHeader>
|
|
<div className="grid grid-cols-2 gap-4 py-4">
|
|
<div className="space-y-1.5 col-span-2"><Label className="text-sm">소모품명 <span className="text-destructive">*</span></Label>
|
|
{consumableItemOptions.length > 0 ? (
|
|
<Select value={consumableForm.consumable_name || ""} onValueChange={(v) => {
|
|
const item = consumableItemOptions.find((i) => (i.item_name || i.item_number) === v);
|
|
setConsumableForm((p) => ({
|
|
...p,
|
|
consumable_name: v,
|
|
specification: item?.size || p.specification || "",
|
|
unit: item?.unit || p.unit || "",
|
|
manufacturer: item?.manufacturer || p.manufacturer || "",
|
|
}));
|
|
}}>
|
|
<SelectTrigger className="h-9"><SelectValue placeholder="소모품 선택" /></SelectTrigger>
|
|
<SelectContent>
|
|
{consumableItemOptions.map((item) => (
|
|
<SelectItem key={item.id} value={item.item_name || item.item_number}>
|
|
{item.item_name}{item.size ? ` (${item.size})` : ""}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
) : (
|
|
<div>
|
|
<Input value={consumableForm.consumable_name || ""} onChange={(e) => setConsumableForm((p) => ({ ...p, consumable_name: e.target.value }))}
|
|
placeholder="소모품명 직접 입력" className="h-9" />
|
|
<p className="text-xs text-muted-foreground mt-1">품목정보에 소모품 타입 품목을 등록하면 선택 가능합니다</p>
|
|
</div>
|
|
)}</div>
|
|
<div className="space-y-1.5"><Label className="text-sm">교체주기</Label>
|
|
<Input value={consumableForm.replacement_cycle || ""} onChange={(e) => setConsumableForm((p) => ({ ...p, replacement_cycle: e.target.value }))} placeholder="교체주기" className="h-9" /></div>
|
|
<div className="space-y-1.5"><Label className="text-sm">단위</Label>
|
|
<Input value={consumableForm.unit || ""} onChange={(e) => setConsumableForm((p) => ({ ...p, unit: e.target.value }))} placeholder="단위" className="h-9" /></div>
|
|
<div className="space-y-1.5"><Label className="text-sm">규격</Label>
|
|
<Input value={consumableForm.specification || ""} onChange={(e) => setConsumableForm((p) => ({ ...p, specification: e.target.value }))} placeholder="규격" className="h-9" /></div>
|
|
<div className="space-y-1.5"><Label className="text-sm">제조사</Label>
|
|
<Input value={consumableForm.manufacturer || ""} onChange={(e) => setConsumableForm((p) => ({ ...p, manufacturer: e.target.value }))} placeholder="제조사" className="h-9" /></div>
|
|
<div className="space-y-1.5 col-span-2"><Label className="text-sm">이미지</Label>
|
|
<ImageUpload value={consumableForm.image_path} onChange={(v) => setConsumableForm((p) => ({ ...p, image_path: v }))}
|
|
tableName={CONSUMABLE_TABLE} columnName="image_path" /></div>
|
|
</div>
|
|
<DialogFooter className="flex items-center justify-between sm:justify-between">
|
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
|
<Checkbox checked={consumableContinuous} onCheckedChange={(c) => setConsumableContinuous(!!c)} />
|
|
저장 후 계속 입력
|
|
</label>
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" onClick={() => setConsumableModalOpen(false)}>취소</Button>
|
|
<Button onClick={handleConsumableSave} disabled={saving}><Save className="w-4 h-4 mr-1.5" /> 저장</Button>
|
|
</div>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* 점검항목 복사 모달 */}
|
|
<Dialog open={copyModalOpen} onOpenChange={setCopyModalOpen}>
|
|
<DialogContent className="max-w-2xl max-h-[70vh] flex flex-col overflow-hidden">
|
|
<DialogHeader><DialogTitle>점검항목 복사</DialogTitle>
|
|
<DialogDescription>다른 설비의 점검항목을 선택하여 {selectedEquip?.equipment_name}에 복사합니다.</DialogDescription></DialogHeader>
|
|
<div className="space-y-3 flex-1 overflow-y-auto">
|
|
<div className="space-y-1.5">
|
|
<Label className="text-sm">소스 설비 선택</Label>
|
|
<Select value={copySourceEquip} onValueChange={(v) => loadCopyItems(v)}>
|
|
<SelectTrigger className="h-9"><SelectValue placeholder="복사할 설비 선택" /></SelectTrigger>
|
|
<SelectContent>
|
|
{equipments.filter((e) => e.equipment_code !== selectedEquip?.equipment_code).map((e) => (
|
|
<SelectItem key={e.equipment_code} value={e.equipment_code}>{e.equipment_name} ({e.equipment_code})</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="border rounded-lg overflow-auto max-h-[300px]">
|
|
{copyLoading ? (
|
|
<div className="flex items-center justify-center py-8"><Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /></div>
|
|
) : copyItems.length === 0 ? (
|
|
<div className="text-center text-muted-foreground py-8 text-sm">{copySourceEquip ? "점검항목이 없어요" : "설비를 선택해주세요"}</div>
|
|
) : (
|
|
<Table noWrapper>
|
|
<thead className="sticky top-0 z-10 bg-card">
|
|
<TableRow>
|
|
<TableHead className="w-[40px] text-center">
|
|
<input type="checkbox" checked={copyItems.length > 0 && copyChecked.size === copyItems.length}
|
|
onChange={(e) => { if (e.target.checked) setCopyChecked(new Set(copyItems.map((i) => i.id))); else setCopyChecked(new Set()); }} />
|
|
</TableHead>
|
|
<TableHead>점검항목</TableHead><TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">점검주기</TableHead>
|
|
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">점검방법</TableHead><TableHead className="w-[70px]">하한</TableHead>
|
|
<TableHead className="w-[70px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">상한</TableHead><TableHead className="w-[60px]">단위</TableHead>
|
|
</TableRow>
|
|
</thead>
|
|
<TableBody>
|
|
{copyItems.map((item) => (
|
|
<TableRow key={item.id} className={cn("cursor-pointer", copyChecked.has(item.id) && "bg-primary/5")}
|
|
onClick={() => setCopyChecked((prev) => { const n = new Set(prev); if (n.has(item.id)) n.delete(item.id); else n.add(item.id); return n; })}>
|
|
<TableCell className="text-center"><input type="checkbox" checked={copyChecked.has(item.id)} readOnly /></TableCell>
|
|
<TableCell className="text-sm">{item.inspection_item}</TableCell>
|
|
<TableCell className="text-[13px]">{resolve("inspection_cycle", item.inspection_cycle)}</TableCell>
|
|
<TableCell className="text-[13px]">{resolve("inspection_method", item.inspection_method)}</TableCell>
|
|
<TableCell className="text-[13px]">{item.lower_limit || "-"}</TableCell>
|
|
<TableCell className="text-[13px]">{item.upper_limit || "-"}</TableCell>
|
|
<TableCell className="text-[13px]">{item.unit || "-"}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<div className="flex items-center gap-2 w-full justify-between">
|
|
<span className="text-sm text-muted-foreground">{copyChecked.size}개 선택됨</span>
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" onClick={() => setCopyModalOpen(false)}>취소</Button>
|
|
<Button onClick={handleCopyApply} disabled={saving || copyChecked.size === 0}>
|
|
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Copy className="w-4 h-4 mr-1.5" />} 복사 적용
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* 엑셀 업로드 (멀티테이블) */}
|
|
{excelChainConfig && (
|
|
<MultiTableExcelUploadModal open={excelUploadOpen}
|
|
onOpenChange={(open) => { setExcelUploadOpen(open); if (!open) setExcelChainConfig(null); }}
|
|
config={excelChainConfig} onSuccess={() => { fetchEquipments(); refreshRight(); }} />
|
|
)}
|
|
|
|
{/* 테이블 설정 */}
|
|
<TableSettingsModal
|
|
open={ts.open}
|
|
onOpenChange={ts.setOpen}
|
|
tableName={ts.tableName}
|
|
settingsId={ts.settingsId}
|
|
defaultVisibleKeys={ts.defaultVisibleKeys}
|
|
onSave={ts.applySettings}
|
|
/>
|
|
|
|
{ConfirmDialogComponent}
|
|
</div>
|
|
);
|
|
}
|