From a6f37fd3dc9026557517e84a85a2ea1773b493b0 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 24 Feb 2026 15:28:21 +0900 Subject: [PATCH] feat: Enhance SplitPanelLayoutComponent with improved filtering and modal handling - Updated search conditions to use an object structure with an "equals" operator for better filtering logic. - Added validation to ensure an item is selected in the left panel before opening the modal, providing user feedback through a toast notification. - Extracted foreign key data from the selected left item for improved data handling when opening the modal. - Cleaned up the code by removing unnecessary comments and consolidating logic for clarity and maintainability. --- .../src/routes/processWorkStandardRoutes.ts | 3 + frontend/lib/registry/components/index.ts | 1 + .../v2-item-routing/ItemRoutingComponent.tsx | 503 +++++++++++ .../ItemRoutingConfigPanel.tsx | 780 ++++++++++++++++++ .../v2-item-routing/ItemRoutingRenderer.tsx | 32 + .../components/v2-item-routing/config.ts | 38 + .../v2-item-routing/hooks/useItemRouting.ts | 239 ++++++ .../components/v2-item-routing/index.ts | 57 ++ .../components/v2-item-routing/types.ts | 77 ++ .../SplitPanelLayoutComponent.tsx | 53 +- 10 files changed, 1765 insertions(+), 18 deletions(-) create mode 100644 frontend/lib/registry/components/v2-item-routing/ItemRoutingComponent.tsx create mode 100644 frontend/lib/registry/components/v2-item-routing/ItemRoutingConfigPanel.tsx create mode 100644 frontend/lib/registry/components/v2-item-routing/ItemRoutingRenderer.tsx create mode 100644 frontend/lib/registry/components/v2-item-routing/config.ts create mode 100644 frontend/lib/registry/components/v2-item-routing/hooks/useItemRouting.ts create mode 100644 frontend/lib/registry/components/v2-item-routing/index.ts create mode 100644 frontend/lib/registry/components/v2-item-routing/types.ts diff --git a/backend-node/src/routes/processWorkStandardRoutes.ts b/backend-node/src/routes/processWorkStandardRoutes.ts index 087f08c0..0c052007 100644 --- a/backend-node/src/routes/processWorkStandardRoutes.ts +++ b/backend-node/src/routes/processWorkStandardRoutes.ts @@ -3,10 +3,13 @@ */ import express from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; import * as ctrl from "../controllers/processWorkStandardController"; const router = express.Router(); +router.use(authenticateToken); + // 품목/라우팅/공정 조회 (좌측 트리) router.get("/items", ctrl.getItemsWithRouting); router.get("/items/:itemCode/routings", ctrl.getRoutingsWithProcesses); diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index 910f3a0b..bb64b79c 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -113,6 +113,7 @@ import "./v2-select/V2SelectRenderer"; // V2 통합 선택 컴포넌트 import "./v2-date/V2DateRenderer"; // V2 통합 날짜 컴포넌트 import "./v2-file-upload/V2FileUploadRenderer"; // V2 파일 업로드 컴포넌트 import "./v2-process-work-standard/ProcessWorkStandardRenderer"; // 공정 작업기준 +import "./v2-item-routing/ItemRoutingRenderer"; // 품목별 라우팅 /** * 컴포넌트 초기화 함수 diff --git a/frontend/lib/registry/components/v2-item-routing/ItemRoutingComponent.tsx b/frontend/lib/registry/components/v2-item-routing/ItemRoutingComponent.tsx new file mode 100644 index 00000000..492f7255 --- /dev/null +++ b/frontend/lib/registry/components/v2-item-routing/ItemRoutingComponent.tsx @@ -0,0 +1,503 @@ +"use client"; + +import React, { useState, useEffect, useCallback, useMemo } from "react"; +import { Search, Plus, Trash2, Edit, ListOrdered, Package } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { cn } from "@/lib/utils"; +import { useToast } from "@/hooks/use-toast"; +import { ItemRoutingConfig, ItemRoutingComponentProps } from "./types"; +import { defaultConfig } from "./config"; +import { useItemRouting } from "./hooks/useItemRouting"; + +export function ItemRoutingComponent({ + config: configProp, + isPreview, +}: ItemRoutingComponentProps) { + const { toast } = useToast(); + + const { + config, + items, + versions, + details, + loading, + selectedItemCode, + selectedItemName, + selectedVersionId, + fetchItems, + selectItem, + selectVersion, + refreshVersions, + refreshDetails, + deleteDetail, + deleteVersion, + } = useItemRouting(configProp || {}); + + const [searchText, setSearchText] = useState(""); + const [deleteTarget, setDeleteTarget] = useState<{ + type: "version" | "detail"; + id: string; + name: string; + } | null>(null); + + // 초기 로딩 (마운트 시 1회만) + const mountedRef = React.useRef(false); + useEffect(() => { + if (!mountedRef.current) { + mountedRef.current = true; + fetchItems(); + } + }, [fetchItems]); + + // 모달 저장 성공 감지 -> 데이터 새로고침 + useEffect(() => { + const handleSaveSuccess = () => { + refreshVersions(); + refreshDetails(); + }; + window.addEventListener("saveSuccessInModal", handleSaveSuccess); + return () => { + window.removeEventListener("saveSuccessInModal", handleSaveSuccess); + }; + }, [refreshVersions, refreshDetails]); + + // 품목 검색 + const handleSearch = useCallback(() => { + fetchItems(searchText || undefined); + }, [fetchItems, searchText]); + + const handleSearchKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter") handleSearch(); + }, + [handleSearch] + ); + + // 버전 추가 모달 + const handleAddVersion = useCallback(() => { + if (!selectedItemCode) { + toast({ title: "품목을 먼저 선택해주세요", variant: "destructive" }); + return; + } + const screenId = config.modals.versionAddScreenId; + if (!screenId) return; + + window.dispatchEvent( + new CustomEvent("openScreenModal", { + detail: { + screenId, + urlParams: { mode: "add", tableName: config.dataSource.routingVersionTable }, + splitPanelParentData: { + [config.dataSource.routingVersionFkColumn]: selectedItemCode, + }, + }, + }) + ); + }, [selectedItemCode, config, toast]); + + // 공정 추가 모달 + const handleAddProcess = useCallback(() => { + if (!selectedVersionId) { + toast({ title: "라우팅 버전을 먼저 선택해주세요", variant: "destructive" }); + return; + } + const screenId = config.modals.processAddScreenId; + if (!screenId) return; + + window.dispatchEvent( + new CustomEvent("openScreenModal", { + detail: { + screenId, + urlParams: { mode: "add", tableName: config.dataSource.routingDetailTable }, + splitPanelParentData: { + [config.dataSource.routingDetailFkColumn]: selectedVersionId, + }, + }, + }) + ); + }, [selectedVersionId, config, toast]); + + // 공정 수정 모달 + const handleEditProcess = useCallback( + (detail: Record) => { + const screenId = config.modals.processEditScreenId; + if (!screenId) return; + + window.dispatchEvent( + new CustomEvent("openScreenModal", { + detail: { + screenId, + urlParams: { mode: "edit", tableName: config.dataSource.routingDetailTable }, + editData: detail, + }, + }) + ); + }, + [config] + ); + + // 삭제 확인 + const handleConfirmDelete = useCallback(async () => { + if (!deleteTarget) return; + + let success = false; + if (deleteTarget.type === "version") { + success = await deleteVersion(deleteTarget.id); + } else { + success = await deleteDetail(deleteTarget.id); + } + + if (success) { + toast({ title: `${deleteTarget.name} 삭제 완료` }); + } else { + toast({ title: "삭제 실패", variant: "destructive" }); + } + setDeleteTarget(null); + }, [deleteTarget, deleteVersion, deleteDetail, toast]); + + // entity join으로 가져온 공정명 컬럼 이름 추정 + const processNameKey = useMemo(() => { + const ds = config.dataSource; + return `${ds.processTable}_${ds.processNameColumn}`; + }, [config.dataSource]); + + const splitRatio = config.splitRatio || 40; + + if (isPreview) { + return ( +
+
+ +

