diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index 320ab74b..430ccfa0 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -922,13 +922,14 @@ export async function addTableData( } // 데이터 추가 - await tableManagementService.addTableData(tableName, data); + const result = await tableManagementService.addTableData(tableName, data); - logger.info(`테이블 데이터 추가 완료: ${tableName}`); + logger.info(`테이블 데이터 추가 완료: ${tableName}, id: ${result.insertedId}`); - const response: ApiResponse = { + const response: ApiResponse<{ id: string | null }> = { success: true, message: "테이블 데이터를 성공적으로 추가했습니다.", + data: { id: result.insertedId }, }; res.status(201).json(response); diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 6e0f3944..252c5a89 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -2438,7 +2438,7 @@ export class TableManagementService { async addTableData( tableName: string, data: Record - ): Promise<{ skippedColumns: string[]; savedColumns: string[] }> { + ): Promise<{ skippedColumns: string[]; savedColumns: string[]; insertedId: string | null }> { try { logger.info(`=== 테이블 데이터 추가 시작: ${tableName} ===`); logger.info(`추가할 데이터:`, data); @@ -2551,19 +2551,21 @@ export class TableManagementService { const insertQuery = ` INSERT INTO "${tableName}" (${columnNames}) VALUES (${placeholders}) + RETURNING id `; logger.info(`실행할 쿼리: ${insertQuery}`); logger.info(`쿼리 파라미터:`, values); - await query(insertQuery, values); + const insertResult = await query(insertQuery, values) as any[]; + const insertedId = insertResult?.[0]?.id ?? null; - logger.info(`테이블 데이터 추가 완료: ${tableName}`); + logger.info(`테이블 데이터 추가 완료: ${tableName}, id: ${insertedId}`); - // 무시된 컬럼과 저장된 컬럼 정보 반환 return { skippedColumns, savedColumns: existingColumns, + insertedId, }; } catch (error) { logger.error(`테이블 데이터 추가 오류: ${tableName}`, error); diff --git a/frontend/components/screen/panels/V2PropertiesPanel.tsx b/frontend/components/screen/panels/V2PropertiesPanel.tsx index cb0ca751..2999ed74 100644 --- a/frontend/components/screen/panels/V2PropertiesPanel.tsx +++ b/frontend/components/screen/panels/V2PropertiesPanel.tsx @@ -217,6 +217,7 @@ export const V2PropertiesPanel: React.FC = ({ "v2-biz": require("@/components/v2/config-panels/V2BizConfigPanel").V2BizConfigPanel, "v2-hierarchy": require("@/components/v2/config-panels/V2HierarchyConfigPanel").V2HierarchyConfigPanel, "v2-bom-item-editor": require("@/components/v2/config-panels/V2BomItemEditorConfigPanel").V2BomItemEditorConfigPanel, + "v2-bom-tree": require("@/components/v2/config-panels/V2BomTreeConfigPanel").V2BomTreeConfigPanel, }; const V2ConfigPanel = v2ConfigPanels[componentId]; @@ -240,7 +241,7 @@ export const V2PropertiesPanel: React.FC = ({ if (componentId === "v2-list") { extraProps.currentTableName = currentTableName; } - if (componentId === "v2-bom-item-editor") { + if (componentId === "v2-bom-item-editor" || componentId === "v2-bom-tree") { extraProps.currentTableName = currentTableName; extraProps.screenTableName = selectedComponent.tableName || currentTable?.tableName || currentTableName; } diff --git a/frontend/components/v2/config-panels/V2BomTreeConfigPanel.tsx b/frontend/components/v2/config-panels/V2BomTreeConfigPanel.tsx new file mode 100644 index 00000000..499d0a93 --- /dev/null +++ b/frontend/components/v2/config-panels/V2BomTreeConfigPanel.tsx @@ -0,0 +1,935 @@ +"use client"; + +/** + * BOM 트리 뷰 설정 패널 + * + * V2BomItemEditorConfigPanel 구조 기반: + * - 기본 탭: 디테일 테이블 + 엔티티 선택 + 트리 설정 + * - 컬럼 탭: 소스 표시 컬럼 + 디테일 컬럼 + 선택된 컬럼 상세 + */ + +import React, { useState, useEffect, useMemo, useCallback } from "react"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Separator } from "@/components/ui/separator"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + Database, + Link2, + Trash2, + GripVertical, + ArrowRight, + ChevronDown, + ChevronRight, + Eye, + EyeOff, + Check, + ChevronsUpDown, + GitBranch, +} from "lucide-react"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { tableTypeApi } from "@/lib/api/screen"; +import { tableManagementApi } from "@/lib/api/tableManagement"; +import { cn } from "@/lib/utils"; + +interface TableRelation { + tableName: string; + tableLabel: string; + foreignKeyColumn: string; + referenceColumn: string; +} + +interface ColumnOption { + columnName: string; + displayName: string; + inputType?: string; + detailSettings?: { + codeGroup?: string; + referenceTable?: string; + referenceColumn?: string; + displayColumn?: string; + format?: string; + }; +} + +interface EntityColumnOption { + columnName: string; + displayName: string; + referenceTable?: string; + referenceColumn?: string; + displayColumn?: string; +} + +interface TreeColumnConfig { + key: string; + title: string; + width?: string; + visible?: boolean; + hidden?: boolean; + isSourceDisplay?: boolean; +} + +interface BomTreeConfig { + detailTable?: string; + foreignKey?: string; + parentKey?: string; + + dataSource?: { + sourceTable?: string; + foreignKey?: string; + referenceKey?: string; + displayColumn?: string; + }; + + columns: TreeColumnConfig[]; + + features?: { + showExpandAll?: boolean; + showHeader?: boolean; + showQuantity?: boolean; + showLossRate?: boolean; + }; +} + +interface V2BomTreeConfigPanelProps { + config: BomTreeConfig; + onChange: (config: BomTreeConfig) => void; + currentTableName?: string; + screenTableName?: string; +} + +export function V2BomTreeConfigPanel({ + config: propConfig, + onChange, + currentTableName: propCurrentTableName, + screenTableName, +}: V2BomTreeConfigPanelProps) { + const currentTableName = screenTableName || propCurrentTableName; + + const config: BomTreeConfig = useMemo( + () => ({ + columns: [], + ...propConfig, + dataSource: { ...propConfig?.dataSource }, + features: { + showExpandAll: true, + showHeader: true, + showQuantity: true, + showLossRate: true, + ...propConfig?.features, + }, + }), + [propConfig], + ); + + const [detailTableColumns, setDetailTableColumns] = useState([]); + const [entityColumns, setEntityColumns] = useState([]); + const [sourceTableColumns, setSourceTableColumns] = useState([]); + const [allTables, setAllTables] = useState<{ tableName: string; displayName: string }[]>([]); + const [relatedTables, setRelatedTables] = useState([]); + const [loadingColumns, setLoadingColumns] = useState(false); + const [loadingSourceColumns, setLoadingSourceColumns] = useState(false); + const [loadingTables, setLoadingTables] = useState(false); + const [loadingRelations, setLoadingRelations] = useState(false); + const [tableComboboxOpen, setTableComboboxOpen] = useState(false); + const [expandedColumn, setExpandedColumn] = useState(null); + + const updateConfig = useCallback( + (updates: Partial) => { + onChange({ ...config, ...updates }); + }, + [config, onChange], + ); + + const updateFeatures = useCallback( + (field: string, value: any) => { + updateConfig({ features: { ...config.features, [field]: value } }); + }, + [config.features, updateConfig], + ); + + // 전체 테이블 목록 로드 + useEffect(() => { + const loadTables = async () => { + setLoadingTables(true); + try { + const response = await tableManagementApi.getTableList(); + if (response.success && response.data) { + setAllTables( + response.data.map((t: any) => ({ + tableName: t.tableName || t.table_name, + displayName: t.displayName || t.table_label || t.tableName || t.table_name, + })), + ); + } + } catch (error) { + console.error("테이블 목록 로드 실패:", error); + } finally { + setLoadingTables(false); + } + }; + loadTables(); + }, []); + + // 연관 테이블 로드 + useEffect(() => { + const loadRelatedTables = async () => { + const baseTable = currentTableName; + if (!baseTable) { + setRelatedTables([]); + return; + } + setLoadingRelations(true); + try { + const { apiClient } = await import("@/lib/api/client"); + const response = await apiClient.get( + `/table-management/columns/${baseTable}/referenced-by`, + ); + if (response.data.success && response.data.data) { + setRelatedTables( + response.data.data.map((rel: any) => ({ + tableName: rel.tableName || rel.table_name, + tableLabel: rel.tableLabel || rel.table_label || rel.tableName || rel.table_name, + foreignKeyColumn: rel.columnName || rel.column_name, + referenceColumn: rel.referenceColumn || rel.reference_column || "id", + })), + ); + } + } catch (error) { + console.error("연관 테이블 로드 실패:", error); + setRelatedTables([]); + } finally { + setLoadingRelations(false); + } + }; + loadRelatedTables(); + }, [currentTableName]); + + // 디테일 테이블 선택 + const handleDetailTableSelect = useCallback( + (tableName: string) => { + const relation = relatedTables.find((r) => r.tableName === tableName); + updateConfig({ + detailTable: tableName, + foreignKey: relation?.foreignKeyColumn || config.foreignKey, + }); + }, + [relatedTables, config.foreignKey, updateConfig], + ); + + // 디테일 테이블 컬럼 로드 + useEffect(() => { + const loadColumns = async () => { + if (!config.detailTable) { + setDetailTableColumns([]); + setEntityColumns([]); + return; + } + setLoadingColumns(true); + try { + const columnData = await tableTypeApi.getColumns(config.detailTable); + const cols: ColumnOption[] = []; + const entityCols: EntityColumnOption[] = []; + + for (const c of columnData) { + let detailSettings: any = null; + if (c.detailSettings) { + try { + detailSettings = + typeof c.detailSettings === "string" ? JSON.parse(c.detailSettings) : c.detailSettings; + } catch { + // ignore + } + } + + const col: ColumnOption = { + columnName: c.columnName || c.column_name, + displayName: c.displayName || c.columnLabel || c.columnName || c.column_name, + inputType: c.inputType || c.input_type, + detailSettings: detailSettings + ? { + codeGroup: detailSettings.codeGroup, + referenceTable: detailSettings.referenceTable, + referenceColumn: detailSettings.referenceColumn, + displayColumn: detailSettings.displayColumn, + format: detailSettings.format, + } + : undefined, + }; + cols.push(col); + + if (col.inputType === "entity") { + const refTable = detailSettings?.referenceTable || c.referenceTable; + if (refTable) { + entityCols.push({ + columnName: col.columnName, + displayName: col.displayName, + referenceTable: refTable, + referenceColumn: detailSettings?.referenceColumn || c.referenceColumn || "id", + displayColumn: detailSettings?.displayColumn || c.displayColumn, + }); + } + } + } + + setDetailTableColumns(cols); + setEntityColumns(entityCols); + } catch (error) { + console.error("컬럼 로드 실패:", error); + setDetailTableColumns([]); + setEntityColumns([]); + } finally { + setLoadingColumns(false); + } + }; + loadColumns(); + }, [config.detailTable]); + + // 소스(엔티티) 테이블 컬럼 로드 + useEffect(() => { + const loadSourceColumns = async () => { + const sourceTable = config.dataSource?.sourceTable; + if (!sourceTable) { + setSourceTableColumns([]); + return; + } + setLoadingSourceColumns(true); + try { + const columnData = await tableTypeApi.getColumns(sourceTable); + setSourceTableColumns( + columnData.map((c: any) => ({ + columnName: c.columnName || c.column_name, + displayName: c.displayName || c.columnLabel || c.columnName || c.column_name, + inputType: c.inputType || c.input_type, + })), + ); + } catch (error) { + console.error("소스 테이블 컬럼 로드 실패:", error); + setSourceTableColumns([]); + } finally { + setLoadingSourceColumns(false); + } + }; + loadSourceColumns(); + }, [config.dataSource?.sourceTable]); + + // 엔티티 컬럼 선택 시 소스 테이블 자동 설정 + const handleEntityColumnSelect = (columnName: string) => { + const selectedEntity = entityColumns.find((c) => c.columnName === columnName); + if (selectedEntity) { + updateConfig({ + dataSource: { + ...config.dataSource, + sourceTable: selectedEntity.referenceTable || "", + foreignKey: selectedEntity.columnName, + referenceKey: selectedEntity.referenceColumn || "id", + displayColumn: selectedEntity.displayColumn, + }, + }); + } + }; + + // 컬럼 토글 + const toggleDetailColumn = (column: ColumnOption) => { + const exists = config.columns.findIndex((c) => c.key === column.columnName && !c.isSourceDisplay); + if (exists >= 0) { + updateConfig({ columns: config.columns.filter((c) => c.key !== column.columnName || c.isSourceDisplay) }); + } else { + const newCol: TreeColumnConfig = { + key: column.columnName, + title: column.displayName, + width: "auto", + visible: true, + }; + updateConfig({ columns: [...config.columns, newCol] }); + } + }; + + const toggleSourceDisplayColumn = (column: ColumnOption) => { + const exists = config.columns.some((c) => c.key === column.columnName && c.isSourceDisplay); + if (exists) { + updateConfig({ columns: config.columns.filter((c) => c.key !== column.columnName) }); + } else { + const newCol: TreeColumnConfig = { + key: column.columnName, + title: column.displayName, + width: "auto", + visible: true, + isSourceDisplay: true, + }; + updateConfig({ columns: [...config.columns, newCol] }); + } + }; + + const isColumnAdded = (columnName: string) => + config.columns.some((c) => c.key === columnName && !c.isSourceDisplay); + + const isSourceColumnSelected = (columnName: string) => + config.columns.some((c) => c.key === columnName && c.isSourceDisplay); + + const updateColumnProp = (key: string, field: keyof TreeColumnConfig, value: any) => { + updateConfig({ + columns: config.columns.map((col) => (col.key === key ? { ...col, [field]: value } : col)), + }); + }; + + // FK/시스템 컬럼 제외한 표시 가능 컬럼 + const displayableColumns = useMemo(() => { + const fkColumn = config.dataSource?.foreignKey; + const systemCols = ["id", "created_at", "updated_at", "created_by", "updated_by", "company_code", "created_date"]; + return detailTableColumns.filter( + (col) => col.columnName !== fkColumn && col.inputType !== "entity" && !systemCols.includes(col.columnName), + ); + }, [detailTableColumns, config.dataSource?.foreignKey]); + + // FK 후보 컬럼 + const fkCandidateColumns = useMemo(() => { + const systemCols = ["created_at", "updated_at", "created_by", "updated_by", "company_code", "created_date"]; + return detailTableColumns.filter((c) => !systemCols.includes(c.columnName)); + }, [detailTableColumns]); + + return ( +
+ + + + 기본 + + + 컬럼 + + + + {/* ─── 기본 설정 탭 ─── */} + + {/* 디테일 테이블 */} +
+ + +
+
+ +
+

