diff --git a/frontend/app/(main)/equipment/info/page.tsx b/frontend/app/(main)/equipment/info/page.tsx new file mode 100644 index 00000000..fb82e1f2 --- /dev/null +++ b/frontend/app/(main)/equipment/info/page.tsx @@ -0,0 +1,728 @@ +"use client"; + +/** + * 설비정보 — 하드코딩 페이지 + * + * 좌측: 설비 목록 (equipment_mng) + * 우측: 탭 (기본정보 / 점검항목 / 소모품) + * 점검항목 복사 기능 포함 + */ + +import React, { useState, useEffect, useCallback } 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, + Wrench, ClipboardCheck, Package, Copy, Info, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { apiClient } from "@/lib/api/client"; +import { useAuth } from "@/hooks/useAuth"; +import { toast } from "sonner"; +import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; +import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; +import { useConfirmDialog } from "@/components/common/ConfirmDialog"; +import { FullscreenDialog } from "@/components/common/FullscreenDialog"; +import { ExcelUploadModal } from "@/components/common/ExcelUploadModal"; +import { exportToExcel } from "@/lib/utils/excelExport"; +import { FormDatePicker } from "@/components/screen/filters/FormDatePicker"; +import { autoDetectMultiTableConfig, TableChainConfig } from "@/lib/api/multiTableExcel"; +import { MultiTableExcelUploadModal } from "@/components/common/MultiTableExcelUploadModal"; +import { ImageUpload } from "@/components/common/ImageUpload"; + +const EQUIP_TABLE = "equipment_mng"; +const INSPECTION_TABLE = "equipment_inspection_item"; +const CONSUMABLE_TABLE = "equipment_consumable"; + +const LEFT_COLUMNS: DataGridColumn[] = [ + { key: "equipment_code", label: "설비코드", width: "w-[110px]" }, + { key: "equipment_name", label: "설비명", minWidth: "min-w-[130px]" }, + { key: "equipment_type", label: "설비유형", width: "w-[90px]" }, + { key: "manufacturer", label: "제조사", width: "w-[100px]" }, + { key: "installation_location", label: "설치장소", width: "w-[100px]" }, + { key: "operation_status", label: "가동상태", width: "w-[80px]" }, +]; + +const INSPECTION_COLUMNS: DataGridColumn[] = [ + { key: "inspection_item", label: "점검항목", minWidth: "min-w-[120px]", editable: true }, + { key: "inspection_cycle", label: "점검주기", width: "w-[80px]" }, + { key: "inspection_method", label: "점검방법", width: "w-[80px]" }, + { key: "lower_limit", label: "하한치", width: "w-[70px]", editable: true }, + { key: "upper_limit", label: "상한치", width: "w-[70px]", editable: true }, + { key: "unit", label: "단위", width: "w-[60px]", editable: true }, + { key: "inspection_content", label: "점검내용", minWidth: "min-w-[150px]", editable: true }, +]; + +const CONSUMABLE_COLUMNS: DataGridColumn[] = [ + { key: "image_path", label: "이미지", width: "w-[50px]", renderType: "image", sortable: false, filterable: false }, + { key: "consumable_name", label: "소모품명", minWidth: "min-w-[120px]", editable: true }, + { key: "replacement_cycle", label: "교체주기", width: "w-[90px]", editable: true }, + { key: "unit", label: "단위", width: "w-[60px]", editable: true }, + { key: "specification", label: "규격", width: "w-[100px]", editable: true }, + { key: "manufacturer", label: "제조사", width: "w-[100px]", editable: true }, +]; + +export default function EquipmentInfoPage() { + const { user } = useAuth(); + const { confirm, ConfirmDialogComponent } = useConfirmDialog(); + + // 좌측 + const [equipments, setEquipments] = useState([]); + const [equipLoading, setEquipLoading] = useState(false); + const [equipCount, setEquipCount] = useState(0); + const [searchFilters, setSearchFilters] = useState([]); + const [selectedEquipId, setSelectedEquipId] = useState(null); + + // 우측 탭 + const [rightTab, setRightTab] = useState<"info" | "inspection" | "consumable">("info"); + const [inspections, setInspections] = useState([]); + const [inspectionLoading, setInspectionLoading] = useState(false); + const [consumables, setConsumables] = useState([]); + const [consumableLoading, setConsumableLoading] = useState(false); + + // 카테고리 + const [catOptions, setCatOptions] = useState>({}); + + // 모달 + const [equipModalOpen, setEquipModalOpen] = useState(false); + const [equipEditMode, setEquipEditMode] = useState(false); + const [equipForm, setEquipForm] = useState>({}); + const [saving, setSaving] = useState(false); + + // 기본정보 탭 편집 폼 + const [infoForm, setInfoForm] = useState>({}); + const [infoSaving, setInfoSaving] = useState(false); + + const [inspectionModalOpen, setInspectionModalOpen] = useState(false); + const [inspectionForm, setInspectionForm] = useState>({}); + + const [consumableModalOpen, setConsumableModalOpen] = useState(false); + const [consumableForm, setConsumableForm] = useState>({}); + const [consumableItemOptions, setConsumableItemOptions] = useState([]); + + // 점검항목 복사 + const [copyModalOpen, setCopyModalOpen] = useState(false); + const [copySourceEquip, setCopySourceEquip] = useState(""); + const [copyItems, setCopyItems] = useState([]); + const [copyChecked, setCopyChecked] = useState>(new Set()); + const [copyLoading, setCopyLoading] = useState(false); + + // 엑셀 + const [excelUploadOpen, setExcelUploadOpen] = useState(false); + const [excelChainConfig, setExcelChainConfig] = useState(null); + const [excelDetecting, setExcelDetecting] = useState(false); + + // 카테고리 로드 + useEffect(() => { + const load = async () => { + const optMap: Record = {}; + 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; + }; + // equipment_mng 카테고리 + 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 */ } + } + // inspection 카테고리 + 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 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 fetch = 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, + }); + const raw = res.data?.data?.data || res.data?.data?.rows || []; + setInspections(raw.map((r: any) => ({ + ...r, + inspection_cycle: resolve("inspection_cycle", r.inspection_cycle), + inspection_method: resolve("inspection_method", r.inspection_method), + }))); + } catch { setInspections([]); } finally { setInspectionLoading(false); } + }; + fetch(); + }, [selectedEquip?.equipment_code, catOptions]); + + // 우측: 소모품 조회 + useEffect(() => { + if (!selectedEquip?.equipment_code) { setConsumables([]); return; } + const fetch = 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); } + }; + fetch(); + }, [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`, 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; } + setSaving(true); + try { + await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/add`, { + ...inspectionForm, equipment_code: selectedEquip?.equipment_code, + }); + toast.success("추가되었습니다."); setInspectionModalOpen(false); refreshRight(); + } catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); } finally { setSaving(false); } + }; + + // 소모품 추가 + // 소모품 품목 로드 (item_info에서 type 또는 division 라벨이 "소모품"인 것) + 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; + }; + + // type과 division 카테고리 모두에서 "소모품" 코드 찾기 + 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(); + 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 { + await apiClient.post(`/table-management/tables/${CONSUMABLE_TABLE}/add`, { + ...consumableForm, equipment_code: selectedEquip?.equipment_code, + }); + toast.success("추가되었습니다."); 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) => ( + + ); + + return ( +
+ + + +
+ } + /> + +
+ + {/* 좌측: 설비 목록 */} + +
+
+
+ 설비 목록 {equipCount}건 +
+
+ + + +
+
+ openEquipEdit()} + emptyMessage="등록된 설비가 없습니다" /> +
+
+ + + + {/* 우측: 탭 */} + +
+
+
+ {([["info", "기본정보", Info], ["inspection", "점검항목", ClipboardCheck], ["consumable", "소모품", Package]] as const).map(([tab, label, Icon]) => ( + + ))} + {selectedEquip && {selectedEquip.equipment_name}} +
+
+ {rightTab === "inspection" && ( + <> + + + + )} + {rightTab === "consumable" && ( + + )} +
+
+ + {!selectedEquipId ? ( +
좌측에서 설비를 선택하세요
+ ) : rightTab === "info" ? ( +
+
+ +
+
+
+ + +
+
+ + setInfoForm((p) => ({ ...p, equipment_name: e.target.value }))} className="h-9" /> +
+
+ + {catSelect("equipment_type", infoForm.equipment_type, (v) => setInfoForm((p) => ({ ...p, equipment_type: v })), "설비유형")} +
+
+ + setInfoForm((p) => ({ ...p, installation_location: e.target.value }))} className="h-9" /> +
+
+ + setInfoForm((p) => ({ ...p, manufacturer: e.target.value }))} className="h-9" /> +
+
+ + setInfoForm((p) => ({ ...p, model_name: e.target.value }))} className="h-9" /> +
+
+ + setInfoForm((p) => ({ ...p, introduction_date: v }))} placeholder="도입일자" /> +
+
+ + {catSelect("operation_status", infoForm.operation_status, (v) => setInfoForm((p) => ({ ...p, operation_status: v })), "가동상태")} +
+
+ + setInfoForm((p) => ({ ...p, remarks: e.target.value }))} className="h-9" /> +
+
+ + setInfoForm((p) => ({ ...p, image_path: v }))} + tableName={EQUIP_TABLE} recordId={infoForm.id} columnName="image_path" /> +
+
+
+ ) : rightTab === "inspection" ? ( + refreshRight()} /> + ) : ( + refreshRight()} /> + )} +
+
+
+
+ + {/* 설비 등록/수정 모달 */} + + }> +
+
+ setEquipForm((p) => ({ ...p, equipment_code: e.target.value }))} placeholder="설비코드" className="h-9" disabled={equipEditMode} />
+
+ setEquipForm((p) => ({ ...p, equipment_name: e.target.value }))} placeholder="설비명" className="h-9" />
+
+ {catSelect("equipment_type", equipForm.equipment_type, (v) => setEquipForm((p) => ({ ...p, equipment_type: v })), "설비유형")}
+
+ {catSelect("operation_status", equipForm.operation_status, (v) => setEquipForm((p) => ({ ...p, operation_status: v })), "가동상태")}
+
+ setEquipForm((p) => ({ ...p, installation_location: e.target.value }))} placeholder="설치장소" className="h-9" />
+
+ setEquipForm((p) => ({ ...p, manufacturer: e.target.value }))} placeholder="제조사" className="h-9" />
+
+ setEquipForm((p) => ({ ...p, model_name: e.target.value }))} placeholder="모델명" className="h-9" />
+
+ setEquipForm((p) => ({ ...p, introduction_date: v }))} placeholder="도입일자" />
+
+ setEquipForm((p) => ({ ...p, remarks: e.target.value }))} placeholder="비고" className="h-9" />
+
+ setEquipForm((p) => ({ ...p, image_path: v }))} + tableName={EQUIP_TABLE} recordId={equipForm.id} columnName="image_path" />
+
+
+ + {/* 점검항목 추가 모달 */} + + + 점검항목 추가{selectedEquip?.equipment_name}에 점검항목을 추가합니다. +
+
+ setInspectionForm((p) => ({ ...p, inspection_item: e.target.value }))} placeholder="점검항목" className="h-9" />
+
+
+ {catSelect("inspection_cycle", inspectionForm.inspection_cycle, (v) => setInspectionForm((p) => ({ ...p, inspection_cycle: v })), "점검주기")}
+
+ {catSelect("inspection_method", inspectionForm.inspection_method, (v) => setInspectionForm((p) => ({ ...p, inspection_method: v })), "점검방법")}
+
+ setInspectionForm((p) => ({ ...p, lower_limit: e.target.value }))} placeholder="하한치" className="h-9" />
+
+ setInspectionForm((p) => ({ ...p, upper_limit: e.target.value }))} placeholder="상한치" className="h-9" />
+
+ setInspectionForm((p) => ({ ...p, unit: e.target.value }))} placeholder="단위" className="h-9" />
+
+
+ setInspectionForm((p) => ({ ...p, inspection_content: e.target.value }))} placeholder="점검내용" className="h-9" />
+
+ + +
+
+ + {/* 소모품 추가 모달 */} + + + 소모품 추가{selectedEquip?.equipment_name}에 소모품을 추가합니다. +
+
+ {consumableItemOptions.length > 0 ? ( + + ) : ( +
+ setConsumableForm((p) => ({ ...p, consumable_name: e.target.value }))} + placeholder="소모품명 직접 입력" className="h-9" /> +

품목정보에 소모품 타입 품목을 등록하면 선택 가능합니다

+
+ )}
+
+ setConsumableForm((p) => ({ ...p, replacement_cycle: e.target.value }))} placeholder="교체주기" className="h-9" />
+
+ setConsumableForm((p) => ({ ...p, unit: e.target.value }))} placeholder="단위" className="h-9" />
+
+ setConsumableForm((p) => ({ ...p, specification: e.target.value }))} placeholder="규격" className="h-9" />
+
+ setConsumableForm((p) => ({ ...p, manufacturer: e.target.value }))} placeholder="제조사" className="h-9" />
+
+ setConsumableForm((p) => ({ ...p, image_path: v }))} + tableName={CONSUMABLE_TABLE} columnName="image_path" />
+
+ + +
+
+ + {/* 점검항목 복사 모달 */} + + + 점검항목 복사 + 다른 설비의 점검항목을 선택하여 {selectedEquip?.equipment_name}에 복사합니다. +
+
+ + +
+
+ {copyLoading ? ( +
+ ) : copyItems.length === 0 ? ( +
{copySourceEquip ? "점검항목이 없습니다" : "설비를 선택하세요"}
+ ) : ( + + + + + 0 && copyChecked.size === copyItems.length} + onChange={(e) => { if (e.target.checked) setCopyChecked(new Set(copyItems.map((i) => i.id))); else setCopyChecked(new Set()); }} /> + + 점검항목점검주기 + 점검방법하한 + 상한단위 + + + + {copyItems.map((item) => ( + setCopyChecked((prev) => { const n = new Set(prev); if (n.has(item.id)) n.delete(item.id); else n.add(item.id); return n; })}> + + {item.inspection_item} + {resolve("inspection_cycle", item.inspection_cycle)} + {resolve("inspection_method", item.inspection_method)} + {item.lower_limit || "-"} + {item.upper_limit || "-"} + {item.unit || "-"} + + ))} + +
+ )} +
+
+ +
+ {copyChecked.size}개 선택됨 +
+ + +
+
+
+
+
+ + {/* 엑셀 업로드 (멀티테이블) */} + {excelChainConfig && ( + { setExcelUploadOpen(open); if (!open) setExcelChainConfig(null); }} + config={excelChainConfig} onSuccess={() => { fetchEquipments(); refreshRight(); }} /> + )} + + {ConfirmDialogComponent} + + ); +} diff --git a/frontend/app/(main)/master-data/department/page.tsx b/frontend/app/(main)/master-data/department/page.tsx new file mode 100644 index 00000000..796f4ef0 --- /dev/null +++ b/frontend/app/(main)/master-data/department/page.tsx @@ -0,0 +1,448 @@ +"use client"; + +/** + * 부서관리 — 하드코딩 페이지 + * + * 좌측: 부서 목록 (dept_info) + * 우측: 선택한 부서의 인원 목록 (user_info) + * + * 모달: 부서 등록(dept_info), 사원 추가(user_info) + */ + +import React, { useState, useEffect, useCallback } 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 { Badge } from "@/components/ui/badge"; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; +import { + Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil, + Building2, Users, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { apiClient } from "@/lib/api/client"; +import { useAuth } from "@/hooks/useAuth"; +import { toast } from "sonner"; +import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; +import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; +import { useConfirmDialog } from "@/components/common/ConfirmDialog"; +import { ExcelUploadModal } from "@/components/common/ExcelUploadModal"; +import { exportToExcel } from "@/lib/utils/excelExport"; +import { FormDatePicker } from "@/components/screen/filters/FormDatePicker"; +import { formatField, validateField, validateForm } from "@/lib/utils/validation"; + +const DEPT_TABLE = "dept_info"; +const USER_TABLE = "user_info"; + +const LEFT_COLUMNS: DataGridColumn[] = [ + { key: "dept_code", label: "부서코드", width: "w-[120px]" }, + { key: "dept_name", label: "부서명", minWidth: "min-w-[150px]" }, + { key: "parent_dept_code", label: "상위부서", width: "w-[100px]" }, + { key: "status", label: "상태", width: "w-[70px]" }, +]; + +const RIGHT_COLUMNS: DataGridColumn[] = [ + { key: "sabun", label: "사번", width: "w-[80px]" }, + { key: "user_name", label: "이름", width: "w-[90px]" }, + { key: "user_id", label: "사용자ID", width: "w-[100px]" }, + { key: "position_name", label: "직급", width: "w-[80px]" }, + { key: "cell_phone", label: "휴대폰", width: "w-[120px]" }, + { key: "email", label: "이메일", minWidth: "min-w-[150px]" }, +]; + +export default function DepartmentPage() { + const { user } = useAuth(); + const { confirm, ConfirmDialogComponent } = useConfirmDialog(); + + // 좌측: 부서 + const [depts, setDepts] = useState([]); + const [deptLoading, setDeptLoading] = useState(false); + const [deptCount, setDeptCount] = useState(0); + const [searchFilters, setSearchFilters] = useState([]); + const [selectedDeptCode, setSelectedDeptCode] = useState(null); + + // 우측: 사원 + const [members, setMembers] = useState([]); + const [memberLoading, setMemberLoading] = useState(false); + + // 부서 모달 + const [deptModalOpen, setDeptModalOpen] = useState(false); + const [deptEditMode, setDeptEditMode] = useState(false); + const [deptForm, setDeptForm] = useState>({}); + const [saving, setSaving] = useState(false); + + // 사원 모달 + const [userModalOpen, setUserModalOpen] = useState(false); + const [userForm, setUserForm] = useState>({}); + const [formErrors, setFormErrors] = useState>({}); + + // 엑셀 + const [excelUploadOpen, setExcelUploadOpen] = useState(false); + + // 부서 조회 + const fetchDepts = useCallback(async () => { + setDeptLoading(true); + try { + const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value })); + const res = await apiClient.post(`/table-management/tables/${DEPT_TABLE}/data`, { + page: 1, size: 500, + dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, + autoFilter: true, + }); + const data = res.data?.data?.data || res.data?.data?.rows || []; + setDepts(data); + setDeptCount(res.data?.data?.total || data.length); + } catch (err) { + console.error("부서 조회 실패:", err); + toast.error("부서 목록을 불러오는데 실패했습니다."); + } finally { + setDeptLoading(false); + } + }, [searchFilters]); + + useEffect(() => { fetchDepts(); }, [fetchDepts]); + + // 선택된 부서 + const selectedDept = depts.find((d) => d.dept_code === selectedDeptCode); + + // 우측: 사원 조회 + useEffect(() => { + if (!selectedDeptCode) { setMembers([]); return; } + const fetchMembers = async () => { + setMemberLoading(true); + try { + const res = await apiClient.post(`/table-management/tables/${USER_TABLE}/data`, { + page: 1, size: 500, + dataFilter: { enabled: true, filters: [{ columnName: "dept_code", operator: "equals", value: selectedDeptCode }] }, + autoFilter: true, + }); + setMembers(res.data?.data?.data || res.data?.data?.rows || []); + } catch { setMembers([]); } finally { setMemberLoading(false); } + }; + fetchMembers(); + }, [selectedDeptCode]); + + // 부서 등록 + const openDeptRegister = () => { + setDeptForm({}); + setDeptEditMode(false); + setDeptModalOpen(true); + }; + + const openDeptEdit = () => { + if (!selectedDept) return; + setDeptForm({ ...selectedDept }); + setDeptEditMode(true); + setDeptModalOpen(true); + }; + + const handleDeptSave = async () => { + if (!deptForm.dept_name) { toast.error("부서명은 필수입니다."); return; } + setSaving(true); + try { + if (deptEditMode && deptForm.dept_code) { + await apiClient.put(`/table-management/tables/${DEPT_TABLE}/edit`, { + originalData: { dept_code: deptForm.dept_code }, + updatedData: { dept_name: deptForm.dept_name, parent_dept_code: deptForm.parent_dept_code || null }, + }); + toast.success("수정되었습니다."); + } else { + await apiClient.post(`/table-management/tables/${DEPT_TABLE}/add`, { + dept_code: deptForm.dept_code || "", + dept_name: deptForm.dept_name, + parent_dept_code: deptForm.parent_dept_code || null, + }); + toast.success("등록되었습니다."); + } + setDeptModalOpen(false); + fetchDepts(); + } catch (err: any) { + toast.error(err.response?.data?.message || "저장에 실패했습니다."); + } finally { + setSaving(false); + } + }; + + // 부서 삭제 + const handleDeptDelete = async () => { + if (!selectedDeptCode) return; + const ok = await confirm("부서를 삭제하시겠습니까?", { + description: "해당 부서에 소속된 사원 정보는 유지됩니다.", + variant: "destructive", confirmText: "삭제", + }); + if (!ok) return; + try { + await apiClient.delete(`/table-management/tables/${DEPT_TABLE}/delete`, { + data: [{ dept_code: selectedDeptCode }], + }); + toast.success("삭제되었습니다."); + setSelectedDeptCode(null); + fetchDepts(); + } catch { toast.error("삭제에 실패했습니다."); } + }; + + // 사원 추가 + const openUserModal = () => { + setUserForm({ dept_code: selectedDeptCode || "" }); + setFormErrors({}); + setUserModalOpen(true); + }; + + const handleUserFormChange = (field: string, value: string) => { + const formatted = formatField(field, value); + setUserForm((prev) => ({ ...prev, [field]: formatted })); + const error = validateField(field, formatted); + setFormErrors((prev) => { const n = { ...prev }; if (error) n[field] = error; else delete n[field]; return n; }); + }; + + const handleUserSave = async () => { + if (!userForm.user_id) { toast.error("사용자 ID는 필수입니다."); return; } + if (!userForm.user_name) { toast.error("사용자 이름은 필수입니다."); return; } + const errors = validateForm(userForm, ["cell_phone", "email"]); + setFormErrors(errors); + if (Object.keys(errors).length > 0) { toast.error("입력 형식을 확인해주세요."); return; } + + setSaving(true); + try { + const { created_date, updated_date, ...fields } = userForm; + await apiClient.post(`/table-management/tables/${USER_TABLE}/add`, fields); + toast.success("사원이 추가되었습니다."); + setUserModalOpen(false); + // 우측 새로고침 + const code = selectedDeptCode; + setSelectedDeptCode(null); + setTimeout(() => setSelectedDeptCode(code), 50); + } catch (err: any) { + toast.error(err.response?.data?.message || "저장에 실패했습니다."); + } finally { + setSaving(false); + } + }; + + // 엑셀 다운로드 + const handleExcelDownload = async () => { + if (depts.length === 0) return; + const data = depts.map((d) => ({ + 부서코드: d.dept_code, 부서명: d.dept_name, 상위부서: d.parent_dept_code, 상태: d.status, + })); + await exportToExcel(data, "부서관리.xlsx", "부서"); + toast.success("다운로드 완료"); + }; + + return ( +
+ {/* 검색 */} + + + +
+ } + /> + + {/* 분할 패널 */} +
+ + {/* 좌측: 부서 */} + +
+
+
+ 부서 + {deptCount}건 +
+
+ + + +
+
+ { + const dept = depts.find((d) => d.dept_code === id || d.id === id); + setSelectedDeptCode(dept?.dept_code || id); + }} + onRowDoubleClick={() => openDeptEdit()} + emptyMessage="등록된 부서가 없습니다" + /> +
+
+ + + + {/* 우측: 사원 */} + +
+
+
+ 부서 인원 + {selectedDept && {selectedDept.dept_name}} + {members.length > 0 && {members.length}명} +
+ +
+ {!selectedDeptCode ? ( +
+ 좌측에서 부서를 선택하세요 +
+ ) : ( + + )} +
+
+
+
+ + {/* 부서 등록/수정 모달 */} + + + + {deptEditMode ? "부서 수정" : "부서 등록"} + {deptEditMode ? "부서 정보를 수정합니다." : "새로운 부서를 등록합니다."} + +
+
+ + setDeptForm((p) => ({ ...p, dept_code: e.target.value }))} + placeholder="부서코드" className="h-9" disabled={deptEditMode} /> +
+
+ + setDeptForm((p) => ({ ...p, dept_name: e.target.value }))} + placeholder="부서명" className="h-9" /> +
+
+ + +
+
+ + + + +
+
+ + {/* 사원 추가 모달 */} + + + + 사원 추가 + {selectedDept?.dept_name} 부서에 사원을 추가합니다. + +
+
+ + setUserForm((p) => ({ ...p, user_id: e.target.value }))} + placeholder="사용자 ID" className="h-9" /> +
+
+ + setUserForm((p) => ({ ...p, user_name: e.target.value }))} + placeholder="이름" className="h-9" /> +
+
+ + setUserForm((p) => ({ ...p, sabun: e.target.value }))} + placeholder="사번" className="h-9" /> +
+
+ + setUserForm((p) => ({ ...p, user_password: e.target.value }))} + placeholder="비밀번호" className="h-9" type="password" /> +
+
+ + setUserForm((p) => ({ ...p, position_name: e.target.value }))} + placeholder="직급" className="h-9" /> +
+
+ + +
+
+ + handleUserFormChange("cell_phone", e.target.value)} + placeholder="010-0000-0000" className={cn("h-9", formErrors.cell_phone && "border-destructive")} /> + {formErrors.cell_phone &&

{formErrors.cell_phone}

} +
+
+ + handleUserFormChange("email", e.target.value)} + placeholder="example@email.com" className={cn("h-9", formErrors.email && "border-destructive")} /> + {formErrors.email &&

{formErrors.email}

} +
+
+ + setUserForm((p) => ({ ...p, regdate: v }))} placeholder="입사일" /> +
+
+ + setUserForm((p) => ({ ...p, end_date: v }))} placeholder="퇴사일" /> +
+
+ + + + +
+
+ + {/* 엑셀 업로드 */} + fetchDepts()} + /> + + {ConfirmDialogComponent} + + ); +} diff --git a/frontend/app/(main)/sales/order/page.tsx b/frontend/app/(main)/sales/order/page.tsx index 42f92462..203f618a 100644 --- a/frontend/app/(main)/sales/order/page.tsx +++ b/frontend/app/(main)/sales/order/page.tsx @@ -145,6 +145,17 @@ export default function SalesOrderPage() { const custs = custRes.data?.data?.data || custRes.data?.data?.rows || []; optMap["partner_id"] = custs.map((c: any) => ({ code: c.customer_code, label: `${c.customer_name} (${c.customer_code})` })); } catch { /* skip */ } + // 사용자 목록 로드 (담당자 선택용) + 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 || []; + optMap["manager_id"] = users.map((u: any) => ({ + code: u.user_id || u.id, + label: `${u.user_name || u.name || u.user_id}${u.position_name ? ` (${u.position_name})` : ""}`, + })); + } catch { /* skip */ } // item_info 카테고리도 로드 (unit, material 등 코드→라벨 변환용) for (const col of ["unit", "material", "division", "type"]) { try { @@ -254,7 +265,7 @@ export default function SalesOrderPage() { const defaultSellMode = categoryOptions["sell_mode"]?.[0]?.code || ""; const defaultInputMode = categoryOptions["input_mode"]?.[0]?.code || ""; const defaultPriceMode = categoryOptions["price_mode"]?.[0]?.code || ""; - setMasterForm({ input_mode: defaultInputMode, sell_mode: defaultSellMode, price_mode: defaultPriceMode }); + setMasterForm({ input_mode: defaultInputMode, sell_mode: defaultSellMode, price_mode: defaultPriceMode, manager_id: user?.userId || "" }); setDetailRows([]); setDeliveryOptions([]); setIsEditMode(false); @@ -647,8 +658,12 @@ export default function SalesOrderPage() {
- setMasterForm((p) => ({ ...p, manager_id: e.target.value }))} - placeholder="담당자" className="h-9" /> +
diff --git a/frontend/app/(main)/sales/shipping-order/page.tsx b/frontend/app/(main)/sales/shipping-order/page.tsx index 8b2c0dea..9ffb45bd 100644 --- a/frontend/app/(main)/sales/shipping-order/page.tsx +++ b/frontend/app/(main)/sales/shipping-order/page.tsx @@ -25,6 +25,7 @@ import { getItemSource, } from "@/lib/api/shipping"; import { ExcelUploadModal } from "@/components/common/ExcelUploadModal"; +import { FullscreenDialog } from "@/components/common/FullscreenDialog"; type DataSourceType = "shipmentPlan" | "salesOrder" | "itemInfo"; @@ -584,14 +585,22 @@ export default function ShippingOrderPage() {
{/* 등록/수정 모달 */} - - - - {isEditMode ? "출하지시 수정" : "출하지시 등록"} - - {isEditMode ? "출하지시 정보를 수정합니다." : "왼쪽에서 데이터를 선택하고 오른쪽에서 출하지시 정보를 입력하세요."} - - + + + + + } + >
@@ -820,14 +829,7 @@ export default function ShippingOrderPage() {
- - - - -
-
+ {/* 엑셀 업로드 모달 */} (null); + // 이미지 확대 모달 + const [previewImage, setPreviewImage] = useState(null); const [editValue, setEditValue] = useState(""); const editRef = useRef(null); @@ -413,6 +417,18 @@ export function DataGrid({ ); } + // 이미지 타입 + if (col.renderType === "image" && val) { + const src = (val.startsWith("http") || val.startsWith("/")) ? val : `/api/files/preview/${val}`; + return ( + { e.stopPropagation(); setPreviewImage(src); }} /> + ); + } + if (col.renderType === "image" && !val) { + return
; + } + let display = val ?? ""; if (col.formatNumber || col.inputType === "number") display = fmtNum(val); const truncateClass = col.truncate !== false ? "block truncate" : ""; @@ -509,7 +525,7 @@ export function DataGrid({ {columns.map((col) => ( { if (col.editable) { e.stopPropagation(); @@ -525,6 +541,20 @@ export function DataGrid({ + + {/* 이미지 확대 모달 */} + {previewImage && ( +
setPreviewImage(null)}> +
+ + +
+
+ )} ); } diff --git a/frontend/components/common/ImageUpload.tsx b/frontend/components/common/ImageUpload.tsx new file mode 100644 index 00000000..afd68a19 --- /dev/null +++ b/frontend/components/common/ImageUpload.tsx @@ -0,0 +1,164 @@ +"use client"; + +/** + * ImageUpload — 공통 이미지 업로드 컴포넌트 + * + * 기능: + * - 이미지 파일 선택 (클릭 or 드래그) + * - 미리보기 표시 + * - /api/files/upload API로 업로드 + * - 업로드 후 파일 objid 반환 + * - 기존 이미지 표시 (objid 또는 URL) + * + * 사용법: + * setForm(...)} // 업로드 완료 시 objid 반환 + * tableName="equipment_mng" // 연결 테이블 + * recordId="xxx" // 연결 레코드 ID + * columnName="image_path" // 연결 컬럼명 + * /> + */ + +import React, { useState, useRef, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { Upload, X, Loader2, ImageIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { apiClient } from "@/lib/api/client"; +import { toast } from "sonner"; + +interface ImageUploadProps { + /** 현재 이미지 값 (파일 objid, URL, 또는 빈 문자열) */ + value?: string; + /** 업로드 완료 시 콜백 (파일 objid) */ + onChange?: (value: string) => void; + /** 연결할 테이블명 */ + tableName?: string; + /** 연결할 레코드 ID */ + recordId?: string; + /** 연결할 컬럼명 */ + columnName?: string; + /** 높이 (기본 160px) */ + height?: string; + /** 비활성화 */ + disabled?: boolean; + className?: string; +} + +export function ImageUpload({ + value, onChange, tableName, recordId, columnName, + height = "h-40", disabled = false, className, +}: ImageUploadProps) { + const [uploading, setUploading] = useState(false); + const [dragOver, setDragOver] = useState(false); + const fileRef = useRef(null); + + // 이미지 URL 결정 + const imageUrl = value + ? (value.startsWith("http") || value.startsWith("/")) + ? value + : `/api/files/preview/${value}` + : null; + + const handleUpload = useCallback(async (file: File) => { + if (!file.type.startsWith("image/")) { + toast.error("이미지 파일만 업로드 가능합니다."); + return; + } + if (file.size > 10 * 1024 * 1024) { + toast.error("파일 크기는 10MB 이하만 가능합니다."); + return; + } + + setUploading(true); + try { + const formData = new FormData(); + formData.append("files", file); + formData.append("docType", "IMAGE"); + formData.append("docTypeName", "이미지"); + if (tableName) formData.append("linkedTable", tableName); + if (recordId) formData.append("recordId", recordId); + if (columnName) formData.append("columnName", columnName); + if (tableName && recordId) { + formData.append("autoLink", "true"); + if (columnName) formData.append("isVirtualFileColumn", "true"); + } + + const res = await apiClient.post("/files/upload", formData, { + headers: { "Content-Type": "multipart/form-data" }, + }); + + if (res.data?.success && (res.data.files?.length > 0 || res.data.data?.length > 0)) { + const file = res.data.files?.[0] || res.data.data?.[0]; + const objid = file.objid; + onChange?.(objid); + toast.success("이미지가 업로드되었습니다."); + } else { + toast.error(res.data?.message || "업로드에 실패했습니다."); + } + } catch (err: any) { + console.error("이미지 업로드 실패:", err); + toast.error(err.response?.data?.message || "업로드에 실패했습니다."); + } finally { + setUploading(false); + } + }, [tableName, recordId, columnName, onChange]); + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) handleUpload(file); + e.target.value = ""; + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setDragOver(false); + const file = e.dataTransfer.files?.[0]; + if (file) handleUpload(file); + }; + + const handleRemove = () => { + onChange?.(""); + }; + + return ( +
+
!disabled && !uploading && fileRef.current?.click()} + onDragOver={(e) => { e.preventDefault(); if (!disabled) setDragOver(true); }} + onDragLeave={() => setDragOver(false)} + onDrop={!disabled ? handleDrop : undefined} + > + {uploading ? ( +
+ + 업로드 중... +
+ ) : imageUrl ? ( + 이미지 + ) : ( +
+ + 클릭 또는 드래그하여 업로드 +
+ )} +
+ + {/* 삭제 버튼 */} + {imageUrl && !disabled && ( + + )} + + +
+ ); +}