+ 품목별 라우팅 관리 +

+

+ 품목 선택 - 라우팅 버전 - 공정 순서 +

+
+
+ ); + } + + return ( +
+
+ {/* 좌측 패널: 품목 목록 */} +
+
+

+ {config.leftPanelTitle || "품목 목록"} +

+
+ + {/* 검색 */} +
+ setSearchText(e.target.value)} + onKeyDown={handleSearchKeyDown} + placeholder="품목명/품번 검색" + className="h-8 text-xs" + /> + +
+ + {/* 품목 리스트 */} +
+ {items.length === 0 ? ( +
+

+ {loading ? "로딩 중..." : "품목이 없습니다"} +

+
+ ) : ( +
+ {items.map((item) => { + const itemCode = + item[config.dataSource.itemCodeColumn] || item.item_code || item.item_number; + const itemName = + item[config.dataSource.itemNameColumn] || item.item_name; + const isSelected = selectedItemCode === itemCode; + + return ( + + ); + })} +
+ )} +
+
+ + {/* 우측 패널: 버전 + 공정 */} +
+ {selectedItemCode ? ( + <> + {/* 헤더: 선택된 품목 + 버전 추가 */} +
+
+

{selectedItemName}

+

{selectedItemCode}

+
+ {!config.readonly && ( + + )} +
+ + {/* 버전 선택 버튼들 */} + {versions.length > 0 ? ( +
+ 버전: + {versions.map((ver) => { + const isActive = selectedVersionId === ver.id; + return ( +
+ selectVersion(ver.id)} + > + {ver[config.dataSource.routingVersionNameColumn] || ver.version_name || ver.id} + + {!config.readonly && ( + + )} +
+ ); + })} +
+ ) : ( +
+

+ 라우팅 버전이 없습니다. 버전을 추가해주세요. +

+
+ )} + + {/* 공정 테이블 */} + {selectedVersionId ? ( +
+ {/* 공정 테이블 헤더 */} +
+

+ {config.rightPanelTitle || "공정 순서"} ({details.length}건) +

+ {!config.readonly && ( + + )} +
+ + {/* 테이블 */} +
+ {details.length === 0 ? ( +
+

+ {loading ? "로딩 중..." : "등록된 공정이 없습니다"} +

+
+ ) : ( + + + + {config.processColumns.map((col) => ( + + {col.label} + + ))} + {!config.readonly && ( + + 관리 + + )} + + + + {details.map((detail) => ( + + {config.processColumns.map((col) => { + let cellValue = detail[col.name]; + if ( + col.name === "process_code" && + detail[processNameKey] + ) { + cellValue = `${detail[col.name]} (${detail[processNameKey]})`; + } + return ( + + {cellValue ?? "-"} + + ); + })} + {!config.readonly && ( + +
+ + +
+
+ )} +
+ ))} +
+
+ )} +
+
+ ) : ( + versions.length > 0 && ( +
+

+ 라우팅 버전을 선택해주세요 +

+
+ ) + )} + + ) : ( +
+ +

+ 좌측에서 품목을 선택하세요 +

+

+ 품목을 선택하면 라우팅 버전별 공정 순서를 관리할 수 있습니다 +

+
+ )} +
+
+ + {/* 삭제 확인 다이얼로그 */} + setDeleteTarget(null)}> + + + 삭제 확인 + + {deleteTarget?.name}을(를) 삭제하시겠습니까? + {deleteTarget?.type === "version" && ( + <> +
+ 해당 버전에 포함된 모든 공정 정보도 함께 삭제됩니다. + + )} +
+
+ + 취소 + + 삭제 + + +
+
+
+ ); +} diff --git a/frontend/lib/registry/components/v2-item-routing/ItemRoutingConfigPanel.tsx b/frontend/lib/registry/components/v2-item-routing/ItemRoutingConfigPanel.tsx new file mode 100644 index 00000000..f6fefd2e --- /dev/null +++ b/frontend/lib/registry/components/v2-item-routing/ItemRoutingConfigPanel.tsx @@ -0,0 +1,780 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { Plus, Trash2, Check, ChevronsUpDown } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; +import { ItemRoutingConfig, ProcessColumnDef } from "./types"; +import { defaultConfig } from "./config"; + +interface TableInfo { + tableName: string; + displayName?: string; +} + +interface ColumnInfo { + columnName: string; + displayName?: string; + dataType?: string; +} + +interface ScreenInfo { + screenId: number; + screenName: string; + screenCode: string; +} + +// 테이블 셀렉터 Combobox +function TableSelector({ + value, + onChange, + tables, + loading, +}: { + value: string; + onChange: (v: string) => void; + tables: TableInfo[]; + loading: boolean; +}) { + const [open, setOpen] = useState(false); + const selected = tables.find((t) => t.tableName === value); + + return ( + + + + + + + + + + 테이블을 찾을 수 없습니다. + + + {tables.map((t) => ( + { + onChange(t.tableName); + setOpen(false); + }} + className="text-xs" + > + +
+ + {t.displayName || t.tableName} + + {t.displayName && ( + + {t.tableName} + + )} +
+
+ ))} +
+
+
+
+
+ ); +} + +// 컬럼 셀렉터 Combobox +function ColumnSelector({ + value, + onChange, + tableName, + label, +}: { + value: string; + onChange: (v: string) => void; + tableName: string; + label?: string; +}) { + const [open, setOpen] = useState(false); + const [columns, setColumns] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!tableName) { + setColumns([]); + return; + } + const load = async () => { + setLoading(true); + try { + const { tableManagementApi } = await import( + "@/lib/api/tableManagement" + ); + const res = await tableManagementApi.getColumnList(tableName); + if (res.success && res.data?.columns) { + setColumns(res.data.columns); + } + } catch { + /* ignore */ + } finally { + setLoading(false); + } + }; + load(); + }, [tableName]); + + const selected = columns.find((c) => c.columnName === value); + + return ( + + + + + + + + + + 컬럼을 찾을 수 없습니다. + + + {columns.map((c) => ( + { + onChange(c.columnName); + setOpen(false); + }} + className="text-xs" + > + +
+ + {c.displayName || c.columnName} + + {c.displayName && ( + + {c.columnName} + + )} +
+
+ ))} +
+
+
+
+
+ ); +} + +// 화면 셀렉터 Combobox +function ScreenSelector({ + value, + onChange, +}: { + value?: number; + onChange: (v?: number) => void; +}) { + const [open, setOpen] = useState(false); + const [screens, setScreens] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + const load = async () => { + setLoading(true); + try { + const { screenApi } = await import("@/lib/api/screen"); + const res = await screenApi.getScreens({ page: 1, size: 1000 }); + setScreens( + res.data.map((s: any) => ({ + screenId: s.screenId, + screenName: s.screenName, + screenCode: s.screenCode, + })) + ); + } catch { + /* ignore */ + } finally { + setLoading(false); + } + }; + load(); + }, []); + + const selected = screens.find((s) => s.screenId === value); + + return ( + + + + + + + + + + 화면을 찾을 수 없습니다. + + + {screens.map((s) => ( + { + onChange(s.screenId === value ? undefined : s.screenId); + setOpen(false); + }} + className="text-xs" + > + +
+ {s.screenName} + + {s.screenCode} (ID: {s.screenId}) + +
+
+ ))} +
+
+
+
+
+ ); +} + +// 공정 테이블 컬럼 셀렉터 (routingDetailTable의 컬럼 목록에서 선택) +function ProcessColumnSelector({ + value, + onChange, + tableName, + processTable, +}: { + value: string; + onChange: (v: string) => void; + tableName: string; + processTable: string; +}) { + const [open, setOpen] = useState(false); + const [columns, setColumns] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + const loadAll = async () => { + if (!tableName) return; + setLoading(true); + try { + const { tableManagementApi } = await import( + "@/lib/api/tableManagement" + ); + const res = await tableManagementApi.getColumnList(tableName); + const cols: ColumnInfo[] = []; + if (res.success && res.data?.columns) { + cols.push(...res.data.columns); + } + if (processTable && processTable !== tableName) { + const res2 = await tableManagementApi.getColumnList(processTable); + if (res2.success && res2.data?.columns) { + cols.push( + ...res2.data.columns.map((c: any) => ({ + ...c, + columnName: c.columnName, + displayName: `[${processTable}] ${c.displayName || c.columnName}`, + })) + ); + } + } + setColumns(cols); + } catch { + /* ignore */ + } finally { + setLoading(false); + } + }; + loadAll(); + }, [tableName, processTable]); + + const selected = columns.find((c) => c.columnName === value); + + return ( + + + + + + + + + + 없음 + + + {columns.map((c) => ( + { + onChange(c.columnName); + setOpen(false); + }} + className="text-xs" + > + + {c.displayName || c.columnName} + + ))} + + + + + + ); +} + +interface ConfigPanelProps { + config: Partial; + onChange: (config: Partial) => void; +} + +export function ItemRoutingConfigPanel({ + config: configProp, + onChange, +}: ConfigPanelProps) { + const config: ItemRoutingConfig = { + ...defaultConfig, + ...configProp, + dataSource: { ...defaultConfig.dataSource, ...configProp?.dataSource }, + modals: { ...defaultConfig.modals, ...configProp?.modals }, + processColumns: configProp?.processColumns?.length + ? configProp.processColumns + : defaultConfig.processColumns, + }; + + const [allTables, setAllTables] = useState([]); + const [tablesLoading, setTablesLoading] = useState(false); + + useEffect(() => { + const load = async () => { + setTablesLoading(true); + try { + const { tableManagementApi } = await import( + "@/lib/api/tableManagement" + ); + const res = await tableManagementApi.getTableList(); + if (res.success && res.data) { + setAllTables(res.data); + } + } catch { + /* ignore */ + } finally { + setTablesLoading(false); + } + }; + load(); + }, []); + + const update = (partial: Partial) => { + onChange({ ...configProp, ...partial }); + }; + + const updateDataSource = (field: string, value: string) => { + update({ dataSource: { ...config.dataSource, [field]: value } }); + }; + + const updateModals = (field: string, value: number | undefined) => { + update({ modals: { ...config.modals, [field]: value } }); + }; + + // 컬럼 관리 + const addColumn = () => { + update({ + processColumns: [ + ...config.processColumns, + { name: "", label: "새 컬럼", width: 100 }, + ], + }); + }; + + const removeColumn = (idx: number) => { + update({ + processColumns: config.processColumns.filter((_, i) => i !== idx), + }); + }; + + const updateColumn = ( + idx: number, + field: keyof ProcessColumnDef, + value: any + ) => { + const next = [...config.processColumns]; + next[idx] = { ...next[idx], [field]: value }; + update({ processColumns: next }); + }; + + return ( +
+