+ {config.detailTable + ? allTables.find((t) => t.tableName === config.detailTable)?.displayName || config.detailTable + : "미설정"} +

+ {config.detailTable && config.foreignKey && ( +

+ FK: {config.foreignKey} → {currentTableName || "메인 테이블"}.id +

+ )} +
+
+
+ + + + + + + + + + + 테이블을 찾을 수 없습니다. + + + {relatedTables.length > 0 && ( + + {relatedTables.map((rel) => ( + { + handleDetailTableSelect(rel.tableName); + setTableComboboxOpen(false); + }} + className="text-xs" + > + + + {rel.tableLabel} + + ({rel.foreignKeyColumn}) + + + ))} + + )} + + + {allTables + .filter((t) => !relatedTables.some((r) => r.tableName === t.tableName)) + .map((table) => ( + { + handleDetailTableSelect(table.tableName); + setTableComboboxOpen(false); + }} + className="text-xs" + > + + + {table.displayName} + + ))} + + + + + +
+ + + + {/* 트리 구조 설정 */} +
+
+ + +
+

+ 메인 FK와 부모-자식 계층 FK를 선택하세요 +

+ + {fkCandidateColumns.length > 0 ? ( +
+
+ + +
+
+ + +
+
+ ) : ( +
+

+ {loadingColumns ? "로딩 중..." : "디테일 테이블을 먼저 선택하세요"} +

+
+ )} +
+ + + + {/* 엔티티 선택 (품목 참조) */} +
+ +

