diff --git a/backend-node/src/routes/dataflow/node-flows.ts b/backend-node/src/routes/dataflow/node-flows.ts index 6de84866..177b4304 100644 --- a/backend-node/src/routes/dataflow/node-flows.ts +++ b/backend-node/src/routes/dataflow/node-flows.ts @@ -214,6 +214,73 @@ router.delete("/:flowId", async (req: Request, res: Response) => { } }); +/** + * 플로우 소스 테이블 조회 + * GET /api/dataflow/node-flows/:flowId/source-table + * 플로우의 첫 번째 소스 노드(tableSource, externalDBSource)에서 테이블명 추출 + */ +router.get("/:flowId/source-table", async (req: Request, res: Response) => { + try { + const { flowId } = req.params; + + const flow = await queryOne<{ flow_data: any }>( + `SELECT flow_data FROM node_flows WHERE flow_id = $1`, + [flowId] + ); + + if (!flow) { + return res.status(404).json({ + success: false, + message: "플로우를 찾을 수 없습니다.", + }); + } + + const flowData = + typeof flow.flow_data === "string" + ? JSON.parse(flow.flow_data) + : flow.flow_data; + + const nodes = flowData.nodes || []; + + // 소스 노드 찾기 (tableSource, externalDBSource 타입) + const sourceNode = nodes.find( + (node: any) => + node.type === "tableSource" || node.type === "externalDBSource" + ); + + if (!sourceNode || !sourceNode.data?.tableName) { + return res.json({ + success: true, + data: { + sourceTable: null, + sourceNodeType: null, + message: "소스 노드가 없거나 테이블명이 설정되지 않았습니다.", + }, + }); + } + + logger.info( + `플로우 소스 테이블 조회: flowId=${flowId}, table=${sourceNode.data.tableName}` + ); + + return res.json({ + success: true, + data: { + sourceTable: sourceNode.data.tableName, + sourceNodeType: sourceNode.type, + sourceNodeId: sourceNode.id, + displayName: sourceNode.data.displayName, + }, + }); + } catch (error) { + logger.error("플로우 소스 테이블 조회 실패:", error); + return res.status(500).json({ + success: false, + message: "플로우 소스 테이블을 조회하지 못했습니다.", + }); + } +}); + /** * 플로우 실행 * POST /api/dataflow/node-flows/:flowId/execute diff --git a/frontend/app/(main)/admin/systemMng/dataflow/page.tsx b/frontend/app/(main)/admin/systemMng/dataflow/page.tsx index 87d937ec..d55a6cf1 100644 --- a/frontend/app/(main)/admin/systemMng/dataflow/page.tsx +++ b/frontend/app/(main)/admin/systemMng/dataflow/page.tsx @@ -51,17 +51,17 @@ export default function DataFlowPage() { // 에디터 모드일 때는 레이아웃 없이 전체 화면 사용 if (isEditorMode) { return ( -
+
{/* 에디터 헤더 */} -
+

노드 플로우 에디터

-

+

드래그 앤 드롭으로 데이터 제어 플로우를 시각적으로 설계합니다

@@ -77,12 +77,12 @@ export default function DataFlowPage() { } return ( -
+
{/* 페이지 헤더 */}

제어 관리

-

노드 기반 데이터 플로우를 시각적으로 설계하고 관리합니다

+

노드 기반 데이터 플로우를 시각적으로 설계하고 관리합니다

{/* 플로우 목록 */} diff --git a/frontend/lib/api/nodeFlows.ts b/frontend/lib/api/nodeFlows.ts index b42340d7..27bb1b96 100644 --- a/frontend/lib/api/nodeFlows.ts +++ b/frontend/lib/api/nodeFlows.ts @@ -120,3 +120,41 @@ export interface NodeExecutionSummary { duration?: number; error?: string; } + +/** + * 플로우 소스 테이블 정보 인터페이스 + */ +export interface FlowSourceTableInfo { + sourceTable: string | null; + sourceNodeType: string | null; + sourceNodeId?: string; + displayName?: string; + message?: string; +} + +/** + * 플로우 소스 테이블 조회 + * 플로우의 첫 번째 소스 노드(tableSource, externalDBSource)에서 테이블명 추출 + */ +export async function getFlowSourceTable(flowId: number): Promise { + try { + const response = await apiClient.get>( + `/dataflow/node-flows/${flowId}/source-table`, + ); + if (response.data.success && response.data.data) { + return response.data.data; + } + return { + sourceTable: null, + sourceNodeType: null, + message: response.data.message || "소스 테이블 정보를 가져올 수 없습니다.", + }; + } catch (error) { + console.error("플로우 소스 테이블 조회 실패:", error); + return { + sourceTable: null, + sourceNodeType: null, + message: "API 호출 중 오류가 발생했습니다.", + }; + } +} diff --git a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx index 70b15e7d..995ebccb 100644 --- a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useRef } from "react"; +import React, { useState, useEffect, useRef, useMemo } from "react"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; @@ -84,6 +84,9 @@ export function RepeaterTable({ onSelectionChange, equalizeWidthsTrigger, }: RepeaterTableProps) { + // 히든 컬럼을 제외한 표시 가능한 컬럼만 필터링 + const visibleColumns = useMemo(() => columns.filter((col) => !col.hidden), [columns]); + // 컨테이너 ref - 실제 너비 측정용 const containerRef = useRef(null); @@ -145,7 +148,7 @@ export function RepeaterTable({ // 컬럼 너비 상태 관리 const [columnWidths, setColumnWidths] = useState>(() => { const widths: Record = {}; - columns.forEach((col) => { + columns.filter((col) => !col.hidden).forEach((col) => { widths[col.field] = col.width ? parseInt(col.width) : 120; }); return widths; @@ -154,11 +157,11 @@ export function RepeaterTable({ // 기본 너비 저장 (리셋용) const defaultWidths = React.useMemo(() => { const widths: Record = {}; - columns.forEach((col) => { + visibleColumns.forEach((col) => { widths[col.field] = col.width ? parseInt(col.width) : 120; }); return widths; - }, [columns]); + }, [visibleColumns]); // 리사이즈 상태 const [resizing, setResizing] = useState<{ field: string; startX: number; startWidth: number } | null>(null); @@ -206,7 +209,7 @@ export function RepeaterTable({ // 해당 컬럼의 가장 긴 글자 너비 계산 // equalWidth: 균등 분배 시 너비 (값이 없는 컬럼의 최소값으로 사용) const calculateColumnContentWidth = (field: string, equalWidth: number): number => { - const column = columns.find((col) => col.field === field); + const column = visibleColumns.find((col) => col.field === field); if (!column) return equalWidth; // 날짜 필드는 110px (yyyy-MM-dd) @@ -257,7 +260,7 @@ export function RepeaterTable({ // 헤더 더블클릭: 해당 컬럼만 글자 너비에 맞춤 const handleDoubleClick = (field: string) => { const availableWidth = getAvailableWidth(); - const equalWidth = Math.max(60, Math.floor(availableWidth / columns.length)); + const equalWidth = Math.max(60, Math.floor(availableWidth / visibleColumns.length)); const contentWidth = calculateColumnContentWidth(field, equalWidth); setColumnWidths((prev) => ({ ...prev, @@ -268,10 +271,10 @@ export function RepeaterTable({ // 균등 분배: 컬럼 수로 테이블 너비를 균등 분배 const applyEqualizeWidths = () => { const availableWidth = getAvailableWidth(); - const equalWidth = Math.max(60, Math.floor(availableWidth / columns.length)); + const equalWidth = Math.max(60, Math.floor(availableWidth / visibleColumns.length)); const newWidths: Record = {}; - columns.forEach((col) => { + visibleColumns.forEach((col) => { newWidths[col.field] = equalWidth; }); @@ -280,15 +283,15 @@ export function RepeaterTable({ // 자동 맞춤: 각 컬럼을 글자 너비에 맞추고, 컨테이너보다 작으면 남는 공간 분배 const applyAutoFitWidths = () => { - if (columns.length === 0) return; + if (visibleColumns.length === 0) return; // 균등 분배 너비 계산 (값이 없는 컬럼의 최소값) const availableWidth = getAvailableWidth(); - const equalWidth = Math.max(60, Math.floor(availableWidth / columns.length)); + const equalWidth = Math.max(60, Math.floor(availableWidth / visibleColumns.length)); // 1. 각 컬럼의 글자 너비 계산 (값이 없으면 균등 분배 너비 사용) const newWidths: Record = {}; - columns.forEach((col) => { + visibleColumns.forEach((col) => { newWidths[col.field] = calculateColumnContentWidth(col.field, equalWidth); }); @@ -298,8 +301,8 @@ export function RepeaterTable({ // 3. 컨테이너보다 작으면 남는 공간을 균등 분배 (테이블 꽉 참 유지) if (totalContentWidth < availableWidth) { const extraSpace = availableWidth - totalContentWidth; - const extraPerColumn = Math.floor(extraSpace / columns.length); - columns.forEach((col) => { + const extraPerColumn = Math.floor(extraSpace / visibleColumns.length); + visibleColumns.forEach((col) => { newWidths[col.field] += extraPerColumn; }); } @@ -311,7 +314,7 @@ export function RepeaterTable({ // 초기 마운트 시 균등 분배 적용 useEffect(() => { if (initializedRef.current) return; - if (!containerRef.current || columns.length === 0) return; + if (!containerRef.current || visibleColumns.length === 0) return; const timer = setTimeout(() => { applyEqualizeWidths(); @@ -319,7 +322,7 @@ export function RepeaterTable({ }, 100); return () => clearTimeout(timer); - }, [columns]); + }, [visibleColumns]); // 트리거 감지: 1=균등분배, 2=자동맞춤 useEffect(() => { @@ -357,7 +360,7 @@ export function RepeaterTable({ document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); }; - }, [resizing, columns, data]); + }, [resizing, visibleColumns, data]); // 데이터 변경 감지 (필요시 활성화) // useEffect(() => { @@ -531,7 +534,7 @@ export function RepeaterTable({ className={cn("border-gray-400", isIndeterminate && "data-[state=checked]:bg-primary")} /> - {columns.map((col) => { + {visibleColumns.map((col) => { const hasDynamicSource = col.dynamicDataSource?.enabled && col.dynamicDataSource.options.length > 0; const activeOptionId = activeDataSources[col.field] || col.dynamicDataSource?.defaultOptionId; const activeOption = hasDynamicSource @@ -631,7 +634,7 @@ export function RepeaterTable({ {data.length === 0 ? ( 추가된 항목이 없습니다 @@ -672,7 +675,7 @@ export function RepeaterTable({ /> {/* 데이터 컬럼들 */} - {columns.map((col) => ( + {visibleColumns.map((col) => ( tableConfig.calculations || [], + [tableConfig.calculations], + ); - // 계산 로직 + // 기본 계산 규칙 변환 (RepeaterTable용 - 조건부 계산이 없는 경우에 사용) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const calculationRules: CalculationRule[] = originalCalculationRules.map(convertToCalculationRule); + + // 조건부 계산 로직: 행의 조건 필드 값에 따라 적절한 계산식 선택 + const getFormulaForRow = useCallback((rule: TableCalculationRule, row: Record): string => { + // 조건부 계산이 활성화된 경우 + if (rule.conditionalCalculation?.enabled && rule.conditionalCalculation.conditionField) { + const conditionValue = row[rule.conditionalCalculation.conditionField]; + // 조건값과 일치하는 규칙 찾기 + const matchedRule = rule.conditionalCalculation.rules?.find((r) => r.conditionValue === conditionValue); + if (matchedRule) { + return matchedRule.formula; + } + // 일치하는 규칙이 없으면 기본 계산식 사용 + if (rule.conditionalCalculation.defaultFormula) { + return rule.conditionalCalculation.defaultFormula; + } + } + // 조건부 계산이 비활성화되었거나 기본값이 없으면 원래 계산식 사용 + return rule.formula; + }, []); + + // 계산 로직 (조건부 계산 지원) const calculateRow = useCallback( (row: any): any => { - if (calculationRules.length === 0) return row; + if (originalCalculationRules.length === 0) return row; const updatedRow = { ...row }; - for (const rule of calculationRules) { + for (const rule of originalCalculationRules) { try { - let formula = rule.formula; + // 조건부 계산에 따라 적절한 계산식 선택 + let formula = getFormulaForRow(rule, row); + + if (!formula) continue; + const fieldMatches = formula.match(/[a-zA-Z_][a-zA-Z0-9_]*/g) || []; const dependencies = rule.dependencies.length > 0 ? rule.dependencies : fieldMatches; for (const dep of dependencies) { - if (dep === rule.result) continue; + if (dep === rule.resultField) continue; const value = parseFloat(row[dep]) || 0; formula = formula.replace(new RegExp(`\\b${dep}\\b`, "g"), value.toString()); } const result = new Function(`return ${formula}`)(); - updatedRow[rule.result] = result; + updatedRow[rule.resultField] = result; } catch (error) { console.error(`계산 오류 (${rule.formula}):`, error); - updatedRow[rule.result] = 0; + updatedRow[rule.resultField] = 0; } } return updatedRow; }, - [calculationRules], + [originalCalculationRules, getFormulaForRow], ); const calculateAll = useCallback( diff --git a/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx b/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx index d82db59b..b01d6b09 100644 --- a/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx +++ b/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx @@ -24,6 +24,8 @@ import { TablePreFilter, TableModalFilter, TableCalculationRule, + ConditionalCalculationRule, + ConditionalCalculationConfig, LookupOption, LookupCondition, ConditionalTableOption, @@ -52,6 +54,414 @@ const HelpText = ({ children }: { children: React.ReactNode }) => (

{children}

); +// 계산 규칙 편집 컴포넌트 (조건부 계산 지원) +interface CalculationRuleEditorProps { + calc: TableCalculationRule; + index: number; + columns: TableColumnConfig[]; + sourceTableName?: string; // 소스 테이블명 추가 + onUpdate: (updates: Partial) => void; + onRemove: () => void; +} + +const CalculationRuleEditor: React.FC = ({ + calc, + index, + columns, + sourceTableName, + onUpdate, + onRemove, +}) => { + const [categoryOptions, setCategoryOptions] = useState<{ value: string; label: string }[]>([]); + const [loadingOptions, setLoadingOptions] = useState(false); + const [categoryColumns, setCategoryColumns] = useState>({}); + + // 조건부 계산 활성화 여부 + const isConditionalEnabled = calc.conditionalCalculation?.enabled ?? false; + + // 소스 테이블의 카테고리 컬럼 정보 로드 + useEffect(() => { + const loadCategoryColumns = async () => { + if (!sourceTableName) { + setCategoryColumns({}); + return; + } + + try { + const { getCategoryColumns } = await import("@/lib/api/tableCategoryValue"); + const result = await getCategoryColumns(sourceTableName); + + if (result && result.success && Array.isArray(result.data)) { + const categoryMap: Record = {}; + result.data.forEach((col: any) => { + // API 응답은 camelCase (columnName) + const colName = col.columnName || col.column_name; + if (colName) { + categoryMap[colName] = true; + } + }); + setCategoryColumns(categoryMap); + } + } catch (error) { + console.error("카테고리 컬럼 조회 실패:", error); + } + }; + + loadCategoryColumns(); + }, [sourceTableName]); + + // 조건 필드가 선택되었을 때 옵션 로드 (테이블 타입 관리의 카테고리 기준) + useEffect(() => { + const loadConditionOptions = async () => { + if (!isConditionalEnabled || !calc.conditionalCalculation?.conditionField) { + setCategoryOptions([]); + return; + } + + const conditionField = calc.conditionalCalculation.conditionField; + + // 소스 필드(sourceField)가 있으면 해당 필드명 사용, 없으면 field명 사용 + const selectedColumn = columns.find((col) => col.field === conditionField); + const actualFieldName = selectedColumn?.sourceField || conditionField; + + // 소스 테이블에서 해당 컬럼이 카테고리 타입인지 확인 + if (sourceTableName && categoryColumns[actualFieldName]) { + try { + setLoadingOptions(true); + const { getCategoryValues } = await import("@/lib/api/tableCategoryValue"); + const result = await getCategoryValues(sourceTableName, actualFieldName, false); + if (result && result.success && Array.isArray(result.data)) { + const options = result.data.map((item: any) => ({ + // API 응답은 camelCase (valueCode, valueLabel) + value: item.valueCode || item.value_code || item.value, + label: item.valueLabel || item.displayLabel || item.display_label || item.label || item.valueCode || item.value_code || item.value, + })); + setCategoryOptions(options); + } else { + setCategoryOptions([]); + } + } catch (error) { + console.error("카테고리 값 로드 실패:", error); + setCategoryOptions([]); + } finally { + setLoadingOptions(false); + } + return; + } + + // 카테고리 키가 직접 설정된 경우 (저장된 값) + const categoryKey = calc.conditionalCalculation?.conditionFieldCategoryKey; + if (categoryKey) { + try { + setLoadingOptions(true); + const [tableName, columnName] = categoryKey.split("."); + if (tableName && columnName) { + const { getCategoryValues } = await import("@/lib/api/tableCategoryValue"); + const result = await getCategoryValues(tableName, columnName, false); + if (result && result.success && Array.isArray(result.data)) { + setCategoryOptions( + result.data.map((item: any) => ({ + // API 응답은 camelCase (valueCode, valueLabel) + value: item.valueCode || item.value_code || item.value, + label: item.valueLabel || item.displayLabel || item.display_label || item.label || item.valueCode || item.value_code || item.value, + })) + ); + } + } + } catch (error) { + console.error("카테고리 옵션 로드 실패:", error); + } finally { + setLoadingOptions(false); + } + return; + } + + // 그 외 타입은 옵션 없음 (직접 입력) + setCategoryOptions([]); + }; + + loadConditionOptions(); + }, [isConditionalEnabled, calc.conditionalCalculation?.conditionField, calc.conditionalCalculation?.conditionFieldCategoryKey, columns, sourceTableName, categoryColumns]); + + // 조건부 계산 토글 + const toggleConditionalCalculation = (enabled: boolean) => { + onUpdate({ + conditionalCalculation: enabled + ? { + enabled: true, + conditionField: "", + conditionFieldType: "static", + rules: [], + defaultFormula: calc.formula || "", + } + : undefined, + }); + }; + + // 조건 필드 변경 + const updateConditionField = (field: string) => { + const selectedColumn = columns.find((col) => col.field === field); + const actualFieldName = selectedColumn?.sourceField || field; + + // 컬럼의 타입과 옵션 확인 (테이블 타입 관리의 카테고리 기준) + let conditionFieldType: "static" | "code" | "table" = "static"; + let conditionFieldCategoryKey = ""; + + // 소스 테이블에서 해당 컬럼이 카테고리 타입인지 확인 + if (sourceTableName && categoryColumns[actualFieldName]) { + conditionFieldType = "code"; + conditionFieldCategoryKey = `${sourceTableName}.${actualFieldName}`; + } + + onUpdate({ + conditionalCalculation: { + ...calc.conditionalCalculation!, + conditionField: field, + conditionFieldType, + conditionFieldCategoryKey, + rules: [], // 필드 변경 시 규칙 초기화 + }, + }); + }; + + // 조건 규칙 추가 + const addConditionRule = () => { + const newRule: ConditionalCalculationRule = { + conditionValue: "", + formula: calc.formula || "", + }; + onUpdate({ + conditionalCalculation: { + ...calc.conditionalCalculation!, + rules: [...(calc.conditionalCalculation?.rules || []), newRule], + }, + }); + }; + + // 조건 규칙 업데이트 + const updateConditionRule = (ruleIndex: number, updates: Partial) => { + const newRules = [...(calc.conditionalCalculation?.rules || [])]; + newRules[ruleIndex] = { ...newRules[ruleIndex], ...updates }; + onUpdate({ + conditionalCalculation: { + ...calc.conditionalCalculation!, + rules: newRules, + }, + }); + }; + + // 조건 규칙 삭제 + const removeConditionRule = (ruleIndex: number) => { + onUpdate({ + conditionalCalculation: { + ...calc.conditionalCalculation!, + rules: (calc.conditionalCalculation?.rules || []).filter((_, i) => i !== ruleIndex), + }, + }); + }; + + // 기본 계산식 업데이트 + const updateDefaultFormula = (formula: string) => { + onUpdate({ + conditionalCalculation: { + ...calc.conditionalCalculation!, + defaultFormula: formula, + }, + }); + }; + + // 조건 필드로 사용 가능한 컬럼 (모든 컬럼) + const availableColumns = columns.filter((col) => col.field); + + return ( +
+ {/* 기본 계산 규칙 */} +
+ + = + onUpdate({ formula: e.target.value })} + placeholder="수식 (예: qty * unit_price)" + className="h-8 text-xs flex-1" + disabled={isConditionalEnabled} + /> + +
+ + {/* 조건부 계산 토글 */} +
+ + + {availableColumns.length === 0 && !isConditionalEnabled && ( + + (컬럼 설정에서 먼저 컬럼을 추가하세요) + + )} +
+ + {/* 조건부 계산 설정 */} + {isConditionalEnabled && ( +
+ {/* 조건 필드 선택 */} +
+ + +
+ + {/* 조건별 계산식 목록 */} + {calc.conditionalCalculation?.conditionField && ( +
+
+ + +
+ + {(calc.conditionalCalculation?.rules || []).map((rule, ruleIndex) => ( +
+ {/* 조건값 선택 */} + {categoryOptions.length > 0 ? ( + + ) : ( + + updateConditionRule(ruleIndex, { conditionValue: e.target.value }) + } + placeholder="조건값" + className="h-7 text-xs w-[120px]" + /> + )} + + + updateConditionRule(ruleIndex, { formula: e.target.value }) + } + placeholder="계산식" + className="h-7 text-xs flex-1" + /> + +
+ ))} + + {/* 기본 계산식 */} +
+ + (기본값) + + + updateDefaultFormula(e.target.value)} + placeholder="기본 계산식 (조건 미해당 시)" + className="h-7 text-xs flex-1" + /> +
+
+ )} + + {loadingOptions && ( +

옵션 로딩 중...

+ )} +
+ )} +
+ ); +}; + // 옵션 소스 설정 컴포넌트 (검색 가능한 Combobox) interface OptionSourceConfigProps { optionSource: { @@ -669,6 +1079,14 @@ function ColumnSettingItem({ /> 필수 +
diff --git a/frontend/lib/registry/components/universal-form-modal/types.ts b/frontend/lib/registry/components/universal-form-modal/types.ts index a07feed6..9d54270b 100644 --- a/frontend/lib/registry/components/universal-form-modal/types.ts +++ b/frontend/lib/registry/components/universal-form-modal/types.ts @@ -378,6 +378,7 @@ export interface TableColumnConfig { editable?: boolean; // 편집 가능 여부 (기본: true) calculated?: boolean; // 계산 필드 여부 (자동 읽기전용) required?: boolean; // 필수 입력 여부 + hidden?: boolean; // 히든 필드 여부 (UI에서 숨기지만 데이터는 유지) // 너비 설정 width?: string; // 기본 너비 (예: "150px") @@ -604,6 +605,27 @@ export interface ColumnModeConfig { valueMapping: ValueMappingConfig; // 이 모드의 값 매핑 } +/** + * 조건부 계산 규칙 + * 특정 필드 값에 따라 다른 계산식 적용 + */ +export interface ConditionalCalculationRule { + conditionValue: string; // 조건 값 (예: "국내", "해외") + formula: string; // 해당 조건일 때 사용할 계산식 +} + +/** + * 조건부 계산 설정 + */ +export interface ConditionalCalculationConfig { + enabled: boolean; // 조건부 계산 활성화 여부 + conditionField: string; // 조건 기준 필드 (예: "sales_type") + conditionFieldType?: "static" | "code" | "table"; // 조건 필드의 옵션 타입 + conditionFieldCategoryKey?: string; // 카테고리 키 (예: "sales_order_mng.sales_type") + rules: ConditionalCalculationRule[]; // 조건별 계산 규칙 + defaultFormula?: string; // 조건에 해당하지 않을 때 기본 계산식 +} + /** * 테이블 계산 규칙 * 다른 컬럼 값을 기반으로 자동 계산 @@ -612,6 +634,9 @@ export interface TableCalculationRule { resultField: string; // 결과를 저장할 필드 formula: string; // 계산 공식 (예: "quantity * unit_price") dependencies: string[]; // 의존하는 필드들 + + // 조건부 계산 (선택사항) + conditionalCalculation?: ConditionalCalculationConfig; } // 다중 행 저장 설정 diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 9a6a606e..327cb87f 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -1608,6 +1608,66 @@ export class ButtonActionExecutor { return { handled: false, success: false }; } + // 🎯 채번 규칙 할당 처리 (저장 시점에 실제 순번 증가) + console.log("🔍 [handleUniversalFormModalTableSectionSave] 채번 규칙 할당 체크 시작"); + + const fieldsWithNumbering: Record = {}; + + // commonFieldsData와 modalData에서 채번 규칙이 설정된 필드 찾기 + for (const [key, value] of Object.entries(modalData)) { + if (key.endsWith("_numberingRuleId") && value) { + const fieldName = key.replace("_numberingRuleId", ""); + fieldsWithNumbering[fieldName] = value as string; + console.log(`🎯 [handleUniversalFormModalTableSectionSave] 채번 필드 발견: ${fieldName} → 규칙 ${value}`); + } + } + + // formData에서도 확인 (모달 외부에 있을 수 있음) + for (const [key, value] of Object.entries(formData)) { + if (key.endsWith("_numberingRuleId") && value && !fieldsWithNumbering[key.replace("_numberingRuleId", "")]) { + const fieldName = key.replace("_numberingRuleId", ""); + fieldsWithNumbering[fieldName] = value as string; + console.log( + `🎯 [handleUniversalFormModalTableSectionSave] 채번 필드 발견 (formData): ${fieldName} → 규칙 ${value}`, + ); + } + } + + console.log("📋 [handleUniversalFormModalTableSectionSave] 채번 규칙이 설정된 필드:", fieldsWithNumbering); + + // 🔥 저장 시점에 allocateCode 호출하여 실제 순번 증가 + if (Object.keys(fieldsWithNumbering).length > 0) { + console.log("🎯 [handleUniversalFormModalTableSectionSave] 채번 규칙 할당 시작 (allocateCode 호출)"); + const { allocateNumberingCode } = await import("@/lib/api/numberingRule"); + + for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) { + try { + console.log( + `🔄 [handleUniversalFormModalTableSectionSave] ${fieldName} 필드에 대해 allocateCode 호출: ${ruleId}`, + ); + const allocateResult = await allocateNumberingCode(ruleId); + + if (allocateResult.success && allocateResult.data?.generatedCode) { + const newCode = allocateResult.data.generatedCode; + console.log( + `✅ [handleUniversalFormModalTableSectionSave] ${fieldName} 새 코드 할당: ${commonFieldsData[fieldName]} → ${newCode}`, + ); + commonFieldsData[fieldName] = newCode; + } else { + console.warn( + `⚠️ [handleUniversalFormModalTableSectionSave] ${fieldName} 코드 할당 실패, 기존 값 유지:`, + allocateResult.error, + ); + } + } catch (allocateError) { + console.error(`❌ [handleUniversalFormModalTableSectionSave] ${fieldName} 코드 할당 오류:`, allocateError); + // 오류 시 기존 값 유지 + } + } + } + + console.log("✅ [handleUniversalFormModalTableSectionSave] 채번 규칙 할당 완료"); + try { // 사용자 정보 추가 if (!context.userId) { @@ -1804,6 +1864,84 @@ export class ButtonActionExecutor { console.log(`✅ [handleUniversalFormModalTableSectionSave] 완료: ${resultMessage}`); toast.success(`저장 완료: ${resultMessage}`); + // 🆕 저장 성공 후 제어 관리 실행 (다중 테이블 저장 시 소스 테이블과 일치하는 섹션만 실행) + if (config.enableDataflowControl && config.dataflowConfig?.flowConfig?.flowId) { + const flowId = config.dataflowConfig.flowConfig.flowId; + console.log("🎯 [handleUniversalFormModalTableSectionSave] 제어 관리 실행 시작:", { flowId }); + + try { + // 플로우 소스 테이블 조회 + const { getFlowSourceTable } = await import("@/lib/api/nodeFlows"); + const flowSourceInfo = await getFlowSourceTable(flowId); + + console.log("📊 [handleUniversalFormModalTableSectionSave] 플로우 소스 테이블:", flowSourceInfo); + + if (flowSourceInfo.sourceTable) { + // 각 섹션 확인하여 소스 테이블과 일치하는 섹션 찾기 + let controlExecuted = false; + + for (const [sectionId, sectionItems] of Object.entries(tableSectionData)) { + const sectionConfig = sections.find((s: any) => s.id === sectionId); + const sectionTargetTable = sectionConfig?.tableConfig?.saveConfig?.targetTable || tableName; + + console.log(`🔍 [handleUniversalFormModalTableSectionSave] 섹션 ${sectionId} 테이블 비교:`, { + sectionTargetTable, + flowSourceTable: flowSourceInfo.sourceTable, + isMatch: sectionTargetTable === flowSourceInfo.sourceTable, + }); + + // 소스 테이블과 일치하는 섹션만 제어 실행 + if (sectionTargetTable === flowSourceInfo.sourceTable && sectionItems.length > 0) { + console.log( + `✅ [handleUniversalFormModalTableSectionSave] 섹션 ${sectionId} → 플로우 소스 테이블 일치! 제어 실행`, + ); + + // 공통 필드 + 해당 섹션 데이터 병합하여 sourceData 생성 + const sourceData = sectionItems.map((item: any) => ({ + ...commonFieldsData, + ...item, + })); + + console.log( + `📦 [handleUniversalFormModalTableSectionSave] 제어 전달 데이터: ${sourceData.length}건`, + sourceData[0], + ); + + // 제어 관리용 컨텍스트 생성 + const controlContext: ButtonActionContext = { + ...context, + selectedRowsData: sourceData, + formData: commonFieldsData, + }; + + // 제어 관리 실행 + await this.executeAfterSaveControl(config, controlContext); + controlExecuted = true; + break; // 첫 번째 매칭 섹션만 실행 + } + } + + // 매칭되는 섹션이 없으면 메인 테이블 확인 + if (!controlExecuted && tableName === flowSourceInfo.sourceTable) { + console.log("✅ [handleUniversalFormModalTableSectionSave] 메인 테이블 일치! 공통 필드로 제어 실행"); + + const controlContext: ButtonActionContext = { + ...context, + selectedRowsData: [commonFieldsData], + formData: commonFieldsData, + }; + + await this.executeAfterSaveControl(config, controlContext); + } + } else { + console.log("⚠️ [handleUniversalFormModalTableSectionSave] 플로우 소스 테이블 없음 - 제어 스킵"); + } + } catch (controlError) { + console.error("❌ [handleUniversalFormModalTableSectionSave] 제어 관리 실행 오류:", controlError); + // 제어 관리 실패는 저장 성공에 영향주지 않음 + } + } + // 저장 성공 이벤트 발생 window.dispatchEvent(new CustomEvent("saveSuccess")); window.dispatchEvent(new CustomEvent("refreshTable"));