From d550959cb7476112ea77852e08516da7669eda6c Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Tue, 9 Dec 2025 14:55:49 +0900 Subject: [PATCH] =?UTF-8?q?feat(modal-repeater-table):=20=EB=8F=99?= =?UTF-8?q?=EC=A0=81=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=86=8C=EC=8A=A4=20?= =?UTF-8?q?=EC=A0=84=ED=99=98=20=EA=B8=B0=EB=8A=A5=20=EB=B0=8F=20Universal?= =?UTF-8?q?FormModal=20=EC=A0=80=EC=9E=A5=20=EB=B2=84=ED=8A=BC=20=EC=98=B5?= =?UTF-8?q?=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ModalRepeaterTable: 컬럼 헤더 클릭으로 데이터 소스 동적 전환 - 단순 조인, 복합 조인(다중 테이블), 전용 API 호출 지원 - DynamicDataSourceConfig, MultiTableJoinStep 타입 추가 - 설정 패널에 동적 데이터 소스 설정 모달 추가 - UniversalFormModal: showSaveButton 옵션 추가 --- .../ModalRepeaterTableComponent.tsx | 194 ++++- .../ModalRepeaterTableConfigPanel.tsx | 800 +++++++++++++++++- .../modal-repeater-table/RepeaterTable.tsx | 94 +- .../components/modal-repeater-table/types.ts | 108 ++- .../UniversalFormModalComponent.tsx | 60 +- .../UniversalFormModalConfigPanel.tsx | 65 +- .../components/universal-form-modal/config.ts | 1 + 7 files changed, 1234 insertions(+), 88 deletions(-) diff --git a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx index 6e0432d1..92eb4bb7 100644 --- a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx @@ -5,7 +5,7 @@ import { Button } from "@/components/ui/button"; import { Plus } from "lucide-react"; import { ItemSelectionModal } from "./ItemSelectionModal"; import { RepeaterTable } from "./RepeaterTable"; -import { ModalRepeaterTableProps, RepeaterColumnConfig, JoinCondition } from "./types"; +import { ModalRepeaterTableProps, RepeaterColumnConfig, JoinCondition, DynamicDataSourceOption } from "./types"; import { useCalculation } from "./useCalculation"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; @@ -293,6 +293,9 @@ export function ModalRepeaterTableComponent({ // 🆕 수주일 일괄 적용 플래그 (딱 한 번만 실행) const [isOrderDateApplied, setIsOrderDateApplied] = useState(false); + + // 🆕 동적 데이터 소스 활성화 상태 (컬럼별로 현재 선택된 옵션 ID) + const [activeDataSources, setActiveDataSources] = useState>({}); // columns가 비어있으면 sourceColumns로부터 자동 생성 const columns = React.useMemo((): RepeaterColumnConfig[] => { @@ -409,6 +412,193 @@ export function ModalRepeaterTableComponent({ }, [localValue, columnName, component?.id, onFormDataChange, targetTable]); const { calculateRow, calculateAll } = useCalculation(calculationRules); + + /** + * 동적 데이터 소스 변경 시 호출 + * 해당 컬럼의 모든 행 데이터를 새로운 소스에서 다시 조회 + */ + const handleDataSourceChange = async (columnField: string, optionId: string) => { + console.log(`🔄 데이터 소스 변경: ${columnField} → ${optionId}`); + + // 활성화 상태 업데이트 + setActiveDataSources((prev) => ({ + ...prev, + [columnField]: optionId, + })); + + // 해당 컬럼 찾기 + const column = columns.find((col) => col.field === columnField); + if (!column?.dynamicDataSource?.enabled) { + console.warn(`⚠️ 컬럼 "${columnField}"에 동적 데이터 소스가 설정되지 않음`); + return; + } + + // 선택된 옵션 찾기 + const option = column.dynamicDataSource.options.find((opt) => opt.id === optionId); + if (!option) { + console.warn(`⚠️ 옵션 "${optionId}"을 찾을 수 없음`); + return; + } + + // 모든 행에 대해 새 값 조회 + const updatedData = await Promise.all( + localValue.map(async (row, index) => { + try { + const newValue = await fetchDynamicValue(option, row); + console.log(` ✅ 행 ${index}: ${columnField} = ${newValue}`); + return { + ...row, + [columnField]: newValue, + }; + } catch (error) { + console.error(` ❌ 행 ${index} 조회 실패:`, error); + return row; + } + }) + ); + + // 계산 필드 업데이트 후 데이터 반영 + const calculatedData = calculateAll(updatedData); + handleChange(calculatedData); + }; + + /** + * 동적 데이터 소스 옵션에 따라 값 조회 + */ + async function fetchDynamicValue( + option: DynamicDataSourceOption, + rowData: any + ): Promise { + if (option.sourceType === "table" && option.tableConfig) { + // 테이블 직접 조회 (단순 조인) + const { tableName, valueColumn, joinConditions } = option.tableConfig; + + const whereConditions: Record = {}; + for (const cond of joinConditions) { + const value = rowData[cond.sourceField]; + if (value === undefined || value === null) { + console.warn(`⚠️ 조인 조건의 소스 필드 "${cond.sourceField}" 값이 없음`); + return undefined; + } + whereConditions[cond.targetField] = value; + } + + console.log(`🔍 테이블 조회: ${tableName}`, whereConditions); + + 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; + + } else if (option.sourceType === "multiTable" && option.multiTableConfig) { + // 테이블 복합 조인 (2개 이상 테이블 순차 조인) + const { joinChain, valueColumn } = option.multiTableConfig; + + if (!joinChain || joinChain.length === 0) { + console.warn("⚠️ 조인 체인이 비어있습니다."); + return undefined; + } + + console.log(`🔗 복합 조인 시작: ${joinChain.length}단계`); + + // 현재 값을 추적 (첫 단계는 현재 행에서 시작) + let currentValue: any = null; + let currentRow: any = null; + + for (let i = 0; i < joinChain.length; i++) { + const step = joinChain[i]; + const { tableName, joinCondition, outputField } = step; + + // 조인 조건 값 가져오기 + let fromValue: any; + if (i === 0) { + // 첫 번째 단계: 현재 행에서 값 가져오기 + fromValue = rowData[joinCondition.fromField]; + console.log(` 📍 단계 ${i + 1}: 현재행.${joinCondition.fromField} = ${fromValue}`); + } else { + // 이후 단계: 이전 조회 결과에서 값 가져오기 + fromValue = currentRow?.[joinCondition.fromField] || currentValue; + console.log(` 📍 단계 ${i + 1}: 이전결과.${joinCondition.fromField} = ${fromValue}`); + } + + if (fromValue === undefined || fromValue === null) { + console.warn(`⚠️ 단계 ${i + 1}: 조인 조건 값이 없습니다. (${joinCondition.fromField})`); + return undefined; + } + + // 테이블 조회 + const whereConditions: Record = { + [joinCondition.toField]: fromValue + }; + + console.log(` 🔍 단계 ${i + 1}: ${tableName} 조회`, whereConditions); + + try { + 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) { + currentRow = response.data.data.data[0]; + currentValue = outputField ? currentRow[outputField] : currentRow; + console.log(` ✅ 단계 ${i + 1} 성공:`, { outputField, value: currentValue }); + } else { + console.warn(` ⚠️ 단계 ${i + 1}: 조회 결과 없음`); + return undefined; + } + } catch (error) { + console.error(` ❌ 단계 ${i + 1} 조회 실패:`, error); + return undefined; + } + } + + // 최종 값 반환 (마지막 테이블에서 valueColumn 가져오기) + const finalValue = currentRow?.[valueColumn]; + console.log(`🎯 복합 조인 완료: ${valueColumn} = ${finalValue}`); + return finalValue; + + } else if (option.sourceType === "api" && option.apiConfig) { + // 전용 API 호출 (복잡한 다중 조인) + const { endpoint, method = "GET", parameterMappings, responseValueField } = option.apiConfig; + + // 파라미터 빌드 + const params: Record = {}; + for (const mapping of parameterMappings) { + const value = rowData[mapping.sourceField]; + if (value !== undefined && value !== null) { + params[mapping.paramName] = value; + } + } + + console.log(`🔍 API 호출: ${method} ${endpoint}`, params); + + let response; + if (method === "POST") { + response = await apiClient.post(endpoint, params); + } else { + response = await apiClient.get(endpoint, { params }); + } + + if (response.data.success && response.data.data) { + // responseValueField로 값 추출 (중첩 경로 지원: "data.price") + const keys = responseValueField.split("."); + let value = response.data.data; + for (const key of keys) { + value = value?.[key]; + } + return value; + } + return undefined; + } + + return undefined; + } // 초기 데이터에 계산 필드 적용 useEffect(() => { @@ -579,6 +769,8 @@ export function ModalRepeaterTableComponent({ onDataChange={handleChange} onRowChange={handleRowChange} onRowDelete={handleRowDelete} + activeDataSources={activeDataSources} + onDataSourceChange={handleDataSourceChange} /> {/* 항목 선택 모달 */} diff --git a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableConfigPanel.tsx b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableConfigPanel.tsx index 507ab54d..914e34f1 100644 --- a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableConfigPanel.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableConfigPanel.tsx @@ -9,7 +9,8 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; import { Plus, X, Check, ChevronsUpDown } from "lucide-react"; -import { ModalRepeaterTableProps, RepeaterColumnConfig, ColumnMapping, CalculationRule } from "./types"; +import { ModalRepeaterTableProps, RepeaterColumnConfig, ColumnMapping, CalculationRule, DynamicDataSourceConfig, DynamicDataSourceOption, MultiTableJoinStep } from "./types"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"; import { tableManagementApi } from "@/lib/api/tableManagement"; import { cn } from "@/lib/utils"; @@ -169,6 +170,10 @@ export function ModalRepeaterTableConfigPanel({ const [openTableCombo, setOpenTableCombo] = useState(false); const [openTargetTableCombo, setOpenTargetTableCombo] = useState(false); const [openUniqueFieldCombo, setOpenUniqueFieldCombo] = useState(false); + + // 동적 데이터 소스 설정 모달 + const [dynamicSourceModalOpen, setDynamicSourceModalOpen] = useState(false); + const [editingDynamicSourceColumnIndex, setEditingDynamicSourceColumnIndex] = useState(null); // config 변경 시 localConfig 동기화 (cleanupInitialConfig 적용) useEffect(() => { @@ -397,6 +402,101 @@ export function ModalRepeaterTableConfigPanel({ updateConfig({ calculationRules: rules }); }; + // 동적 데이터 소스 설정 함수들 + const openDynamicSourceModal = (columnIndex: number) => { + setEditingDynamicSourceColumnIndex(columnIndex); + setDynamicSourceModalOpen(true); + }; + + const toggleDynamicDataSource = (columnIndex: number, enabled: boolean) => { + const columns = [...(localConfig.columns || [])]; + if (enabled) { + columns[columnIndex] = { + ...columns[columnIndex], + dynamicDataSource: { + enabled: true, + options: [], + }, + }; + } else { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { dynamicDataSource, ...rest } = columns[columnIndex]; + columns[columnIndex] = rest; + } + updateConfig({ columns }); + }; + + const addDynamicSourceOption = (columnIndex: number) => { + const columns = [...(localConfig.columns || [])]; + const col = columns[columnIndex]; + const newOption: DynamicDataSourceOption = { + id: `option_${Date.now()}`, + label: "새 옵션", + sourceType: "table", + tableConfig: { + tableName: "", + valueColumn: "", + joinConditions: [], + }, + }; + + columns[columnIndex] = { + ...col, + dynamicDataSource: { + ...col.dynamicDataSource!, + enabled: true, + options: [...(col.dynamicDataSource?.options || []), newOption], + }, + }; + updateConfig({ columns }); + }; + + const updateDynamicSourceOption = (columnIndex: number, optionIndex: number, updates: Partial) => { + const columns = [...(localConfig.columns || [])]; + const col = columns[columnIndex]; + const options = [...(col.dynamicDataSource?.options || [])]; + options[optionIndex] = { ...options[optionIndex], ...updates }; + + columns[columnIndex] = { + ...col, + dynamicDataSource: { + ...col.dynamicDataSource!, + options, + }, + }; + updateConfig({ columns }); + }; + + const removeDynamicSourceOption = (columnIndex: number, optionIndex: number) => { + const columns = [...(localConfig.columns || [])]; + const col = columns[columnIndex]; + const options = [...(col.dynamicDataSource?.options || [])]; + options.splice(optionIndex, 1); + + columns[columnIndex] = { + ...col, + dynamicDataSource: { + ...col.dynamicDataSource!, + options, + }, + }; + updateConfig({ columns }); + }; + + const setDefaultDynamicSourceOption = (columnIndex: number, optionId: string) => { + const columns = [...(localConfig.columns || [])]; + const col = columns[columnIndex]; + + columns[columnIndex] = { + ...col, + dynamicDataSource: { + ...col.dynamicDataSource!, + defaultOptionId: optionId, + }, + }; + updateConfig({ columns }); + }; + return (
{/* 소스/저장 테이블 설정 */} @@ -1327,6 +1427,60 @@ export function ModalRepeaterTableConfigPanel({ )}
+ + {/* 6. 동적 데이터 소스 설정 */} +
+
+ + toggleDynamicDataSource(index, checked)} + /> +
+

+ 컬럼 헤더 클릭으로 데이터 소스 전환 (예: 거래처별 단가, 품목별 단가) +

+ + {col.dynamicDataSource?.enabled && ( +
+
+ + {col.dynamicDataSource.options.length}개 옵션 설정됨 + + +
+ + {/* 옵션 미리보기 */} + {col.dynamicDataSource.options.length > 0 && ( +
+ {col.dynamicDataSource.options.map((opt) => ( + + {opt.label} + {col.dynamicDataSource?.defaultOptionId === opt.id && " (기본)"} + + ))} +
+ )} +
+ )} +
))} @@ -1493,6 +1647,650 @@ export function ModalRepeaterTableConfigPanel({ + + {/* 동적 데이터 소스 설정 모달 */} + + + + + 동적 데이터 소스 설정 + {editingDynamicSourceColumnIndex !== null && localConfig.columns?.[editingDynamicSourceColumnIndex] && ( + + ({localConfig.columns[editingDynamicSourceColumnIndex].label}) + + )} + + + 컬럼 헤더 클릭 시 선택할 수 있는 데이터 소스 옵션을 설정합니다 + + + + {editingDynamicSourceColumnIndex !== null && localConfig.columns?.[editingDynamicSourceColumnIndex] && ( +
+ {/* 옵션 목록 */} +
+ {(localConfig.columns[editingDynamicSourceColumnIndex].dynamicDataSource?.options || []).map((option, optIndex) => ( +
+
+
+ 옵션 {optIndex + 1} + {localConfig.columns![editingDynamicSourceColumnIndex].dynamicDataSource?.defaultOptionId === option.id && ( + 기본 + )} +
+
+ {localConfig.columns![editingDynamicSourceColumnIndex].dynamicDataSource?.defaultOptionId !== option.id && ( + + )} + +
+
+ + {/* 옵션 라벨 */} +
+ + updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, { label: e.target.value })} + placeholder="예: 거래처별 단가" + className="h-8 text-xs" + /> +
+ + {/* 소스 타입 */} +
+ + +
+ + {/* 테이블 직접 조회 설정 */} + {option.sourceType === "table" && ( +
+

테이블 조회 설정

+ + {/* 참조 테이블 */} +
+ + +
+ + {/* 값 컬럼 */} +
+ + updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, { + tableConfig: { ...option.tableConfig!, valueColumn: value }, + })} + /> +
+ + {/* 조인 조건 */} +
+
+ + +
+ + {(option.tableConfig?.joinConditions || []).map((cond, condIndex) => ( +
+ + = + { + const newConditions = [...(option.tableConfig?.joinConditions || [])]; + newConditions[condIndex] = { ...newConditions[condIndex], targetField: value }; + updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, { + tableConfig: { ...option.tableConfig!, joinConditions: newConditions }, + }); + }} + /> + +
+ ))} +
+
+ )} + + {/* 테이블 복합 조인 설정 (2개 이상 테이블) */} + {option.sourceType === "multiTable" && ( +
+
+

복합 조인 설정

+

+ 여러 테이블을 순차적으로 조인합니다 +

+
+ + {/* 조인 체인 */} +
+
+ + +
+ + {/* 시작점 안내 */} +
+

시작: 현재 행 데이터

+

+ 첫 번째 조인은 현재 행의 필드에서 시작합니다 +

+
+ + {/* 조인 단계들 */} + {(option.multiTableConfig?.joinChain || []).map((step, stepIndex) => ( +
+
+
+
+ {stepIndex + 1} +
+ 조인 단계 {stepIndex + 1} +
+ +
+ + {/* 조인할 테이블 */} +
+ + +
+ + {/* 조인 조건 */} +
+
+ + {stepIndex === 0 ? ( + + ) : ( + { + const newChain = [...(option.multiTableConfig?.joinChain || [])]; + newChain[stepIndex] = { + ...newChain[stepIndex], + joinCondition: { ...newChain[stepIndex].joinCondition, fromField: e.target.value } + }; + updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, { + multiTableConfig: { ...option.multiTableConfig!, joinChain: newChain }, + }); + }} + placeholder={option.multiTableConfig?.joinChain[stepIndex - 1]?.outputField || "이전 출력 필드"} + className="h-8 text-xs" + /> + )} +
+ +
+ = +
+ +
+ + { + const newChain = [...(option.multiTableConfig?.joinChain || [])]; + newChain[stepIndex] = { + ...newChain[stepIndex], + joinCondition: { ...newChain[stepIndex].joinCondition, toField: value } + }; + updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, { + multiTableConfig: { ...option.multiTableConfig!, joinChain: newChain }, + }); + }} + /> +
+
+ + {/* 다음 단계로 전달할 필드 */} +
+ + { + const newChain = [...(option.multiTableConfig?.joinChain || [])]; + newChain[stepIndex] = { ...newChain[stepIndex], outputField: value }; + updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, { + multiTableConfig: { ...option.multiTableConfig!, joinChain: newChain }, + }); + }} + /> +