+ 트리 노드에 표시할 품목 정보의 소스 엔티티 +

+ + {entityColumns.length > 0 ? ( + + ) : ( +
+

+ {loadingColumns + ? "로딩 중..." + : !config.detailTable + ? "디테일 테이블을 먼저 선택하세요" + : "엔티티 타입 컬럼이 없습니다"} +

+
+ )} + + {config.dataSource?.sourceTable && ( +
+

선택된 엔티티

+
+

참조 테이블: {config.dataSource.sourceTable}

+

FK 컬럼: {config.dataSource.foreignKey}

+
+
+ )} +
+ + + + {/* 표시 옵션 */} +
+ +
+
+ updateFeatures("showExpandAll", !!checked)} + /> + +
+
+ updateFeatures("showHeader", !!checked)} + /> + +
+
+ updateFeatures("showQuantity", !!checked)} + /> + +
+
+ updateFeatures("showLossRate", !!checked)} + /> + +
+
+
+ + {/* 메인 화면 테이블 참고 */} + {currentTableName && ( + <> + +
+ +
+

{currentTableName}

+

+ 컬럼 {detailTableColumns.length}개 / 엔티티 {entityColumns.length}개 +

+
+
+ + )} +
+ + {/* ─── 컬럼 설정 탭 ─── */} + +
+ +

