ERP-node/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent...

824 lines
30 KiB
TypeScript
Raw Normal View History

"use client";
import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react";
import { ItemSelectionModal } from "./ItemSelectionModal";
import { RepeaterTable } from "./RepeaterTable";
import { ModalRepeaterTableProps, RepeaterColumnConfig, JoinCondition, DynamicDataSourceOption } from "./types";
import { useCalculation } from "./useCalculation";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { ComponentRendererProps } from "@/types/component";
// ✅ ComponentRendererProps 상속으로 필수 props 자동 확보
export interface ModalRepeaterTableComponentProps extends ComponentRendererProps {
config?: ModalRepeaterTableProps;
// ModalRepeaterTableProps의 개별 prop들도 지원 (호환성)
sourceTable?: string;
sourceColumns?: string[];
sourceSearchFields?: string[];
targetTable?: string;
modalTitle?: string;
modalButtonText?: string;
multiSelect?: boolean;
columns?: RepeaterColumnConfig[];
calculationRules?: any[];
value?: any[];
onChange?: (newData: any[]) => void;
uniqueField?: string;
filterCondition?: Record<string, any>;
companyCode?: string;
}
/**
*
* @param referenceTable (: "customer_item_mapping")
* @param referenceField (: "basic_price")
* @param joinConditions
* @param sourceItem ( )
* @param currentItem ( )
* @returns undefined
*/
async function fetchReferenceValue(
referenceTable: string,
referenceField: string,
joinConditions: JoinCondition[],
sourceItem: any,
currentItem: any
): Promise<any> {
if (joinConditions.length === 0) {
console.warn("⚠️ 조인 조건이 없습니다. 참조 조회를 건너뜁니다.");
return undefined;
}
try {
// 조인 조건을 WHERE 절로 변환
const whereConditions: Record<string, any> = {};
for (const condition of joinConditions) {
const { sourceTable = "target", sourceField, targetField, operator = "=" } = condition;
// 소스 테이블에 따라 값을 가져오기
let value: any;
if (sourceTable === "source") {
// 소스 테이블 (item_info 등): 모달에서 선택한 원본 데이터
value = sourceItem[sourceField];
console.log(` 📘 소스 테이블에서 값 가져오기: ${sourceField} =`, value);
} else {
// 저장 테이블 (sales_order_mng 등): 반복 테이블에 이미 복사된 값
value = currentItem[sourceField];
console.log(` 📗 저장 테이블(반복테이블)에서 값 가져오기: ${sourceField} =`, value);
}
if (value === undefined || value === null) {
console.warn(`⚠️ 조인 조건의 소스 필드 "${sourceField}" 값이 없습니다. (sourceTable: ${sourceTable})`);
return undefined;
}
// 연산자가 "=" 인 경우만 지원 (확장 가능)
if (operator === "=") {
whereConditions[targetField] = value;
} else {
console.warn(`⚠️ 연산자 "${operator}"는 아직 지원되지 않습니다.`);
}
}
console.log(`🔍 참조 조회 API 호출:`, {
table: referenceTable,
field: referenceField,
where: whereConditions,
});
// API 호출: 테이블 데이터 조회 (POST 방식)
const requestBody = {
search: whereConditions, // ✅ filters → search 변경 (백엔드 파라미터명)
size: 1, // 첫 번째 결과만 가져오기
page: 1,
};
console.log("📤 API 요청 Body:", JSON.stringify(requestBody, null, 2));
const response = await apiClient.post(
`/table-management/tables/${referenceTable}/data`,
requestBody
);
console.log("📥 API 전체 응답:", {
success: response.data.success,
dataLength: response.data.data?.data?.length, // ✅ data.data.data 구조
total: response.data.data?.total, // ✅ data.data.total
firstRow: response.data.data?.data?.[0], // ✅ data.data.data[0]
});
if (response.data.success && response.data.data?.data?.length > 0) {
const firstRow = response.data.data.data[0]; // ✅ data.data.data[0]
const value = firstRow[referenceField];
console.log(`✅ 참조 조회 성공:`, {
table: referenceTable,
field: referenceField,
value,
fullRow: firstRow,
});
return value;
} else {
console.warn(`⚠️ 참조 조회 결과 없음:`, {
table: referenceTable,
where: whereConditions,
responseData: response.data.data,
total: response.data.total,
});
return undefined;
}
} catch (error) {
console.error(`❌ 참조 조회 API 오류:`, error);
return undefined;
}
}
export function ModalRepeaterTableComponent({
// ComponentRendererProps (자동 전달)
component,
isDesignMode = false,
isSelected = false,
isInteractive = false,
onClick,
onDragStart,
onDragEnd,
className,
style,
formData,
onFormDataChange,
// ModalRepeaterTable 전용 props
config,
sourceTable: propSourceTable,
sourceColumns: propSourceColumns,
sourceSearchFields: propSourceSearchFields,
targetTable: propTargetTable,
modalTitle: propModalTitle,
modalButtonText: propModalButtonText,
multiSelect: propMultiSelect,
columns: propColumns,
calculationRules: propCalculationRules,
value: propValue,
onChange: propOnChange,
uniqueField: propUniqueField,
filterCondition: propFilterCondition,
companyCode: propCompanyCode,
...props
}: ModalRepeaterTableComponentProps) {
// ✅ config 또는 component.config 또는 개별 prop 우선순위로 병합
const componentConfig = {
...config,
...component?.config,
};
// config prop 우선, 없으면 개별 prop 사용
const sourceTable = componentConfig?.sourceTable || propSourceTable || "";
const targetTable = componentConfig?.targetTable || propTargetTable;
// sourceColumns에서 빈 문자열 필터링
const rawSourceColumns = componentConfig?.sourceColumns || propSourceColumns || [];
const sourceColumns = rawSourceColumns.filter((col: string) => col && col.trim() !== "");
// 모달 컬럼 라벨 (컬럼명 -> 표시 라벨)
const sourceColumnLabels = componentConfig?.sourceColumnLabels || {};
const sourceSearchFields = componentConfig?.sourceSearchFields || propSourceSearchFields || [];
const modalTitle = componentConfig?.modalTitle || propModalTitle || "항목 검색";
const modalButtonText = componentConfig?.modalButtonText || propModalButtonText || "품목 검색";
const multiSelect = componentConfig?.multiSelect ?? propMultiSelect ?? true;
const calculationRules = componentConfig?.calculationRules || propCalculationRules || [];
// ✅ value는 formData[columnName] 우선, 없으면 prop 사용
const columnName = component?.columnName;
const externalValue = (columnName && formData?.[columnName]) || componentConfig?.value || propValue || [];
// 빈 객체 판단 함수 (수정 모달의 실제 데이터는 유지)
const isEmptyRow = (item: any): boolean => {
if (!item || typeof item !== 'object') return true;
// id가 있으면 실제 데이터 (수정 모달)
if (item.id) return false;
// 모든 값이 비어있는지 확인 (계산 필드 제외)
const hasValue = Object.entries(item).some(([key, value]) => {
// 계산 필드나 메타데이터는 제외
if (key.startsWith('_') || key === 'total_amount') return false;
// 실제 값이 있는지 확인
return value !== undefined &&
value !== null &&
value !== '' &&
value !== 0 &&
value !== '0' &&
value !== '0.00';
});
return !hasValue;
};
// 🆕 내부 상태로 데이터 관리 (즉시 UI 반영을 위해)
const [localValue, setLocalValue] = useState<any[]>(() => {
return externalValue.filter((item) => !isEmptyRow(item));
});
// 🆕 외부 값(formData, propValue) 변경 시 내부 상태 동기화
useEffect(() => {
// 빈 객체 필터링
const filteredValue = externalValue.filter((item) => !isEmptyRow(item));
// 외부 값이 변경되었고, 내부 값과 다른 경우에만 동기화
if (JSON.stringify(filteredValue) !== JSON.stringify(localValue)) {
setLocalValue(filteredValue);
}
}, [externalValue]);
// ✅ onChange 래퍼 (기존 onChange 콜백 + onFormDataChange 호출 + 납기일 일괄 적용)
const handleChange = (newData: any[]) => {
// 🆕 납기일 일괄 적용 로직 (납기일 필드가 있는 경우만)
let processedData = newData;
// 납기일 필드 찾기 (item_due_date, delivery_date, due_date 등)
const dateField = columns.find(
(col) =>
col.field === "item_due_date" ||
col.field === "delivery_date" ||
col.field === "due_date"
);
if (dateField && !isDeliveryDateApplied && newData.length > 0) {
// 현재 상태: 납기일이 있는 행과 없는 행 개수 체크
const itemsWithDate = newData.filter((item) => item[dateField.field]);
const itemsWithoutDate = newData.filter((item) => !item[dateField.field]);
// 정확히 1개만 날짜가 있고, 나머지는 모두 비어있을 때 일괄 적용
if (itemsWithDate.length === 1 && itemsWithoutDate.length > 0) {
const selectedDate = itemsWithDate[0][dateField.field];
processedData = newData.map((item) => ({
...item,
[dateField.field]: selectedDate, // 모든 행에 동일한 납기일 적용
}));
setIsDeliveryDateApplied(true); // 플래그 활성화
}
}
// 🆕 수주일 일괄 적용 로직 (order_date 필드가 있는 경우만)
const orderDateField = columns.find(
(col) =>
col.field === "order_date" ||
col.field === "ordered_date"
);
if (orderDateField && !isOrderDateApplied && newData.length > 0) {
// ⚠️ 중요: 원본 newData를 참조해야 납기일의 영향을 받지 않음
const itemsWithOrderDate = newData.filter((item) => item[orderDateField.field]);
const itemsWithoutOrderDate = newData.filter((item) => !item[orderDateField.field]);
// ✅ 조건: 모든 행이 비어있는 초기 상태 → 어느 행에서든 첫 선택 시 전체 적용
if (itemsWithOrderDate.length === 1 && itemsWithoutOrderDate.length === newData.length - 1) {
const selectedOrderDate = itemsWithOrderDate[0][orderDateField.field];
processedData = processedData.map((item) => ({
...item,
[orderDateField.field]: selectedOrderDate,
}));
setIsOrderDateApplied(true); // 플래그 활성화
}
}
// 🆕 내부 상태 즉시 업데이트 (UI 즉시 반영) - 일괄 적용된 데이터로 업데이트
setLocalValue(processedData);
// 기존 onChange 콜백 호출 (호환성)
const externalOnChange = componentConfig?.onChange || propOnChange;
if (externalOnChange) {
externalOnChange(processedData);
}
// 🆕 onFormDataChange 호출하여 EditModal의 groupData 업데이트
if (onFormDataChange && columnName) {
onFormDataChange(columnName, processedData);
}
};
// uniqueField 자동 보정: order_no는 item_info 테이블에 없으므로 item_number로 변경
const rawUniqueField = componentConfig?.uniqueField || propUniqueField;
const uniqueField = rawUniqueField === "order_no" && sourceTable === "item_info"
? "item_number"
: rawUniqueField;
const filterCondition = componentConfig?.filterCondition || propFilterCondition || {};
const companyCode = componentConfig?.companyCode || propCompanyCode;
const [modalOpen, setModalOpen] = useState(false);
// 🆕 납기일 일괄 적용 플래그 (딱 한 번만 실행)
const [isDeliveryDateApplied, setIsDeliveryDateApplied] = useState(false);
// 🆕 수주일 일괄 적용 플래그 (딱 한 번만 실행)
const [isOrderDateApplied, setIsOrderDateApplied] = useState(false);
// 🆕 동적 데이터 소스 활성화 상태 (컬럼별로 현재 선택된 옵션 ID)
const [activeDataSources, setActiveDataSources] = useState<Record<string, string>>({});
// columns가 비어있으면 sourceColumns로부터 자동 생성
const columns = React.useMemo((): RepeaterColumnConfig[] => {
const configuredColumns = componentConfig?.columns || propColumns || [];
if (configuredColumns.length > 0) {
return configuredColumns;
}
// columns가 비어있으면 sourceColumns로부터 자동 생성
if (sourceColumns.length > 0) {
const autoColumns: RepeaterColumnConfig[] = sourceColumns.map((field) => ({
field: field,
label: field, // 필드명을 라벨로 사용 (나중에 설정에서 변경 가능)
editable: false, // 기본적으로 읽기 전용
type: "text" as const,
width: "150px",
}));
return autoColumns;
}
console.warn("⚠️ [ModalRepeaterTable] columns와 sourceColumns 모두 비어있음!");
return [];
}, [componentConfig?.columns, propColumns, sourceColumns]);
// 초기 props 검증
useEffect(() => {
if (rawSourceColumns.length !== sourceColumns.length) {
console.warn(`⚠️ [ModalRepeaterTable] sourceColumns 필터링: ${rawSourceColumns.length}개 → ${sourceColumns.length}`);
}
if (rawUniqueField !== uniqueField) {
console.warn(`⚠️ [ModalRepeaterTable] uniqueField 자동 보정: "${rawUniqueField}" → "${uniqueField}"`);
}
if (columns.length === 0) {
console.error("❌ [ModalRepeaterTable] columns가 비어있습니다!", { sourceColumns });
}
}, []);
// 🆕 저장 요청 시에만 데이터 전달 (beforeFormSave 이벤트 리스너)
useEffect(() => {
const handleSaveRequest = async (event: Event) => {
const componentKey = columnName || component?.id || "modal_repeater_data";
if (localValue.length === 0) {
console.warn("⚠️ [ModalRepeaterTable] 저장할 데이터 없음");
return;
}
// sourceColumns에 포함된 컬럼 제외 (조인된 컬럼 제거)
// 단, columnMappings에 정의된 컬럼은 저장해야 하므로 제외하지 않음
const mappedFields = columns
.filter(col => col.mapping?.type === "source" && col.mapping?.sourceField)
.map(col => col.field);
const filteredData = localValue.map((item: any) => {
const filtered: Record<string, any> = {};
Object.keys(item).forEach((key) => {
// 메타데이터 필드 제외
if (key.startsWith("_")) {
return;
}
// sourceColumns에 포함되어 있지만 columnMappings에도 정의된 경우 → 저장함
if (mappedFields.includes(key)) {
filtered[key] = item[key];
return;
}
// sourceColumns에만 있고 매핑 안 된 경우 → 제외 (조인 전용)
if (sourceColumns.includes(key)) {
return;
}
// 나머지는 모두 저장
filtered[key] = item[key];
});
return filtered;
});
// targetTable 메타데이터를 배열 항목에 추가
const dataWithTargetTable = targetTable
? filteredData.map((item: any) => ({
...item,
_targetTable: targetTable, // 백엔드가 인식할 메타데이터
}))
: filteredData;
// CustomEvent의 detail에 데이터 추가
if (event instanceof CustomEvent && event.detail) {
event.detail.formData[componentKey] = dataWithTargetTable;
console.log("✅ [ModalRepeaterTable] 저장 데이터 준비:", {
key: componentKey,
itemCount: dataWithTargetTable.length,
targetTable: targetTable || "미설정",
});
}
// 기존 onFormDataChange도 호출 (호환성)
if (onFormDataChange) {
onFormDataChange(componentKey, dataWithTargetTable);
}
};
// 저장 버튼 클릭 시 데이터 수집
window.addEventListener("beforeFormSave", handleSaveRequest as EventListener);
return () => {
window.removeEventListener("beforeFormSave", handleSaveRequest as EventListener);
};
}, [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(() => {
if (localValue.length > 0 && calculationRules.length > 0) {
const calculated = calculateAll(localValue);
// 값이 실제로 변경된 경우만 업데이트
if (JSON.stringify(calculated) !== JSON.stringify(localValue)) {
handleChange(calculated);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleAddItems = async (items: any[]) => {
console.log(" handleAddItems 호출:", items.length, "개 항목");
console.log("📋 소스 데이터:", items);
// 매핑 규칙에 따라 데이터 변환 (비동기 처리)
const mappedItems = await Promise.all(items.map(async (sourceItem) => {
const newItem: any = {};
// ⚠️ 중요: reference 매핑은 다른 컬럼에 의존할 수 있으므로
// 1단계: source/manual 매핑을 먼저 처리
// 2단계: reference 매핑을 나중에 처리
const referenceColumns: typeof columns = [];
const otherColumns: typeof columns = [];
for (const col of columns) {
if (col.mapping?.type === "reference") {
referenceColumns.push(col);
} else {
otherColumns.push(col);
}
}
// 1단계: source/manual 컬럼 먼저 처리
for (const col of otherColumns) {
console.log(`🔄 컬럼 "${col.field}" 매핑 처리:`, col.mapping);
// 1. 매핑 규칙이 있는 경우
if (col.mapping) {
if (col.mapping.type === "source") {
// 소스 테이블 컬럼에서 복사
const sourceField = col.mapping.sourceField;
if (sourceField && sourceItem[sourceField] !== undefined) {
newItem[col.field] = sourceItem[sourceField];
console.log(` ✅ 소스 복사: ${sourceField}${col.field}:`, newItem[col.field]);
} else {
console.warn(` ⚠️ 소스 필드 "${sourceField}" 값이 없음`);
}
} else if (col.mapping.type === "manual") {
// 사용자 입력 (빈 값)
newItem[col.field] = undefined;
console.log(` ✏️ 수동 입력 필드`);
}
}
// 2. 매핑 규칙이 없는 경우 - 소스 데이터에서 같은 필드명으로 복사
else if (sourceItem[col.field] !== undefined) {
newItem[col.field] = sourceItem[col.field];
console.log(` 📝 직접 복사: ${col.field}:`, newItem[col.field]);
}
// 3. 기본값 적용
if (col.defaultValue !== undefined && newItem[col.field] === undefined) {
newItem[col.field] = col.defaultValue;
console.log(` 🎯 기본값 적용: ${col.field}:`, col.defaultValue);
}
}
// 2단계: reference 컬럼 처리 (다른 컬럼들이 모두 설정된 후)
console.log("🔗 2단계: reference 컬럼 처리 시작");
for (const col of referenceColumns) {
console.log(`🔄 컬럼 "${col.field}" 참조 매핑 처리:`, col.mapping);
// 외부 테이블 참조 (API 호출)
console.log(` ⏳ 참조 조회 시작: ${col.mapping?.referenceTable}.${col.mapping?.referenceField}`);
try {
const referenceValue = await fetchReferenceValue(
col.mapping!.referenceTable!,
col.mapping!.referenceField!,
col.mapping!.joinCondition || [],
sourceItem,
newItem
);
if (referenceValue !== null && referenceValue !== undefined) {
newItem[col.field] = referenceValue;
console.log(` ✅ 참조 조회 성공: ${col.field}:`, referenceValue);
} else {
newItem[col.field] = undefined;
console.warn(` ⚠️ 참조 조회 결과 없음`);
}
} catch (error) {
console.error(` ❌ 참조 조회 오류:`, error);
newItem[col.field] = undefined;
}
// 기본값 적용
if (col.defaultValue !== undefined && newItem[col.field] === undefined) {
newItem[col.field] = col.defaultValue;
console.log(` 🎯 기본값 적용: ${col.field}:`, col.defaultValue);
}
}
console.log("📦 변환된 항목:", newItem);
return newItem;
}));
// 계산 필드 업데이트
const calculatedItems = calculateAll(mappedItems);
// 기존 데이터에 추가
const newData = [...localValue, ...calculatedItems];
console.log("✅ 최종 데이터:", newData.length, "개 항목");
// ✅ 통합 onChange 호출 (formData 반영 포함)
handleChange(newData);
};
const handleRowChange = (index: number, newRow: any) => {
// 계산 필드 업데이트
const calculatedRow = calculateRow(newRow);
// 데이터 업데이트
const newData = [...localValue];
newData[index] = calculatedRow;
// ✅ 통합 onChange 호출 (formData 반영 포함)
handleChange(newData);
};
const handleRowDelete = (index: number) => {
const newData = localValue.filter((_, i) => i !== index);
// ✅ 통합 onChange 호출 (formData 반영 포함)
handleChange(newData);
};
// 컬럼명 -> 라벨명 매핑 생성 (sourceColumnLabels 우선, 없으면 columns에서 가져옴)
const columnLabels = columns.reduce((acc, col) => {
// sourceColumnLabels에 정의된 라벨 우선 사용
acc[col.field] = sourceColumnLabels[col.field] || col.label;
return acc;
}, { ...sourceColumnLabels } as Record<string, string>);
return (
<div className={cn("space-y-4", className)}>
{/* 추가 버튼 */}
<div className="flex justify-between items-center">
<div className="text-sm text-muted-foreground">
{localValue.length > 0 && `${localValue.length}개 항목`}
</div>
<Button
onClick={() => setModalOpen(true)}
className="h-8 text-xs sm:h-10 sm:text-sm"
>
<Plus className="h-4 w-4 mr-2" />
{modalButtonText}
</Button>
</div>
{/* Repeater 테이블 */}
<RepeaterTable
columns={columns}
data={localValue}
onDataChange={handleChange}
onRowChange={handleRowChange}
onRowDelete={handleRowDelete}
activeDataSources={activeDataSources}
onDataSourceChange={handleDataSourceChange}
/>
{/* 항목 선택 모달 */}
<ItemSelectionModal
open={modalOpen}
onOpenChange={setModalOpen}
sourceTable={sourceTable}
sourceColumns={sourceColumns}
sourceSearchFields={sourceSearchFields}
multiSelect={multiSelect}
filterCondition={filterCondition}
modalTitle={modalTitle}
alreadySelected={localValue}
uniqueField={uniqueField}
onSelect={handleAddItems}
columnLabels={columnLabels}
/>
</div>
);
}