+ {stepIndex < (option.multiTableConfig?.joinChain.length || 0) - 1 + ? "다음 조인 단계에서 사용할 필드" + : "마지막 단계면 비워두세요"} +

+
+ + {/* 조인 미리보기 */} + {step.tableName && step.joinCondition.fromField && step.joinCondition.toField && ( +
+ + {stepIndex === 0 ? "현재행" : option.multiTableConfig?.joinChain[stepIndex - 1]?.tableName} + + .{step.joinCondition.fromField} + = + {step.tableName} + .{step.joinCondition.toField} + {step.outputField && ( + + → {step.outputField} + + )} +
+ )} +
+ ))} + + {/* 조인 체인이 없을 때 안내 */} + {(!option.multiTableConfig?.joinChain || option.multiTableConfig.joinChain.length === 0) && ( +
+

+ 조인 체인이 없습니다 +

+

+ "조인 추가" 버튼을 클릭하여 테이블 조인을 설정하세요 +

+
+ )} +
+ + {/* 최종 값 컬럼 */} + {option.multiTableConfig?.joinChain && option.multiTableConfig.joinChain.length > 0 && ( +
+ + { + updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, { + multiTableConfig: { ...option.multiTableConfig!, valueColumn: value }, + }); + }} + /> +

+ 마지막 테이블에서 가져올 값 +

