feat(modal-repeater-table): 동적 데이터 소스 전환 기능 및 UniversalFormModal 저장 버튼 옵션 추가

- ModalRepeaterTable: 컬럼 헤더 클릭으로 데이터 소스 동적 전환
- 단순 조인, 복합 조인(다중 테이블), 전용 API 호출 지원
- DynamicDataSourceConfig, MultiTableJoinStep 타입 추가
- 설정 패널에 동적 데이터 소스 설정 모달 추가
- UniversalFormModal: showSaveButton 옵션 추가
This commit is contained in:
SeongHyun Kim
2025-12-09 14:55:49 +09:00
parent 7ac6bbc2c6
commit d550959cb7
7 changed files with 1234 additions and 88 deletions

View File

@@ -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<Record<string, string>>({});
// 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<any> {
if (option.sourceType === "table" && option.tableConfig) {
// 테이블 직접 조회 (단순 조인)
const { tableName, valueColumn, joinConditions } = option.tableConfig;
const whereConditions: Record<string, any> = {};
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<string, any> = {
[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<string, any> = {};
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}
/>
{/* 항목 선택 모달 */}