품목별 라우팅 설정

+ + {/* 데이터 소스 설정 */} +
+

+ 데이터 소스 +

+ +
+ + updateDataSource("itemTable", v)} + tables={allTables} + loading={tablesLoading} + /> +
+
+
+ + updateDataSource("itemNameColumn", v)} + tableName={config.dataSource.itemTable} + label="품목명" + /> +
+
+ + updateDataSource("itemCodeColumn", v)} + tableName={config.dataSource.itemTable} + label="품목코드" + /> +
+
+ +
+ + updateDataSource("routingVersionTable", v)} + tables={allTables} + loading={tablesLoading} + /> +
+
+
+ + updateDataSource("routingVersionFkColumn", v)} + tableName={config.dataSource.routingVersionTable} + label="FK 컬럼" + /> +
+
+ + + updateDataSource("routingVersionNameColumn", v) + } + tableName={config.dataSource.routingVersionTable} + label="버전명" + /> +
+
+ +
+ + updateDataSource("routingDetailTable", v)} + tables={allTables} + loading={tablesLoading} + /> +
+
+ + updateDataSource("routingDetailFkColumn", v)} + tableName={config.dataSource.routingDetailTable} + label="FK 컬럼" + /> +
+ +
+ + updateDataSource("processTable", v)} + tables={allTables} + loading={tablesLoading} + /> +
+
+
+ + updateDataSource("processNameColumn", v)} + tableName={config.dataSource.processTable} + label="공정명" + /> +
+
+ + updateDataSource("processCodeColumn", v)} + tableName={config.dataSource.processTable} + label="공정코드" + /> +
+
+
+ + {/* 모달 설정 */} +
+