+
+ )} + + {/* 전체 조인 경로 미리보기 */} + {option.multiTableConfig?.joinChain && option.multiTableConfig.joinChain.length > 0 && ( +
+

조인 경로 미리보기

+
+ {option.multiTableConfig.joinChain.map((step, idx) => ( +
+ {idx === 0 && ( + <> + 현재행 + .{step.joinCondition.fromField} + + + )} + {step.tableName} + .{step.joinCondition.toField} + {step.outputField && idx < option.multiTableConfig!.joinChain.length - 1 && ( + <> + + {step.outputField} + + )} +
+ ))} + {option.multiTableConfig.valueColumn && ( +
+ 최종 값: + {option.multiTableConfig.joinChain[option.multiTableConfig.joinChain.length - 1]?.tableName}.{option.multiTableConfig.valueColumn} +
+ )} +
+
+ )} +
+ )} + + {/* API 호출 설정 */} + {option.sourceType === "api" && ( +
+

API 호출 설정

+

+ 복잡한 다중 조인은 백엔드 API로 처리합니다 +

+ + {/* API 엔드포인트 */} +
+ + updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, { + apiConfig: { ...option.apiConfig!, endpoint: e.target.value }, + })} + placeholder="/api/price/customer" + className="h-8 text-xs font-mono" + /> +
+ + {/* HTTP 메서드 */} +
+ + +
+ + {/* 파라미터 매핑 */} +
+
+ + +
+ + {(option.apiConfig?.parameterMappings || []).map((mapping, mapIndex) => ( +
+ { + const newMappings = [...(option.apiConfig?.parameterMappings || [])]; + newMappings[mapIndex] = { ...newMappings[mapIndex], paramName: e.target.value }; + updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, { + apiConfig: { ...option.apiConfig!, parameterMappings: newMappings }, + }); + }} + placeholder="파라미터명" + className="h-7 text-[10px] flex-1" + /> + = + + +
+ ))} +
+ + {/* 응답 값 필드 */} +
+ + updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, { + apiConfig: { ...option.apiConfig!, responseValueField: e.target.value }, + })} + placeholder="price (또는 data.price)" + className="h-8 text-xs font-mono" + /> +

