ERP-node/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx

893 lines
32 KiB
TypeScript

"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<any> {
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<any> {
// 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<any> {
if (joinConditions.length === 0) {
return undefined;
}
try {
const whereConditions: Record<string, any> = {};
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<any[]>([]);
// 모달 상태
const [modalOpen, setModalOpen] = useState(false);
// 체크박스 선택 상태
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
// 너비 조정 트리거 (홀수: 자동맞춤, 짝수: 균등분배)
const [widthTrigger, setWidthTrigger] = useState(0);
// 동적 데이터 소스 활성화 상태
const [activeDataSources, setActiveDataSources] = useState<Record<string, string>>({});
// 날짜 일괄 적용 완료 플래그 (컬럼별로 한 번만 적용)
const [batchAppliedFields, setBatchAppliedFields] = useState<Set<string>>(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;
}
// 부모에서 값 받기 (receiveFromParent)
if (col.receiveFromParent) {
const parentField = col.parentFieldName || col.field;
if (formData[parentField] !== undefined) {
newItem[col.field] = formData[parentField];
}
}
}
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<string, any> = {};
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 (
<div className={cn("space-y-4", className)}>
{/* 추가 버튼 영역 */}
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
{tableData.length > 0 && `${tableData.length}개 항목`}
{selectedRows.size > 0 && ` (${selectedRows.size}개 선택됨)`}
</span>
{columns.length > 0 && (
<Button
variant="outline"
size="sm"
onClick={() => setWidthTrigger((prev) => prev + 1)}
className="h-7 text-xs px-2"
title={widthTrigger % 2 === 0 ? "내용에 맞게 자동 조정" : "균등 분배"}
>
{widthTrigger % 2 === 0 ? (
<>
<AlignJustify className="h-3.5 w-3.5 mr-1" />
</>
) : (
<>
<Columns className="h-3.5 w-3.5 mr-1" />
</>
)}
</Button>
)}
</div>
<div className="flex gap-2">
{selectedRows.size > 0 && (
<Button
variant="destructive"
onClick={handleBulkDelete}
className="h-8 text-xs sm:h-10 sm:text-sm"
>
({selectedRows.size})
</Button>
)}
<Button
onClick={() => setModalOpen(true)}
className="h-8 text-xs sm:h-10 sm:text-sm"
>
<Plus className="h-4 w-4 mr-2" />
{addButtonText}
</Button>
</div>
</div>
{/* Repeater 테이블 */}
<RepeaterTable
columns={columns}
data={tableData}
onDataChange={handleDataChange}
onRowChange={handleRowChange}
onRowDelete={handleRowDelete}
activeDataSources={activeDataSources}
onDataSourceChange={handleDataSourceChange}
selectedRows={selectedRows}
onSelectionChange={setSelectedRows}
equalizeWidthsTrigger={widthTrigger}
/>
{/* 항목 선택 모달 */}
<ItemSelectionModal
open={modalOpen}
onOpenChange={setModalOpen}
sourceTable={sourceTable}
sourceColumns={sourceColumns}
sourceSearchFields={sourceSearchFields}
multiSelect={multiSelect}
filterCondition={baseFilterCondition}
modalTitle={modalTitle}
alreadySelected={tableData}
uniqueField={tableConfig.saveConfig?.uniqueField}
onSelect={handleAddItems}
columnLabels={columnLabels}
modalFilters={modalFiltersForModal}
/>
</div>
);
}