"use client"; import React, { useState, useEffect, useCallback, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { Plus, Columns, AlignJustify } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; // 기존 ModalRepeaterTable 컴포넌트 재사용 import { RepeaterTable } from "../modal-repeater-table/RepeaterTable"; import { ItemSelectionModal } from "../modal-repeater-table/ItemSelectionModal"; import { RepeaterColumnConfig, CalculationRule, DynamicDataSourceOption } from "../modal-repeater-table/types"; // 타입 정의 import { TableSectionConfig, TableColumnConfig, ValueMappingConfig, TableJoinCondition, FormDataState, } from "./types"; interface TableSectionRendererProps { sectionId: string; tableConfig: TableSectionConfig; formData: FormDataState; onFormDataChange: (field: string, value: any) => void; onTableDataChange: (data: any[]) => void; className?: string; } /** * TableColumnConfig를 RepeaterColumnConfig로 변환 * columnModes 또는 lookup이 있으면 dynamicDataSource로 변환 */ function convertToRepeaterColumn(col: TableColumnConfig): RepeaterColumnConfig { const baseColumn: RepeaterColumnConfig = { field: col.field, label: col.label, type: col.type, editable: col.editable ?? true, calculated: col.calculated ?? false, width: col.width || "150px", required: col.required, defaultValue: col.defaultValue, selectOptions: col.selectOptions, // valueMapping은 별도로 처리 }; // lookup 설정을 dynamicDataSource로 변환 (새로운 조회 기능) if (col.lookup?.enabled && col.lookup.options && col.lookup.options.length > 0) { baseColumn.dynamicDataSource = { enabled: true, options: col.lookup.options.map((option) => ({ id: option.id, // "컬럼명 - 옵션라벨" 형식으로 헤더에 표시 label: option.displayLabel || option.label, // 헤더에 표시될 전체 라벨 (컬럼명 - 옵션라벨) headerLabel: `${col.label} - ${option.displayLabel || option.label}`, sourceType: "table" as const, tableConfig: { tableName: option.tableName, valueColumn: option.valueColumn, joinConditions: option.conditions.map((cond) => ({ sourceField: cond.sourceField, targetField: cond.targetColumn, // sourceType에 따른 데이터 출처 설정 sourceType: cond.sourceType, // "currentRow" | "sectionField" | "externalTable" fromFormData: cond.sourceType === "sectionField", sectionId: cond.sectionId, // 외부 테이블 조회 설정 (sourceType이 "externalTable"인 경우) externalLookup: cond.externalLookup, // 값 변환 설정 전달 (레거시 호환) transform: cond.transform?.enabled ? { tableName: cond.transform.tableName, matchColumn: cond.transform.matchColumn, resultColumn: cond.transform.resultColumn, } : undefined, })), }, // 조회 유형 정보 추가 lookupType: option.type, })), defaultOptionId: col.lookup.options.find((o) => o.isDefault)?.id || col.lookup.options[0]?.id, }; } // columnModes를 dynamicDataSource로 변환 (기존 로직 유지) else if (col.columnModes && col.columnModes.length > 0) { baseColumn.dynamicDataSource = { enabled: true, options: col.columnModes.map((mode) => ({ id: mode.id, label: mode.label, sourceType: "table" as const, // 실제 조회 로직은 TableSectionRenderer에서 처리 tableConfig: { tableName: mode.valueMapping?.externalRef?.tableName || "", valueColumn: mode.valueMapping?.externalRef?.valueColumn || "", joinConditions: (mode.valueMapping?.externalRef?.joinConditions || []).map((jc) => ({ sourceField: jc.sourceField, targetField: jc.targetColumn, })), }, })), defaultOptionId: col.columnModes.find((m) => m.isDefault)?.id || col.columnModes[0]?.id, }; } return baseColumn; } /** * TableCalculationRule을 CalculationRule로 변환 */ function convertToCalculationRule(calc: { resultField: string; formula: string; dependencies: string[] }): CalculationRule { return { result: calc.resultField, formula: calc.formula, dependencies: calc.dependencies, }; } /** * 값 변환 함수: 중간 테이블을 통해 값을 변환 * 예: 거래처 이름 "(무)테스트업체" → 거래처 코드 "CUST-0002" */ async function transformValue( value: any, transform: { tableName: string; matchColumn: string; resultColumn: string } ): Promise { if (!value || !transform.tableName || !transform.matchColumn || !transform.resultColumn) { return value; } try { // 정확히 일치하는 검색 const response = await apiClient.post( `/table-management/tables/${transform.tableName}/data`, { search: { [transform.matchColumn]: { value: value, operator: "equals" } }, size: 1, page: 1 } ); if (response.data.success && response.data.data?.data?.length > 0) { const transformedValue = response.data.data.data[0][transform.resultColumn]; return transformedValue; } console.warn(`변환 실패: ${transform.tableName}.${transform.matchColumn} = "${value}" 인 행을 찾을 수 없습니다.`); return undefined; } catch (error) { console.error("값 변환 오류:", error); return undefined; } } /** * 외부 테이블에서 조건 값을 조회하는 함수 * LookupCondition.sourceType이 "externalTable"인 경우 사용 */ async function fetchExternalLookupValue( externalLookup: { tableName: string; matchColumn: string; matchSourceType: "currentRow" | "sourceTable" | "sectionField"; matchSourceField: string; matchSectionId?: string; resultColumn: string; }, rowData: any, sourceData: any, formData: FormDataState ): Promise { // 1. 비교 값 가져오기 let matchValue: any; if (externalLookup.matchSourceType === "currentRow") { matchValue = rowData[externalLookup.matchSourceField]; } else if (externalLookup.matchSourceType === "sourceTable") { matchValue = sourceData?.[externalLookup.matchSourceField]; } else { matchValue = formData[externalLookup.matchSourceField]; } if (matchValue === undefined || matchValue === null || matchValue === "") { console.warn(`외부 테이블 조회: 비교 값이 없습니다. (${externalLookup.matchSourceType}.${externalLookup.matchSourceField})`); return undefined; } // 2. 외부 테이블에서 값 조회 (정확히 일치하는 검색) try { const response = await apiClient.post( `/table-management/tables/${externalLookup.tableName}/data`, { search: { [externalLookup.matchColumn]: { value: matchValue, operator: "equals" } }, size: 1, page: 1 } ); if (response.data.success && response.data.data?.data?.length > 0) { return response.data.data.data[0][externalLookup.resultColumn]; } console.warn(`외부 테이블 조회: ${externalLookup.tableName}.${externalLookup.matchColumn} = "${matchValue}" 인 행을 찾을 수 없습니다.`); return undefined; } catch (error) { console.error("외부 테이블 조회 오류:", error); return undefined; } } /** * 외부 테이블에서 값을 조회하는 함수 * * @param tableName - 조회할 테이블명 * @param valueColumn - 가져올 컬럼명 * @param joinConditions - 조인 조건 목록 * @param rowData - 현재 행 데이터 (설정된 컬럼 필드) * @param sourceData - 원본 소스 데이터 (_sourceData) * @param formData - 폼 데이터 (다른 섹션 필드) */ async function fetchExternalValue( tableName: string, valueColumn: string, joinConditions: TableJoinCondition[], rowData: any, sourceData: any, formData: FormDataState ): Promise { if (joinConditions.length === 0) { return undefined; } try { const whereConditions: Record = {}; for (const condition of joinConditions) { let value: any; // 값 출처에 따라 가져오기 (4가지 소스 타입 지원) if (condition.sourceType === "row") { // 현재 행 데이터 (설정된 컬럼 필드) value = rowData[condition.sourceField]; } else if (condition.sourceType === "sourceData") { // 원본 소스 테이블 데이터 (_sourceData) value = sourceData?.[condition.sourceField]; } else if (condition.sourceType === "formData") { // formData에서 가져오기 (다른 섹션) value = formData[condition.sourceField]; } else if (condition.sourceType === "externalTable" && condition.externalLookup) { // 외부 테이블에서 조회하여 가져오기 value = await fetchExternalLookupValue(condition.externalLookup, rowData, sourceData, formData); } if (value === undefined || value === null || value === "") { return undefined; } // 값 변환이 필요한 경우 (예: 이름 → 코드) - 레거시 호환 if (condition.transform) { value = await transformValue(value, condition.transform); if (value === undefined) { return undefined; } } // 숫자형 ID 변환 let convertedValue = value; if (condition.targetColumn.endsWith("_id") || condition.targetColumn === "id") { const numValue = Number(value); if (!isNaN(numValue)) { convertedValue = numValue; } } // 정확히 일치하는 검색을 위해 operator: "equals" 사용 whereConditions[condition.targetColumn] = { value: convertedValue, operator: "equals" }; } // API 호출 const response = await apiClient.post( `/table-management/tables/${tableName}/data`, { search: whereConditions, size: 1, page: 1 } ); if (response.data.success && response.data.data?.data?.length > 0) { return response.data.data.data[0][valueColumn]; } return undefined; } catch (error) { console.error("외부 테이블 조회 오류:", error); return undefined; } } /** * 테이블 섹션 렌더러 * UniversalFormModal 내에서 테이블 형식의 데이터를 표시하고 편집 */ export function TableSectionRenderer({ sectionId, tableConfig, formData, onFormDataChange, onTableDataChange, className, }: TableSectionRendererProps) { // 테이블 데이터 상태 const [tableData, setTableData] = useState([]); // 모달 상태 const [modalOpen, setModalOpen] = useState(false); // 체크박스 선택 상태 const [selectedRows, setSelectedRows] = useState>(new Set()); // 너비 조정 트리거 (홀수: 자동맞춤, 짝수: 균등분배) const [widthTrigger, setWidthTrigger] = useState(0); // 동적 데이터 소스 활성화 상태 const [activeDataSources, setActiveDataSources] = useState>({}); // 날짜 일괄 적용 완료 플래그 (컬럼별로 한 번만 적용) const [batchAppliedFields, setBatchAppliedFields] = useState>(new Set()); // 초기 데이터 로드 완료 플래그 (무한 루프 방지) const initialDataLoadedRef = React.useRef(false); // formData에서 초기 테이블 데이터 로드 (수정 모드에서 _groupedData 표시) useEffect(() => { // 이미 초기화되었으면 스킵 if (initialDataLoadedRef.current) return; const tableSectionKey = `_tableSection_${sectionId}`; const initialData = formData[tableSectionKey]; if (Array.isArray(initialData) && initialData.length > 0) { console.log("[TableSectionRenderer] 초기 데이터 로드:", { sectionId, itemCount: initialData.length, }); setTableData(initialData); initialDataLoadedRef.current = true; } }, [sectionId, formData]); // RepeaterColumnConfig로 변환 const columns: RepeaterColumnConfig[] = (tableConfig.columns || []).map(convertToRepeaterColumn); // 계산 규칙 변환 const calculationRules: CalculationRule[] = (tableConfig.calculations || []).map(convertToCalculationRule); // 계산 로직 const calculateRow = useCallback( (row: any): any => { if (calculationRules.length === 0) return row; const updatedRow = { ...row }; for (const rule of calculationRules) { try { let formula = rule.formula; 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; 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; } catch (error) { console.error(`계산 오류 (${rule.formula}):`, error); updatedRow[rule.result] = 0; } } return updatedRow; }, [calculationRules] ); const calculateAll = useCallback( (data: any[]): any[] => { return data.map((row) => calculateRow(row)); }, [calculateRow] ); // 데이터 변경 핸들러 (날짜 일괄 적용 로직 포함) const handleDataChange = useCallback( (newData: any[]) => { let processedData = newData; // 날짜 일괄 적용 로직: batchApply가 활성화된 날짜 컬럼 처리 const batchApplyColumns = tableConfig.columns.filter( (col) => col.type === "date" && col.batchApply === true ); for (const dateCol of batchApplyColumns) { // 이미 일괄 적용된 컬럼은 건너뜀 if (batchAppliedFields.has(dateCol.field)) continue; // 해당 컬럼에 값이 있는 행과 없는 행 분류 const itemsWithDate = processedData.filter((item) => item[dateCol.field]); const itemsWithoutDate = processedData.filter((item) => !item[dateCol.field]); // 조건: 정확히 1개만 날짜가 있고, 나머지는 모두 비어있을 때 if (itemsWithDate.length === 1 && itemsWithoutDate.length > 0) { const selectedDate = itemsWithDate[0][dateCol.field]; // 모든 행에 동일한 날짜 적용 processedData = processedData.map((item) => ({ ...item, [dateCol.field]: selectedDate, })); // 플래그 활성화 (이후 개별 수정 가능) setBatchAppliedFields((prev) => new Set([...prev, dateCol.field])); } } setTableData(processedData); onTableDataChange(processedData); }, [onTableDataChange, tableConfig.columns, batchAppliedFields] ); // 행 변경 핸들러 const handleRowChange = useCallback( (index: number, newRow: any) => { const calculatedRow = calculateRow(newRow); const newData = [...tableData]; newData[index] = calculatedRow; handleDataChange(newData); }, [tableData, calculateRow, handleDataChange] ); // 행 삭제 핸들러 const handleRowDelete = useCallback( (index: number) => { const newData = tableData.filter((_, i) => i !== index); handleDataChange(newData); }, [tableData, handleDataChange] ); // 선택된 항목 일괄 삭제 const handleBulkDelete = useCallback(() => { if (selectedRows.size === 0) return; const newData = tableData.filter((_, index) => !selectedRows.has(index)); handleDataChange(newData); setSelectedRows(new Set()); // 데이터가 모두 삭제되면 일괄 적용 플래그도 리셋 if (newData.length === 0) { setBatchAppliedFields(new Set()); } }, [tableData, selectedRows, handleDataChange]); // 아이템 추가 핸들러 (모달에서 선택) const handleAddItems = useCallback( async (items: any[]) => { // 각 아이템에 대해 valueMapping 적용 const mappedItems = await Promise.all( items.map(async (sourceItem) => { const newItem: any = {}; for (const col of tableConfig.columns) { const mapping = col.valueMapping; // 0. lookup 설정이 있는 경우 (동적 조회) if (col.lookup?.enabled && col.lookup.options && col.lookup.options.length > 0) { // 현재 활성화된 옵션 또는 기본 옵션 사용 const activeOptionId = activeDataSources[col.field]; const defaultOption = col.lookup.options.find((o) => o.isDefault) || col.lookup.options[0]; const selectedOption = activeOptionId ? col.lookup.options.find((o) => o.id === activeOptionId) || defaultOption : defaultOption; if (selectedOption) { // sameTable 타입: 소스 데이터에서 직접 값 복사 if (selectedOption.type === "sameTable") { const value = sourceItem[selectedOption.valueColumn]; if (value !== undefined) { newItem[col.field] = value; } // _sourceData에 원본 저장 (나중에 다른 옵션으로 전환 시 사용) newItem._sourceData = sourceItem; continue; } // relatedTable, combinedLookup: 외부 테이블 조회 // 조인 조건 구성 (4가지 소스 타입 지원) const joinConditions: TableJoinCondition[] = selectedOption.conditions.map((cond) => { // sourceType 매핑 let sourceType: "row" | "sourceData" | "formData" | "externalTable"; if (cond.sourceType === "currentRow") { sourceType = "row"; } else if (cond.sourceType === "sourceTable") { sourceType = "sourceData"; } else if (cond.sourceType === "externalTable") { sourceType = "externalTable"; } else { sourceType = "formData"; } return { sourceType, sourceField: cond.sourceField, targetColumn: cond.targetColumn, // 외부 테이블 조회 설정 externalLookup: cond.externalLookup, // 값 변환 설정 전달 (레거시 호환) transform: cond.transform?.enabled ? { tableName: cond.transform.tableName, matchColumn: cond.transform.matchColumn, resultColumn: cond.transform.resultColumn, } : undefined, }; }); // 외부 테이블에서 값 조회 (sourceItem이 _sourceData 역할) const value = await fetchExternalValue( selectedOption.tableName, selectedOption.valueColumn, joinConditions, { ...sourceItem, ...newItem }, // rowData (현재 행) sourceItem, // sourceData (소스 테이블 원본) formData ); if (value !== undefined) { newItem[col.field] = value; } // _sourceData에 원본 저장 newItem._sourceData = sourceItem; } continue; } // 1. 먼저 col.sourceField 확인 (간단 매핑) if (!mapping && col.sourceField) { // sourceField가 명시적으로 설정된 경우 if (sourceItem[col.sourceField] !== undefined) { newItem[col.field] = sourceItem[col.sourceField]; } continue; } if (!mapping) { // 매핑 없으면 소스에서 동일 필드명으로 복사 if (sourceItem[col.field] !== undefined) { newItem[col.field] = sourceItem[col.field]; } continue; } // 2. valueMapping이 있는 경우 (고급 매핑) switch (mapping.type) { case "source": // 소스 테이블에서 복사 const srcField = mapping.sourceField || col.sourceField || col.field; if (sourceItem[srcField] !== undefined) { newItem[col.field] = sourceItem[srcField]; } break; case "manual": // 사용자 입력 (빈 값 또는 기본값) newItem[col.field] = col.defaultValue ?? undefined; break; case "internal": // formData에서 값 가져오기 if (mapping.internalField) { newItem[col.field] = formData[mapping.internalField]; } break; case "external": // 외부 테이블에서 조회 if (mapping.externalRef) { const { tableName, valueColumn, joinConditions } = mapping.externalRef; const value = await fetchExternalValue( tableName, valueColumn, joinConditions, { ...sourceItem, ...newItem }, // rowData sourceItem, // sourceData formData ); if (value !== undefined) { newItem[col.field] = value; } } break; } // 기본값 적용 if (col.defaultValue !== undefined && newItem[col.field] === undefined) { newItem[col.field] = col.defaultValue; } } return newItem; }) ); // 계산 필드 업데이트 const calculatedItems = calculateAll(mappedItems); // 기존 데이터에 추가 const newData = [...tableData, ...calculatedItems]; handleDataChange(newData); }, [tableConfig.columns, formData, tableData, calculateAll, handleDataChange, activeDataSources] ); // 컬럼 모드/조회 옵션 변경 핸들러 const handleDataSourceChange = useCallback( async (columnField: string, optionId: string) => { setActiveDataSources((prev) => ({ ...prev, [columnField]: optionId, })); // 해당 컬럼의 모든 행 데이터 재조회 const column = tableConfig.columns.find((col) => col.field === columnField); // lookup 설정이 있는 경우 (새로운 조회 기능) if (column?.lookup?.enabled && column.lookup.options) { const selectedOption = column.lookup.options.find((opt) => opt.id === optionId); if (!selectedOption) return; // sameTable 타입: 현재 행의 소스 데이터에서 값 복사 (외부 조회 필요 없음) if (selectedOption.type === "sameTable") { const updatedData = tableData.map((row) => { // sourceField에서 값을 가져와 해당 컬럼에 복사 // row에 _sourceData가 있으면 거기서, 없으면 row 자체에서 가져옴 const sourceData = row._sourceData || row; const newValue = sourceData[selectedOption.valueColumn] ?? row[columnField]; return { ...row, [columnField]: newValue }; }); const calculatedData = calculateAll(updatedData); handleDataChange(calculatedData); return; } // 모든 행에 대해 새 값 조회 const updatedData = await Promise.all( tableData.map(async (row) => { let newValue: any = row[columnField]; // 조인 조건 구성 (4가지 소스 타입 지원) const joinConditions: TableJoinCondition[] = selectedOption.conditions.map((cond) => { // sourceType 매핑 let sourceType: "row" | "sourceData" | "formData" | "externalTable"; if (cond.sourceType === "currentRow") { sourceType = "row"; } else if (cond.sourceType === "sourceTable") { sourceType = "sourceData"; } else if (cond.sourceType === "externalTable") { sourceType = "externalTable"; } else { sourceType = "formData"; } return { sourceType, sourceField: cond.sourceField, targetColumn: cond.targetColumn, // 외부 테이블 조회 설정 externalLookup: cond.externalLookup, // 값 변환 설정 전달 (레거시 호환) transform: cond.transform?.enabled ? { tableName: cond.transform.tableName, matchColumn: cond.transform.matchColumn, resultColumn: cond.transform.resultColumn, } : undefined, }; }); // 외부 테이블에서 값 조회 (_sourceData 전달) const sourceData = row._sourceData || row; const value = await fetchExternalValue( selectedOption.tableName, selectedOption.valueColumn, joinConditions, row, sourceData, formData ); if (value !== undefined) { newValue = value; } return { ...row, [columnField]: newValue }; }) ); // 계산 필드 업데이트 const calculatedData = calculateAll(updatedData); handleDataChange(calculatedData); return; } // 기존 columnModes 처리 (레거시 호환) if (!column?.columnModes) return; const selectedMode = column.columnModes.find((mode) => mode.id === optionId); if (!selectedMode) return; // 모든 행에 대해 새 값 조회 const updatedData = await Promise.all( tableData.map(async (row) => { const mapping = selectedMode.valueMapping; let newValue: any = row[columnField]; const sourceData = row._sourceData || row; if (mapping.type === "external" && mapping.externalRef) { const { tableName, valueColumn, joinConditions } = mapping.externalRef; const value = await fetchExternalValue(tableName, valueColumn, joinConditions, row, sourceData, formData); if (value !== undefined) { newValue = value; } } else if (mapping.type === "source" && mapping.sourceField) { newValue = row[mapping.sourceField]; } else if (mapping.type === "internal" && mapping.internalField) { newValue = formData[mapping.internalField]; } return { ...row, [columnField]: newValue }; }) ); // 계산 필드 업데이트 const calculatedData = calculateAll(updatedData); handleDataChange(calculatedData); }, [tableConfig.columns, tableData, formData, calculateAll, handleDataChange] ); // 소스 테이블 정보 const { source, filters, uiConfig } = tableConfig; const sourceTable = source.tableName; const sourceColumns = source.displayColumns; const sourceSearchFields = source.searchColumns; const columnLabels = source.columnLabels || {}; const modalTitle = uiConfig?.modalTitle || "항목 검색 및 선택"; const addButtonText = uiConfig?.addButtonText || "항목 검색"; const multiSelect = uiConfig?.multiSelect ?? true; // 기본 필터 조건 생성 (사전 필터만 - 모달 필터는 ItemSelectionModal에서 처리) const baseFilterCondition: Record = {}; if (filters?.preFilters) { for (const filter of filters.preFilters) { // 간단한 "=" 연산자만 처리 (확장 가능) if (filter.operator === "=") { baseFilterCondition[filter.column] = filter.value; } } } // 모달 필터 설정을 ItemSelectionModal에 전달할 형식으로 변환 const modalFiltersForModal = useMemo(() => { if (!filters?.modalFilters) return []; return filters.modalFilters.map((filter) => ({ column: filter.column, label: filter.label || filter.column, // category 타입을 select로 변환 (ModalFilterConfig 호환) type: filter.type === "category" ? "select" as const : filter.type as "text" | "select", options: filter.options, categoryRef: filter.categoryRef, defaultValue: filter.defaultValue, })); }, [filters?.modalFilters]); return (
{/* 추가 버튼 영역 */}
{tableData.length > 0 && `${tableData.length}개 항목`} {selectedRows.size > 0 && ` (${selectedRows.size}개 선택됨)`} {columns.length > 0 && ( )}
{selectedRows.size > 0 && ( )}
{/* Repeater 테이블 */} {/* 항목 선택 모달 */}
); }