+ API 응답에서 값을 가져올 필드 (중첩 경로 지원: data.price) +

+
+
+ )} +
+ ))} + + {/* 옵션 추가 버튼 */} + +
+ + {/* 안내 */} +
+

사용 예시

+
    +
  • - 거래처별 단가: customer_item_price 테이블에서 조회
  • +
  • - 품목별 단가: item_info 테이블에서 기준 단가 조회
  • +
  • - 계약 단가: 전용 API로 복잡한 조인 처리
  • +
+
+
+ )} + + + + +
+
); } diff --git a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx index 703256b2..869884a7 100644 --- a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx @@ -4,7 +4,8 @@ import React, { useState, useEffect } from "react"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Button } from "@/components/ui/button"; -import { Trash2 } from "lucide-react"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Trash2, ChevronDown, Check } from "lucide-react"; import { RepeaterColumnConfig } from "./types"; import { cn } from "@/lib/utils"; @@ -14,6 +15,9 @@ interface RepeaterTableProps { onDataChange: (newData: any[]) => void; onRowChange: (index: number, newRow: any) => void; onRowDelete: (index: number) => void; + // 동적 데이터 소스 관련 + activeDataSources?: Record; // 컬럼별 현재 활성화된 데이터 소스 ID + onDataSourceChange?: (columnField: string, optionId: string) => void; // 데이터 소스 변경 콜백 } export function RepeaterTable({ @@ -22,11 +26,16 @@ export function RepeaterTable({ onDataChange, onRowChange, onRowDelete, + activeDataSources = {}, + onDataSourceChange, }: RepeaterTableProps) { const [editingCell, setEditingCell] = useState<{ rowIndex: number; field: string; } | null>(null); + + // 동적 데이터 소스 Popover 열림 상태 + const [openPopover, setOpenPopover] = useState(null); // 데이터 변경 감지 (필요시 활성화) // useEffect(() => { @@ -144,16 +153,79 @@ export function RepeaterTable({ # - {columns.map((col) => ( - - {col.label} - {col.required && *} - - ))} + {columns.map((col) => { + const hasDynamicSource = col.dynamicDataSource?.enabled && col.dynamicDataSource.options.length > 0; + const activeOptionId = activeDataSources[col.field] || col.dynamicDataSource?.defaultOptionId; + const activeOption = hasDynamicSource + ? col.dynamicDataSource!.options.find(opt => opt.id === activeOptionId) || col.dynamicDataSource!.options[0] + : null; + + return ( + + {hasDynamicSource ? ( + setOpenPopover(open ? col.field : null)} + > + + + + +
+ 데이터 소스 선택 +
+ {col.dynamicDataSource!.options.map((option) => ( + + ))} +
+
+ ) : ( + <> + {col.label} + {col.required && *} + + )} + + ); + })} 삭제 diff --git a/frontend/lib/registry/components/modal-repeater-table/types.ts b/frontend/lib/registry/components/modal-repeater-table/types.ts index c0cac4a9..028a892b 100644 --- a/frontend/lib/registry/components/modal-repeater-table/types.ts +++ b/frontend/lib/registry/components/modal-repeater-table/types.ts @@ -10,7 +10,7 @@ export interface ModalRepeaterTableProps { sourceColumnLabels?: Record; // 모달 컬럼 라벨 (컬럼명 -> 표시 라벨) sourceSearchFields?: string[]; // 검색 가능한 필드들 - // 🆕 저장 대상 테이블 설정 + // 저장 대상 테이블 설정 targetTable?: string; // 저장할 테이블 (예: "sales_order_mng") // 모달 설정 @@ -25,14 +25,14 @@ export interface ModalRepeaterTableProps { calculationRules?: CalculationRule[]; // 자동 계산 규칙 // 데이터 - value: any[]; // 현재 추가된 항목들 - onChange: (newData: any[]) => void; // 데이터 변경 콜백 + value: Record[]; // 현재 추가된 항목들 + onChange: (newData: Record[]) => void; // 데이터 변경 콜백 // 중복 체크 uniqueField?: string; // 중복 체크할 필드 (예: "item_code") // 필터링 - filterCondition?: Record; + filterCondition?: Record; companyCode?: string; // 스타일 @@ -47,11 +47,92 @@ export interface RepeaterColumnConfig { calculated?: boolean; // 계산 필드 여부 width?: string; // 컬럼 너비 required?: boolean; // 필수 입력 여부 - defaultValue?: any; // 기본값 + defaultValue?: string | number | boolean; // 기본값 selectOptions?: { value: string; label: string }[]; // select일 때 옵션 - - // 🆕 컬럼 매핑 설정 + + // 컬럼 매핑 설정 mapping?: ColumnMapping; // 이 컬럼의 데이터를 어디서 가져올지 설정 + + // 동적 데이터 소스 (컬럼 헤더 클릭으로 데이터 소스 전환) + dynamicDataSource?: DynamicDataSourceConfig; +} + +/** + * 동적 데이터 소스 설정 + * 컬럼 헤더를 클릭하여 데이터 소스를 전환할 수 있는 기능 + * 예: 거래처별 단가, 품목별 단가, 기준 단가 등을 선택 + */ +export interface DynamicDataSourceConfig { + enabled: boolean; + options: DynamicDataSourceOption[]; + defaultOptionId?: string; // 기본 선택 옵션 ID +} + +/** + * 동적 데이터 소스 옵션 + * 각 옵션은 다른 테이블/API에서 데이터를 가져오는 방법을 정의 + */ +export interface DynamicDataSourceOption { + id: string; + label: string; // 표시 라벨 (예: "거래처별 단가") + + // 조회 방식 + sourceType: "table" | "multiTable" | "api"; + + // 테이블 직접 조회 (단순 조인 - 1개 테이블) + tableConfig?: { + tableName: string; // 참조 테이블명 + valueColumn: string; // 가져올 값 컬럼 + joinConditions: { + sourceField: string; // 현재 행의 필드 + targetField: string; // 참조 테이블의 필드 + }[]; + }; + + // 테이블 복합 조인 (2개 이상 테이블 조인) + multiTableConfig?: { + // 조인 체인 정의 (순서대로 조인) + joinChain: MultiTableJoinStep[]; + // 최종적으로 가져올 값 컬럼 (마지막 테이블에서) + valueColumn: string; + }; + + // 전용 API 호출 (복잡한 다중 조인) + apiConfig?: { + endpoint: string; // API 엔드포인트 (예: "/api/price/customer") + method?: "GET" | "POST"; // HTTP 메서드 (기본: GET) + parameterMappings: { + paramName: string; // API 파라미터명 + sourceField: string; // 현재 행의 필드 + }[]; + responseValueField: string; // 응답에서 값을 가져올 필드 + }; +} + +/** + * 복합 조인 단계 정의 + * 예: item_info.item_number → customer_item.item_code → customer_item.id → customer_item_price.customer_item_id + */ +export interface MultiTableJoinStep { + // 조인할 테이블 + tableName: string; + // 조인 조건 + joinCondition: { + // 이전 단계의 필드 (첫 번째 단계는 현재 행의 필드) + fromField: string; + // 이 테이블의 필드 + toField: string; + }; + // 다음 단계로 전달할 필드 (다음 조인에 사용) + outputField?: string; + // 추가 필터 조건 (선택사항) + additionalFilters?: { + field: string; + operator: "=" | "!=" | ">" | "<" | ">=" | "<="; + value: string | number | boolean; + // 값이 현재 행에서 오는 경우 + valueFromField?: string; + }[]; } /** @@ -61,16 +142,16 @@ export interface RepeaterColumnConfig { export interface ColumnMapping { /** 매핑 타입 */ type: "source" | "reference" | "manual"; - + /** 매핑 타입별 설정 */ // type: "source" - 소스 테이블 (모달에서 선택한 항목)의 컬럼에서 가져오기 sourceField?: string; // 소스 테이블의 컬럼명 (예: "item_name") - + // type: "reference" - 외부 테이블 참조 (조인) referenceTable?: string; // 참조 테이블명 (예: "customer_item_mapping") referenceField?: string; // 참조 테이블에서 가져올 컬럼 (예: "basic_price") joinCondition?: JoinCondition[]; // 조인 조건 - + // type: "manual" - 사용자가 직접 입력 } @@ -101,11 +182,10 @@ export interface ItemSelectionModalProps { sourceColumns: string[]; sourceSearchFields?: string[]; multiSelect?: boolean; - filterCondition?: Record; + filterCondition?: Record; modalTitle: string; - alreadySelected: any[]; // 이미 선택된 항목들 (중복 방지용) + alreadySelected: Record[]; // 이미 선택된 항목들 (중복 방지용) uniqueField?: string; - onSelect: (items: any[]) => void; + onSelect: (items: Record[]) => void; columnLabels?: Record; // 컬럼명 -> 라벨명 매핑 } - diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx index 11b3aa43..3fa8f623 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -1027,7 +1027,7 @@ export function UniversalFormModalComponent({ }} disabled={isDisabled} > - + @@ -1409,47 +1409,37 @@ export function UniversalFormModalComponent({ {/* 섹션들 */}
{config.sections.map((section) => renderSection(section))}
- {/* 버튼 영역 */} -
- {config.modal.showResetButton && ( + {/* 버튼 영역 - 저장 버튼이 표시될 때만 렌더링 */} + {config.modal.showSaveButton !== false && ( +
+ {config.modal.showResetButton && ( + + )} - )} - - -
+
+ )} {/* 삭제 확인 다이얼로그 */} - + diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx index e503ebfb..3ce7477a 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx @@ -402,21 +402,26 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor -
- - updateModalConfig({ saveButtonText: e.target.value })} - className="h-7 text-xs mt-1" - /> -
-
- - updateModalConfig({ cancelButtonText: e.target.value })} - className="h-7 text-xs mt-1" - /> +
+
+ 저장 버튼 표시 + updateModalConfig({ showSaveButton: checked })} + /> +
+ ButtonPrimary 컴포넌트로 저장 버튼을 별도 구성할 경우 끄세요 + + {config.modal.showSaveButton !== false && ( +
+ + updateModalConfig({ saveButtonText: e.target.value })} + className="h-7 text-xs mt-1" + /> +
+ )}
@@ -1896,7 +1901,7 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
테이블 참조: DB 테이블에서 옵션 목록을 가져옵니다.
- + - 예: dept_info (부서 테이블) + 드롭다운 목록을 가져올 테이블을 선택하세요
- + @@ -1933,13 +1938,17 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor }, }) } - placeholder="dept_code" - className="h-6 text-[10px] mt-1" + placeholder="customer_code" + className="h-7 text-xs mt-1" /> - 선택 시 실제 저장되는 값 (예: D001) + + 참조 테이블에서 조인할 컬럼을 선택하세요 +
+ 예: customer_code, customer_id +
- + @@ -1950,10 +1959,14 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor }, }) } - placeholder="dept_name" - className="h-6 text-[10px] mt-1" + placeholder="customer_name" + className="h-7 text-xs mt-1" /> - 드롭다운에 보여질 텍스트 (예: 영업부) + + 드롭다운에 표시할 컬럼을 선택하세요 +
+ 예: customer_name, company_name +
)} diff --git a/frontend/lib/registry/components/universal-form-modal/config.ts b/frontend/lib/registry/components/universal-form-modal/config.ts index 5383512b..66644576 100644 --- a/frontend/lib/registry/components/universal-form-modal/config.ts +++ b/frontend/lib/registry/components/universal-form-modal/config.ts @@ -12,6 +12,7 @@ export const defaultConfig: UniversalFormModalConfig = { size: "lg", closeOnOutsideClick: false, showCloseButton: true, + showSaveButton: true, saveButtonText: "저장", cancelButtonText: "취소", showResetButton: false,