모달 연동

+ +
+ + updateModals("versionAddScreenId", v)} + /> +
+
+ + updateModals("processAddScreenId", v)} + /> +
+
+ + updateModals("processEditScreenId", v)} + /> +
+
+ + {/* 공정 테이블 컬럼 설정 */} +
+
+

+ 공정 테이블 컬럼 +

+ +
+ +
+ {config.processColumns.map((col, idx) => ( +
+ updateColumn(idx, "name", v)} + tableName={config.dataSource.routingDetailTable} + processTable={config.dataSource.processTable} + /> + updateColumn(idx, "label", e.target.value)} + className="h-7 flex-1 text-[10px]" + placeholder="표시명" + /> + + updateColumn( + idx, + "width", + e.target.value ? Number(e.target.value) : undefined + ) + } + className="h-7 w-14 text-[10px]" + placeholder="너비" + /> + +
+ ))} +
+
+ + {/* UI 설정 */} +
+

UI 설정

+ +
+ + update({ splitRatio: Number(e.target.value) })} + min={20} + max={60} + className="mt-1 h-8 w-20 text-xs" + /> +
+ +
+ + update({ leftPanelTitle: e.target.value })} + className="mt-1 h-8 text-xs" + /> +
+
+ + update({ rightPanelTitle: e.target.value })} + className="mt-1 h-8 text-xs" + /> +
+ +
+ + update({ versionAddButtonText: e.target.value })} + className="mt-1 h-8 text-xs" + /> +
+
+ + update({ processAddButtonText: e.target.value })} + className="mt-1 h-8 text-xs" + /> +
+ +
+ update({ autoSelectFirstVersion: v })} + /> + +
+ +
+ update({ readonly: v })} + /> + +
+
+
+ ); +} diff --git a/frontend/lib/registry/components/v2-item-routing/ItemRoutingRenderer.tsx b/frontend/lib/registry/components/v2-item-routing/ItemRoutingRenderer.tsx new file mode 100644 index 00000000..7a9fa624 --- /dev/null +++ b/frontend/lib/registry/components/v2-item-routing/ItemRoutingRenderer.tsx @@ -0,0 +1,32 @@ +"use client"; + +import React from "react"; +import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; +import { V2ItemRoutingDefinition } from "./index"; +import { ItemRoutingComponent } from "./ItemRoutingComponent"; + +export class ItemRoutingRenderer extends AutoRegisteringComponentRenderer { + static componentDefinition = V2ItemRoutingDefinition; + + render(): React.ReactElement { + const { formData, isPreview, config, tableName } = this.props as Record< + string, + unknown + >; + + return ( + } + tableName={tableName as string} + isPreview={isPreview as boolean} + /> + ); + } +} + +ItemRoutingRenderer.registerSelf(); + +if (process.env.NODE_ENV === "development") { + ItemRoutingRenderer.enableHotReload(); +} diff --git a/frontend/lib/registry/components/v2-item-routing/config.ts b/frontend/lib/registry/components/v2-item-routing/config.ts new file mode 100644 index 00000000..a84ff23e --- /dev/null +++ b/frontend/lib/registry/components/v2-item-routing/config.ts @@ -0,0 +1,38 @@ +import { ItemRoutingConfig } from "./types"; + +export const defaultConfig: ItemRoutingConfig = { + dataSource: { + itemTable: "item_info", + itemNameColumn: "item_name", + itemCodeColumn: "item_number", + routingVersionTable: "item_routing_version", + routingVersionFkColumn: "item_code", + routingVersionNameColumn: "version_name", + routingDetailTable: "item_routing_detail", + routingDetailFkColumn: "routing_version_id", + processTable: "process_mng", + processNameColumn: "process_name", + processCodeColumn: "process_code", + }, + modals: { + versionAddScreenId: 1613, + processAddScreenId: 1614, + processEditScreenId: 1615, + }, + processColumns: [ + { name: "seq_no", label: "순서", width: 60, align: "center" }, + { name: "process_code", label: "공정코드", width: 120 }, + { name: "work_type", label: "작업유형", width: 100 }, + { name: "standard_time", label: "표준시간(분)", width: 100, align: "right" }, + { name: "is_required", label: "필수여부", width: 80, align: "center" }, + { name: "is_fixed_order", label: "순서고정", width: 80, align: "center" }, + { name: "outsource_supplier", label: "외주업체", width: 120 }, + ], + splitRatio: 40, + leftPanelTitle: "품목 목록", + rightPanelTitle: "공정 순서", + readonly: false, + autoSelectFirstVersion: true, + versionAddButtonText: "+ 라우팅 버전 추가", + processAddButtonText: "+ 공정 추가", +}; diff --git a/frontend/lib/registry/components/v2-item-routing/hooks/useItemRouting.ts b/frontend/lib/registry/components/v2-item-routing/hooks/useItemRouting.ts new file mode 100644 index 00000000..c53dd3a9 --- /dev/null +++ b/frontend/lib/registry/components/v2-item-routing/hooks/useItemRouting.ts @@ -0,0 +1,239 @@ +"use client"; + +import { useState, useCallback, useEffect, useMemo, useRef } from "react"; +import { apiClient } from "@/lib/api/client"; +import { ItemRoutingConfig, ItemData, RoutingVersionData, RoutingDetailData } from "../types"; +import { defaultConfig } from "../config"; + +const API_BASE = "/process-work-standard"; + +export function useItemRouting(configPartial: Partial) { + const configKey = useMemo( + () => JSON.stringify(configPartial), + [configPartial] + ); + + const config: ItemRoutingConfig = useMemo(() => ({ + ...defaultConfig, + ...configPartial, + dataSource: { ...defaultConfig.dataSource, ...configPartial?.dataSource }, + modals: { ...defaultConfig.modals, ...configPartial?.modals }, + processColumns: configPartial?.processColumns?.length + ? configPartial.processColumns + : defaultConfig.processColumns, + }), [configKey]); + + const configRef = useRef(config); + configRef.current = config; + + const [items, setItems] = useState([]); + const [versions, setVersions] = useState([]); + const [details, setDetails] = useState([]); + const [loading, setLoading] = useState(false); + + // 선택 상태 + const [selectedItemCode, setSelectedItemCode] = useState(null); + const [selectedItemName, setSelectedItemName] = useState(null); + const [selectedVersionId, setSelectedVersionId] = useState(null); + + // 품목 목록 조회 + const fetchItems = useCallback( + async (search?: string) => { + try { + setLoading(true); + const ds = configRef.current.dataSource; + const params = new URLSearchParams({ + tableName: ds.itemTable, + nameColumn: ds.itemNameColumn, + codeColumn: ds.itemCodeColumn, + routingTable: ds.routingVersionTable, + routingFkColumn: ds.routingVersionFkColumn, + ...(search ? { search } : {}), + }); + const res = await apiClient.get(`${API_BASE}/items?${params}`); + if (res.data?.success) { + setItems(res.data.data || []); + } + } catch (err) { + console.error("품목 조회 실패", err); + } finally { + setLoading(false); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [configKey] + ); + + // 라우팅 버전 목록 조회 + const fetchVersions = useCallback( + async (itemCode: string) => { + try { + const ds = configRef.current.dataSource; + const params = new URLSearchParams({ + routingVersionTable: ds.routingVersionTable, + routingDetailTable: ds.routingDetailTable, + routingFkColumn: ds.routingVersionFkColumn, + processTable: ds.processTable, + processNameColumn: ds.processNameColumn, + processCodeColumn: ds.processCodeColumn, + }); + const res = await apiClient.get( + `${API_BASE}/items/${encodeURIComponent(itemCode)}/routings?${params}` + ); + if (res.data?.success) { + const routingData = res.data.data || []; + setVersions(routingData); + return routingData; + } + } catch (err) { + console.error("라우팅 버전 조회 실패", err); + } + return []; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [configKey] + ); + + // 공정 상세 목록 조회 (특정 버전의 공정들) + const fetchDetails = useCallback( + async (versionId: string) => { + try { + setLoading(true); + const ds = configRef.current.dataSource; + const res = await apiClient.get("/table-data/entity-join-api/data-with-joins", { + params: { + tableName: ds.routingDetailTable, + searchConditions: JSON.stringify({ + [ds.routingDetailFkColumn]: { + value: versionId, + operator: "equals", + }, + }), + sortColumn: "seq_no", + sortDirection: "ASC", + }, + }); + if (res.data?.success) { + setDetails(res.data.data || []); + } + } catch (err) { + console.error("공정 상세 조회 실패", err); + } finally { + setLoading(false); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [configKey] + ); + + // 품목 선택 + const selectItem = useCallback( + async (itemCode: string, itemName: string) => { + setSelectedItemCode(itemCode); + setSelectedItemName(itemName); + setSelectedVersionId(null); + setDetails([]); + + const versionList = await fetchVersions(itemCode); + + // 첫번째 버전 자동 선택 + if (config.autoSelectFirstVersion && versionList.length > 0) { + const firstVersion = versionList[0]; + setSelectedVersionId(firstVersion.id); + await fetchDetails(firstVersion.id); + } + }, + [fetchVersions, fetchDetails, config.autoSelectFirstVersion] + ); + + // 버전 선택 + const selectVersion = useCallback( + async (versionId: string) => { + setSelectedVersionId(versionId); + await fetchDetails(versionId); + }, + [fetchDetails] + ); + + // 모달에서 데이터 변경 후 새로고침 + const refreshVersions = useCallback(async () => { + if (selectedItemCode) { + const versionList = await fetchVersions(selectedItemCode); + if (selectedVersionId) { + await fetchDetails(selectedVersionId); + } else if (versionList.length > 0) { + const lastVersion = versionList[versionList.length - 1]; + setSelectedVersionId(lastVersion.id); + await fetchDetails(lastVersion.id); + } + } + }, [selectedItemCode, selectedVersionId, fetchVersions, fetchDetails]); + + const refreshDetails = useCallback(async () => { + if (selectedVersionId) { + await fetchDetails(selectedVersionId); + } + }, [selectedVersionId, fetchDetails]); + + // 공정 삭제 + const deleteDetail = useCallback( + async (detailId: string) => { + try { + const ds = configRef.current.dataSource; + const res = await apiClient.delete( + `/table-data/${ds.routingDetailTable}/${detailId}` + ); + if (res.data?.success) { + await refreshDetails(); + return true; + } + } catch (err) { + console.error("공정 삭제 실패", err); + } + return false; + }, + [refreshDetails] + ); + + // 버전 삭제 + const deleteVersion = useCallback( + async (versionId: string) => { + try { + const ds = configRef.current.dataSource; + const res = await apiClient.delete( + `/table-data/${ds.routingVersionTable}/${versionId}` + ); + if (res.data?.success) { + if (selectedVersionId === versionId) { + setSelectedVersionId(null); + setDetails([]); + } + await refreshVersions(); + return true; + } + } catch (err) { + console.error("버전 삭제 실패", err); + } + return false; + }, + [selectedVersionId, refreshVersions] + ); + + return { + config, + items, + versions, + details, + loading, + selectedItemCode, + selectedItemName, + selectedVersionId, + fetchItems, + selectItem, + selectVersion, + refreshVersions, + refreshDetails, + deleteDetail, + deleteVersion, + }; +} diff --git a/frontend/lib/registry/components/v2-item-routing/index.ts b/frontend/lib/registry/components/v2-item-routing/index.ts new file mode 100644 index 00000000..1ccd3c7a --- /dev/null +++ b/frontend/lib/registry/components/v2-item-routing/index.ts @@ -0,0 +1,57 @@ +"use client"; + +import React from "react"; +import { createComponentDefinition } from "../../utils/createComponentDefinition"; +import { ComponentCategory } from "@/types/component"; +import { ItemRoutingComponent } from "./ItemRoutingComponent"; +import { ItemRoutingConfigPanel } from "./ItemRoutingConfigPanel"; +import { defaultConfig } from "./config"; + +export const V2ItemRoutingDefinition = createComponentDefinition({ + id: "v2-item-routing", + name: "품목별 라우팅", + nameEng: "Item Routing", + description: "품목별 라우팅 버전과 공정 순서를 관리하는 3단계 계층 컴포넌트", + category: ComponentCategory.INPUT, + webType: "component", + component: ItemRoutingComponent, + defaultConfig: defaultConfig, + defaultSize: { + width: 1400, + height: 800, + gridColumnSpan: "12", + }, + configPanel: ItemRoutingConfigPanel, + icon: "ListOrdered", + tags: ["라우팅", "공정", "품목", "버전", "제조", "생산"], + version: "1.0.0", + author: "개발팀", + documentation: ` +품목별 라우팅 버전과 공정 순서를 관리하는 전용 컴포넌트입니다. + +## 주요 기능 +- 좌측: 품목 목록 검색 및 선택 +- 우측 상단: 라우팅 버전 선택 (Badge 버튼) 및 추가/삭제 +- 우측 하단: 선택된 버전의 공정 순서 테이블 (추가/수정/삭제) +- 기존 모달 화면 재활용 (1613, 1614, 1615) + +## 커스터마이징 +- 데이터 소스 테이블/컬럼 변경 가능 +- 모달 화면 ID 변경 가능 +- 공정 테이블 컬럼 추가/삭제 가능 +- 좌우 분할 비율, 패널 제목, 버튼 텍스트 변경 가능 +- 읽기 전용 모드 지원 + `, +}); + +export type { + ItemRoutingConfig, + ItemRoutingComponentProps, + ItemRoutingDataSource, + ItemRoutingModals, + ProcessColumnDef, +} from "./types"; + +export { ItemRoutingComponent } from "./ItemRoutingComponent"; +export { ItemRoutingRenderer } from "./ItemRoutingRenderer"; +export { ItemRoutingConfigPanel } from "./ItemRoutingConfigPanel"; diff --git a/frontend/lib/registry/components/v2-item-routing/types.ts b/frontend/lib/registry/components/v2-item-routing/types.ts new file mode 100644 index 00000000..e5b1aa38 --- /dev/null +++ b/frontend/lib/registry/components/v2-item-routing/types.ts @@ -0,0 +1,77 @@ +/** + * 품목별 라우팅 관리 컴포넌트 타입 정의 + * + * 3단계 계층: item_info → item_routing_version → item_routing_detail + */ + +// 데이터 소스 설정 +export interface ItemRoutingDataSource { + itemTable: string; + itemNameColumn: string; + itemCodeColumn: string; + routingVersionTable: string; + routingVersionFkColumn: string; // item_routing_version에서 item_code를 가리키는 FK + routingVersionNameColumn: string; + routingDetailTable: string; + routingDetailFkColumn: string; // item_routing_detail에서 routing_version_id를 가리키는 FK + processTable: string; + processNameColumn: string; + processCodeColumn: string; +} + +// 모달 연동 설정 +export interface ItemRoutingModals { + versionAddScreenId?: number; + processAddScreenId?: number; + processEditScreenId?: number; +} + +// 공정 테이블 컬럼 정의 +export interface ProcessColumnDef { + name: string; + label: string; + width?: number; + align?: "left" | "center" | "right"; +} + +// 전체 Config +export interface ItemRoutingConfig { + dataSource: ItemRoutingDataSource; + modals: ItemRoutingModals; + processColumns: ProcessColumnDef[]; + splitRatio?: number; + leftPanelTitle?: string; + rightPanelTitle?: string; + readonly?: boolean; + autoSelectFirstVersion?: boolean; + versionAddButtonText?: string; + processAddButtonText?: string; +} + +// 컴포넌트 Props +export interface ItemRoutingComponentProps { + config: Partial; + formData?: Record; + isPreview?: boolean; + tableName?: string; +} + +// 데이터 모델 +export interface ItemData { + id: string; + [key: string]: any; +} + +export interface RoutingVersionData { + id: string; + version_name: string; + [key: string]: any; +} + +export interface RoutingDetailData { + id: string; + routing_version_id: string; + seq_no: string; + process_code: string; + [key: string]: any; +} diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx index f56b0fb3..3458c1df 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx @@ -1241,7 +1241,7 @@ export const SplitPanelLayoutComponent: React.FC const searchConditions: Record = {}; keys?.forEach((key: any) => { if (key.leftColumn && key.rightColumn && originalItem[key.leftColumn] !== undefined) { - searchConditions[key.rightColumn] = originalItem[key.leftColumn]; + searchConditions[key.rightColumn] = { value: originalItem[key.leftColumn], operator: "equals" }; } }); @@ -1271,11 +1271,11 @@ export const SplitPanelLayoutComponent: React.FC // 복합키: 여러 조건으로 필터링 const { entityJoinApi } = await import("@/lib/api/entityJoin"); - // 복합키 조건 생성 + // 복합키 조건 생성 (FK 필터링이므로 equals 연산자 사용) const searchConditions: Record = {}; keys.forEach((key) => { if (key.leftColumn && key.rightColumn && leftItem[key.leftColumn] !== undefined) { - searchConditions[key.rightColumn] = leftItem[key.leftColumn]; + searchConditions[key.rightColumn] = { value: leftItem[key.leftColumn], operator: "equals" }; } }); @@ -2035,20 +2035,47 @@ export const SplitPanelLayoutComponent: React.FC : (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.addButton; if (addButtonConfig?.mode === "modal" && addButtonConfig?.modalScreenId) { - // 커스텀 모달 화면 열기 + if (!selectedLeftItem) { + toast({ + title: "항목을 선택해주세요", + description: "좌측 패널에서 항목을 먼저 선택한 후 추가해주세요.", + variant: "destructive", + }); + return; + } + const currentTableName = activeTabIndex === 0 ? componentConfig.rightPanel?.tableName || "" : (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.tableName || ""; - // 좌측 선택 데이터를 modalDataStore에 저장 (추가 화면에서 참조 가능) + // 좌측 선택 데이터를 modalDataStore에 저장 if (selectedLeftItem && componentConfig.leftPanel?.tableName) { import("@/stores/modalDataStore").then(({ useModalDataStore }) => { useModalDataStore.getState().setData(componentConfig.leftPanel!.tableName!, [selectedLeftItem]); }); } - // ScreenModal 열기 이벤트 발생 + // relation.keys에서 FK 데이터 추출 + const parentData: Record = {}; + const relation = activeTabIndex === 0 + ? componentConfig.rightPanel?.relation + : (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.relation; + + if (relation?.keys && Array.isArray(relation.keys)) { + for (const key of relation.keys) { + if (key.leftColumn && key.rightColumn && selectedLeftItem[key.leftColumn] != null) { + parentData[key.rightColumn] = selectedLeftItem[key.leftColumn]; + } + } + } else if (relation) { + const leftColumn = relation.leftColumn; + const rightColumn = relation.foreignKey || relation.rightColumn; + if (leftColumn && rightColumn && selectedLeftItem[leftColumn] != null) { + parentData[rightColumn] = selectedLeftItem[leftColumn]; + } + } + window.dispatchEvent( new CustomEvent("openScreenModal", { detail: { @@ -2056,19 +2083,8 @@ export const SplitPanelLayoutComponent: React.FC urlParams: { mode: "add", tableName: currentTableName, - // 좌측 선택 항목의 연결 키 값 전달 - ...(selectedLeftItem && (() => { - const relation = activeTabIndex === 0 - ? componentConfig.rightPanel?.relation - : (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.relation; - const leftColumn = relation?.keys?.[0]?.leftColumn || relation?.leftColumn; - const rightColumn = relation?.keys?.[0]?.rightColumn || relation?.foreignKey; - if (leftColumn && rightColumn && selectedLeftItem[leftColumn] !== undefined) { - return { [rightColumn]: selectedLeftItem[leftColumn] }; - } - return {}; - })()), }, + splitPanelParentData: parentData, }, }), ); @@ -2076,6 +2092,7 @@ export const SplitPanelLayoutComponent: React.FC console.log("✅ [SplitPanel] 추가 모달 화면 열기:", { screenId: addButtonConfig.modalScreenId, tableName: currentTableName, + parentData, }); return; }