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({ /> 필수 +