+ 트리 노드에 표시할 소스/디테일 컬럼을 선택하세요 +

+ + {/* 소스 테이블 컬럼 (표시용) */} + {config.dataSource?.sourceTable && ( + <> +
+ + 소스 테이블 ({config.dataSource.sourceTable}) - 표시용 +
+ {loadingSourceColumns ? ( +

로딩 중...

+ ) : sourceTableColumns.length === 0 ? ( +

컬럼 정보가 없습니다

+ ) : ( +
+ {sourceTableColumns.map((column) => ( +
toggleSourceDisplayColumn(column)} + > + toggleSourceDisplayColumn(column)} + className="pointer-events-none h-3.5 w-3.5" + /> + + {column.displayName} + 표시 +
+ ))} +
+ )} + + )} + + {/* 디테일 테이블 컬럼 */} +
+ + 디테일 테이블 ({config.detailTable || "미선택"}) - 직접 컬럼 +
+ {loadingColumns ? ( +

로딩 중...

+ ) : displayableColumns.length === 0 ? ( +

컬럼 정보가 없습니다

+ ) : ( +
+ {displayableColumns.map((column) => ( +
toggleDetailColumn(column)} + > + toggleDetailColumn(column)} + className="pointer-events-none h-3.5 w-3.5" + /> + + {column.displayName} + {column.inputType} +
+ ))} +
+ )} +
+ + {/* 선택된 컬럼 상세 */} + {config.columns.length > 0 && ( + <> + +
+ +
+ {config.columns.map((col, index) => ( +
+
e.dataTransfer.setData("columnIndex", String(index))} + onDragOver={(e) => e.preventDefault()} + onDrop={(e) => { + e.preventDefault(); + const fromIndex = parseInt(e.dataTransfer.getData("columnIndex"), 10); + if (fromIndex !== index) { + const newColumns = [...config.columns]; + const [movedCol] = newColumns.splice(fromIndex, 1); + newColumns.splice(index, 0, movedCol); + updateConfig({ columns: newColumns }); + } + }} + > + + + {!col.isSourceDisplay && ( + + )} + + {col.isSourceDisplay ? ( + + ) : ( + + )} + + updateColumnProp(col.key, "title", e.target.value)} + placeholder="제목" + className="h-6 flex-1 text-xs" + /> + + {!col.isSourceDisplay && ( + + )} + + +
+ + {/* 확장 상세 */} + {!col.isSourceDisplay && expandedColumn === col.key && ( +
+
+ + updateColumnProp(col.key, "width", e.target.value)} + placeholder="auto, 100px, 20%" + className="h-6 text-xs" + /> +
+
+ )} +
+ ))} +
+
+ + )} +
+
+
+ ); +} + +V2BomTreeConfigPanel.displayName = "V2BomTreeConfigPanel"; + +export default V2BomTreeConfigPanel; diff --git a/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx b/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx index 9573472d..4b5e263c 100644 --- a/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx +++ b/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx @@ -1,47 +1,39 @@ "use client"; import React, { useState, useEffect, useCallback, useMemo } from "react"; -import { ChevronRight, ChevronDown, Package, Layers, Box, AlertCircle } from "lucide-react"; +import { + ChevronRight, + ChevronDown, + Package, + Layers, + Box, + AlertCircle, + Expand, + Shrink, + Loader2, +} from "lucide-react"; import { cn } from "@/lib/utils"; import { entityJoinApi } from "@/lib/api/entityJoin"; +import { Button } from "@/components/ui/button"; -/** - * BOM 트리 노드 데이터 - */ interface BomTreeNode { id: string; - bom_id: string; - parent_detail_id: string | null; - seq_no: string; - level: string; - child_item_id: string; - child_item_code: string; - child_item_name: string; - child_item_type: string; - quantity: string; - unit: string; - loss_rate: string; - remark: string; + [key: string]: any; children: BomTreeNode[]; } -/** - * BOM 헤더 정보 - */ interface BomHeaderInfo { id: string; - bom_number: string; - item_code: string; - item_name: string; - item_type: string; - base_qty: string; - unit: string; - version: string; - revision: string; - status: string; - effective_date: string; - expired_date: string; - remark: string; + [key: string]: any; +} + +interface TreeColumnDef { + key: string; + title: string; + width?: string; + visible?: boolean; + hidden?: boolean; + isSourceDisplay?: boolean; } interface BomTreeComponentProps { @@ -54,10 +46,9 @@ interface BomTreeComponentProps { [key: string]: any; } -/** - * BOM 트리 컴포넌트 - * 좌측 패널에서 BOM 헤더 선택 시 계층 구조로 BOM 디테일을 표시 - */ +// 컬럼은 설정 패널에서만 추가 (하드코딩 금지) +const EMPTY_COLUMNS: TreeColumnDef[] = []; + export function BomTreeComponent({ component, formData, @@ -73,18 +64,14 @@ export function BomTreeComponent({ const [selectedNodeId, setSelectedNodeId] = useState(null); const config = component?.componentConfig || {}; + const overrides = component?.overrides || {}; - // 선택된 BOM 헤더에서 bom_id 추출 const selectedBomId = useMemo(() => { - // SplitPanel에서 좌측 선택 시 formData나 selectedRowsData로 전달됨 - if (selectedRowsData && selectedRowsData.length > 0) { - return selectedRowsData[0]?.id; - } + if (selectedRowsData && selectedRowsData.length > 0) return selectedRowsData[0]?.id; if (formData?.id) return formData.id; return null; }, [formData, selectedRowsData]); - // 선택된 BOM 헤더 정보 추출 (조인 필드명 매핑 포함) const selectedHeaderData = useMemo(() => { const raw = selectedRowsData?.[0] || (formData?.id ? formData : null); if (!raw) return null; @@ -96,12 +83,45 @@ export function BomTreeComponent({ } as BomHeaderInfo; }, [formData, selectedRowsData]); - // BOM 디테일 데이터 로드 - const detailTable = config.detailTable || "bom_detail"; - const foreignKey = config.foreignKey || "bom_id"; - const sourceFk = "child_item_id"; + const detailTable = overrides.detailTable || config.detailTable || "bom_detail"; + const foreignKey = overrides.foreignKey || config.foreignKey || "bom_id"; + const parentKey = overrides.parentKey || config.parentKey || "parent_detail_id"; + const sourceFk = config.dataSource?.foreignKey || "child_item_id"; - const loadBomDetails = useCallback(async (bomId: string) => { + const displayColumns = useMemo(() => { + const configured = config.columns as TreeColumnDef[] | undefined; + if (configured && configured.length > 0) return configured.filter((c) => !c.hidden); + return EMPTY_COLUMNS; + }, [config.columns]); + + const features = config.features || {}; + + // ─── 데이터 로드 ─── + + // BOM 헤더 데이터로 가상 0레벨 루트 노드 생성 + const buildVirtualRoot = useCallback((headerData: BomHeaderInfo | null, children: BomTreeNode[]): BomTreeNode | null => { + if (!headerData) return null; + return { + id: `__root_${headerData.id}`, + _isVirtualRoot: true, + level: "0", + child_item_name: headerData.item_name || "", + child_item_code: headerData.item_code || headerData.bom_number || "", + child_item_type: headerData.item_type || "", + item_name: headerData.item_name || "", + item_number: headerData.item_code || "", + quantity: "-", + base_qty: headerData.base_qty || "", + unit: headerData.unit || "", + revision: headerData.revision || "", + loss_rate: "", + process_type: "", + remark: headerData.remark || "", + children, + }; + }, []); + + const loadBomDetails = useCallback(async (bomId: string, headerData: BomHeaderInfo | null) => { if (!bomId) return; setLoading(true); try { @@ -116,72 +136,73 @@ export function BomTreeComponent({ const rows = (result.data || []).map((row: Record) => { const mapped = { ...row }; - // 엔티티 조인 필드 매핑: child_item_id_item_name → child_item_name 등 + for (const key of Object.keys(row)) { + if (key.startsWith(`${sourceFk}_`)) { + const shortKey = key.replace(`${sourceFk}_`, ""); + const aliasKey = `child_${shortKey}`; + if (!mapped[aliasKey]) mapped[aliasKey] = row[key]; + if (!mapped[shortKey]) mapped[shortKey] = row[key]; + } + } mapped.child_item_name = row[`${sourceFk}_item_name`] || row.child_item_name || ""; mapped.child_item_code = row[`${sourceFk}_item_number`] || row.child_item_code || ""; mapped.child_item_type = row[`${sourceFk}_type`] || row[`${sourceFk}_division`] || row.child_item_type || ""; return mapped; }); - const tree = buildTree(rows); - setTreeData(tree); - const firstLevelIds = new Set(tree.map((n: BomTreeNode) => n.id)); - setExpandedNodes(firstLevelIds); + const detailTree = buildTree(rows); + + // BOM 헤더를 가상 0레벨 루트로 삽입 + const virtualRoot = buildVirtualRoot(headerData, detailTree); + if (virtualRoot) { + setTreeData([virtualRoot]); + setExpandedNodes(new Set([virtualRoot.id])); + } else { + setTreeData(detailTree); + const firstLevelIds = new Set(detailTree.map((n: BomTreeNode) => n.id)); + setExpandedNodes(firstLevelIds); + } } catch (error) { console.error("[BomTree] 데이터 로드 실패:", error); } finally { setLoading(false); } - }, [detailTable, foreignKey]); + }, [detailTable, foreignKey, sourceFk, buildVirtualRoot]); - // 평면 데이터 -> 트리 구조 변환 const buildTree = (flatData: any[]): BomTreeNode[] => { const nodeMap = new Map(); const roots: BomTreeNode[] = []; - - // 모든 노드를 맵에 등록 - flatData.forEach((item) => { - nodeMap.set(item.id, { ...item, children: [] }); - }); - - // 부모-자식 관계 설정 + flatData.forEach((item) => nodeMap.set(item.id, { ...item, children: [] })); flatData.forEach((item) => { const node = nodeMap.get(item.id)!; - if (item.parent_detail_id && nodeMap.has(item.parent_detail_id)) { - nodeMap.get(item.parent_detail_id)!.children.push(node); + if (item[parentKey] && nodeMap.has(item[parentKey])) { + nodeMap.get(item[parentKey])!.children.push(node); } else { roots.push(node); } }); - return roots; }; - // 선택된 BOM 변경 시 데이터 로드 useEffect(() => { if (selectedBomId) { setHeaderInfo(selectedHeaderData); - loadBomDetails(selectedBomId); + loadBomDetails(selectedBomId, selectedHeaderData); } else { setHeaderInfo(null); setTreeData([]); } }, [selectedBomId, selectedHeaderData, loadBomDetails]); - // 노드 펼치기/접기 토글 const toggleNode = useCallback((nodeId: string) => { setExpandedNodes((prev) => { const next = new Set(prev); - if (next.has(nodeId)) { - next.delete(nodeId); - } else { - next.add(nodeId); - } + if (next.has(nodeId)) next.delete(nodeId); + else next.add(nodeId); return next; }); }, []); - // 전체 펼치기 const expandAll = useCallback(() => { const allIds = new Set(); const collectIds = (nodes: BomTreeNode[]) => { @@ -194,270 +215,381 @@ export function BomTreeComponent({ setExpandedNodes(allIds); }, [treeData]); - // 전체 접기 - const collapseAll = useCallback(() => { - setExpandedNodes(new Set()); - }, []); + const collapseAll = useCallback(() => setExpandedNodes(new Set()), []); + + // ─── 유틸 ─── - // 품목 구분 라벨 const getItemTypeLabel = (type: string) => { - switch (type) { - case "product": return "제품"; - case "semi": return "반제품"; - case "material": return "원자재"; - case "part": return "부품"; - default: return type || "-"; - } + const map: Record = { product: "제품", semi: "반제품", material: "원자재", part: "부품" }; + return map[type] || type || "-"; }; - // 품목 구분 아이콘 & 색상 - const getItemTypeStyle = (type: string) => { - switch (type) { - case "product": - return { icon: Package, color: "text-blue-600", bg: "bg-blue-50" }; - case "semi": - return { icon: Layers, color: "text-amber-600", bg: "bg-amber-50" }; - case "material": - return { icon: Box, color: "text-emerald-600", bg: "bg-emerald-50" }; - default: - return { icon: Box, color: "text-gray-500", bg: "bg-gray-50" }; - } + const getItemTypeBadge = (type: string) => { + const map: Record = { + product: "bg-blue-50 text-blue-600 ring-blue-200", + semi: "bg-amber-50 text-amber-600 ring-amber-200", + material: "bg-emerald-50 text-emerald-600 ring-emerald-200", + part: "bg-purple-50 text-purple-600 ring-purple-200", + }; + return map[type] || "bg-gray-50 text-gray-500 ring-gray-200"; }; - // 디자인 모드 미리보기 + const getItemIcon = (type: string) => { + const map: Record = { product: Package, semi: Layers }; + return map[type] || Box; + }; + + const getItemIconColor = (type: string) => { + const map: Record = { + product: "text-blue-500", + semi: "text-amber-500", + material: "text-emerald-500", + part: "text-purple-500", + }; + return map[type] || "text-gray-400"; + }; + + // ─── 셀 렌더링 ─── + + const renderCellValue = (node: BomTreeNode, col: TreeColumnDef, depth: number) => { + const value = node[col.key]; + + if (col.key === "child_item_type" || col.key === "item_type") { + const label = getItemTypeLabel(String(value || "")); + return ( + + {label} + + ); + } + + if (col.key === "level") { + return ( + + {value ?? depth} + + ); + } + + if (col.key === "child_item_code") { + return {value || "-"}; + } + + if (col.key === "child_item_name") { + return {value || "-"}; + } + + if (col.key === "quantity" || col.key === "base_qty") { + return ( + + {value != null && value !== "" && value !== "0" ? value : "-"} + + ); + } + + if (col.key === "loss_rate") { + const num = Number(value); + if (!num) return -; + return {value}%; + } + + if (col.key === "revision") { + return ( + + {value != null && value !== "" && value !== "0" ? value : "-"} + + ); + } + + if (col.key === "unit") { + return {value || "-"}; + } + + return {value ?? "-"}; + }; + + // ─── 디자인 모드 ─── + if (isDesignMode) { + const configuredColumns = (config.columns || []).filter((c: TreeColumnDef) => !c.hidden); + return ( -
-
- - BOM 트리 뷰 +
+
+ + BOM 트리 뷰 + {detailTable} + {config.dataSource?.sourceTable && ( + + {config.dataSource.sourceTable} + + )}
-
-
- - - 완제품 A (제품) - 수량: 1 + + {configuredColumns.length === 0 ? ( +
+ +

컬럼 미설정

+

설정 패널 > 컬럼 탭에서 표시할 컬럼을 선택하세요

-
- - - 반제품 B (반제품) - 수량: 2 + ) : ( +
+ + + + + {configuredColumns.map((col: TreeColumnDef) => ( + + ))} + + + + + + {configuredColumns.map((col: TreeColumnDef, i: number) => ( + + ))} + + + + {configuredColumns.map((col: TreeColumnDef, i: number) => ( + + ))} + + +
+ {col.title || col.key} +
+ + + {col.key === "level" ? "0" : col.key.includes("type") ? ( + 제품 + ) : col.key.includes("quantity") || col.key.includes("qty") ? "30" : `예시${i + 1}`} +
+ + + {col.key === "level" ? "1" : col.key.includes("type") ? ( + 반제품 + ) : col.key.includes("quantity") || col.key.includes("qty") ? "3" : `예시${i + 1}`} +
-
- - - 원자재 C (원자재) - 수량: 5 -
-
+ )}
); } - // 선택 안 된 상태 + // ─── 미선택 상태 ─── + if (!selectedBomId) { return ( -
-
-

좌측에서 BOM을 선택하세요

-

선택한 BOM의 구성 정보가 트리로 표시됩니다

+
+
+ +

BOM을 선택해주세요

+

좌측 목록에서 BOM을 선택하면 구성이 표시됩니다

); } + // ─── 트리 평탄화 ─── + + const flattenedRows = useMemo(() => { + const rows: { node: BomTreeNode; depth: number }[] = []; + const traverse = (nodes: BomTreeNode[], depth: number) => { + for (const node of nodes) { + rows.push({ node, depth }); + if (node.children.length > 0 && expandedNodes.has(node.id)) { + traverse(node.children, depth + 1); + } + } + }; + traverse(treeData, 0); + return rows; + }, [treeData, expandedNodes]); + + // ─── 메인 렌더링 ─── + return ( -
+
{/* 헤더 정보 */} - {headerInfo && ( -
-
- -

{headerInfo.item_name || "-"}

- - {headerInfo.bom_number || "-"} - - +
+
- {headerInfo.status === "active" ? "사용" : "미사용"} - -
-
- 품목코드: {headerInfo.item_code || "-"} - 구분: {getItemTypeLabel(headerInfo.item_type)} - 기준수량: {headerInfo.base_qty || "1"} {headerInfo.unit || ""} - 버전: v{headerInfo.version || "1.0"} (차수 {headerInfo.revision || "1"}) + +
+
+
+

+ {headerInfo.item_name || "-"} +

+ + {getItemTypeLabel(headerInfo.item_type)} + + + {headerInfo.status === "active" ? "사용" : "미사용"} + +
+
+ 품목코드 {headerInfo.item_code || "-"} + 기준수량 {headerInfo.base_qty || "1"} + 버전 v{headerInfo.version || "1"} (차수 {headerInfo.revision || "1"}) +
+
)} - {/* 트리 툴바 */} -
- BOM 구성 - - {treeData.length}건 + {/* 툴바 */} +
+ BOM 구성 + + {flattenedRows.length} -
- - -
+ {features.showExpandAll !== false && ( +
+ + +
+ )}
- {/* 트리 컨텐츠 */} -
+ {/* 테이블 */} +
{loading ? ( -
-
로딩 중...
+
+ +
+ ) : displayColumns.length === 0 ? ( +
+ +

표시할 컬럼이 설정되지 않았습니다

+

디자인 모드에서 컬럼을 추가하세요

) : treeData.length === 0 ? ( -
- -

등록된 하위 품목이 없습니다

+
+ +

등록된 하위 품목이 없습니다

) : ( -
- {treeData.map((node) => ( - - ))} -
+ + + + + {displayColumns.map((col) => { + const centered = ["quantity", "loss_rate", "level", "base_qty", "revision", "seq_no"].includes(col.key); + return ( + + ); + })} + + + + {flattenedRows.map(({ node, depth }, rowIdx) => { + const hasChildren = node.children.length > 0; + const isExpanded = expandedNodes.has(node.id); + const isSelected = selectedNodeId === node.id; + const isRoot = !!node._isVirtualRoot; + const itemType = node.child_item_type || node.item_type || ""; + const ItemIcon = getItemIcon(itemType); + + return ( + { + setSelectedNodeId(node.id); + if (hasChildren) toggleNode(node.id); + }} + > + + + {displayColumns.map((col) => { + const centered = ["quantity", "loss_rate", "level", "base_qty", "revision", "seq_no"].includes(col.key); + return ( + + ); + })} + + ); + })} + +
+ {col.title} +
+
+ + {hasChildren ? ( + isExpanded ? ( + + ) : ( + + ) + ) : ( + + )} + + + + +
+
+ {renderCellValue(node, col, depth)} +
)}
); } -/** - * 트리 노드 행 (재귀 렌더링) - */ -interface TreeNodeRowProps { - node: BomTreeNode; - depth: number; - expandedNodes: Set; - selectedNodeId: string | null; - onToggle: (id: string) => void; - onSelect: (id: string) => void; - getItemTypeLabel: (type: string) => string; - getItemTypeStyle: (type: string) => { icon: any; color: string; bg: string }; -} - -function TreeNodeRow({ - node, - depth, - expandedNodes, - selectedNodeId, - onToggle, - onSelect, - getItemTypeLabel, - getItemTypeStyle, -}: TreeNodeRowProps) { - const isExpanded = expandedNodes.has(node.id); - const hasChildren = node.children.length > 0; - const isSelected = selectedNodeId === node.id; - const style = getItemTypeStyle(node.child_item_type); - const ItemIcon = style.icon; - - return ( - <> -
{ - onSelect(node.id); - if (hasChildren) onToggle(node.id); - }} - > - {/* 펼치기/접기 화살표 */} - - {hasChildren ? ( - isExpanded ? ( - - ) : ( - - ) - ) : ( - - )} - - - {/* 품목 타입 아이콘 */} - - - - - {/* 품목 정보 */} -
- - {node.child_item_name || "-"} - - - {node.child_item_code || ""} - - - {getItemTypeLabel(node.child_item_type)} - -
- - {/* 수량/단위 */} -
- - 수량: {node.quantity || "0"} {node.unit || ""} - - {node.loss_rate && node.loss_rate !== "0" && ( - - 로스: {node.loss_rate}% - - )} -
-
- - {/* 하위 노드 재귀 렌더링 */} - {hasChildren && isExpanded && ( -
- {node.children.map((child) => ( - - ))} -
- )} - - ); -} +export default BomTreeComponent; diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 2a5907fc..2c4af6e3 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -1791,7 +1791,7 @@ export class ButtonActionExecutor { // 🔧 formData를 리피터에 전달하여 각 행에 병합 저장 const savedId = saveResult?.data?.id || saveResult?.data?.data?.id || formData.id || context.formData?.id; - // _deferSave 데이터 처리 (마스터-디테일 순차 저장: 메인 저장 후 디테일 저장) + // _deferSave 데이터 처리 (마스터-디테일 순차 저장: 레벨별 저장 + temp→real ID 매핑) if (savedId) { for (const [fieldKey, fieldValue] of Object.entries(context.formData)) { let parsedData = fieldValue; @@ -1804,27 +1804,47 @@ export class ButtonActionExecutor { const targetTable = parsedData[0]?._targetTable; if (!targetTable) continue; - for (const item of parsedData) { - const { _targetTable: _, _isNew, _deferSave: __, _fkColumn: fkCol, tempId: ___, ...data } = item; - if (!data.id || data.id === "") delete data.id; + // 레벨별 그룹핑 (레벨 0 먼저 저장 → 레벨 1 → ...) + const maxLevel = Math.max(...parsedData.map((item: any) => Number(item.level) || 0)); + const tempIdToRealId = new Map(); - // FK 주입 - if (fkCol) data[fkCol] = savedId; + for (let lvl = 0; lvl <= maxLevel; lvl++) { + const levelItems = parsedData.filter((item: any) => (Number(item.level) || 0) === lvl); - // 시스템 필드 추가 - data.created_by = context.userId; - data.updated_by = context.userId; - data.company_code = context.companyCode; + for (const item of levelItems) { + const { _targetTable: _, _isNew, _deferSave: __, _fkColumn: fkCol, tempId, ...data } = item; + if (!data.id || data.id === "") delete data.id; - try { - const isNew = _isNew || !item.id || item.id === ""; - if (isNew) { - await apiClient.post(`/table-management/tables/${targetTable}/add`, data); - } else { - await apiClient.put(`/table-management/tables/${targetTable}/${item.id}`, data); + // FK 주입 (bom_id 등) + if (fkCol) data[fkCol] = savedId; + + // parent_detail_id의 temp 참조를 실제 ID로 교체 + if (data.parent_detail_id && tempIdToRealId.has(data.parent_detail_id)) { + data.parent_detail_id = tempIdToRealId.get(data.parent_detail_id); + } + + // 시스템 필드 추가 + data.created_by = context.userId; + data.updated_by = context.userId; + data.company_code = context.companyCode; + + try { + const isNew = _isNew || !item.id || item.id === ""; + if (isNew) { + const res = await apiClient.post(`/table-management/tables/${targetTable}/add`, data); + const newId = res.data?.data?.id || res.data?.id; + if (newId && tempId) { + tempIdToRealId.set(tempId, newId); + } + } else { + await apiClient.put(`/table-management/tables/${targetTable}/${item.id}`, data); + if (item.id && tempId) { + tempIdToRealId.set(tempId, item.id); + } + } + } catch (err: any) { + console.error(`[handleSave] 디테일 저장 실패 (${targetTable}):`, err.response?.data || err.message); } - } catch (err: any) { - console.error(`[handleSave] 디테일 저장 실패 (${targetTable}):`, err.response?.data || err.message); } } }