행추가,모달 동시입력기능 구현

This commit is contained in:
kjs 2025-12-30 13:32:49 +09:00
parent c7efe8ec33
commit fd58e9cce2
3 changed files with 639 additions and 563 deletions

View File

@ -16,12 +16,7 @@ import { ItemSelectionModal } from "../modal-repeater-table/ItemSelectionModal";
import { RepeaterColumnConfig, CalculationRule } from "../modal-repeater-table/types";
// 타입 정의
import {
TableSectionConfig,
TableColumnConfig,
TableJoinCondition,
FormDataState,
} from "./types";
import { TableSectionConfig, TableColumnConfig, TableJoinCondition, FormDataState } from "./types";
interface TableSectionRendererProps {
sectionId: string;
@ -81,11 +76,13 @@ function convertToRepeaterColumn(col: TableColumnConfig): RepeaterColumnConfig {
// 외부 테이블 조회 설정 (sourceType이 "externalTable"인 경우)
externalLookup: cond.externalLookup,
// 값 변환 설정 전달 (레거시 호환)
transform: cond.transform?.enabled ? {
transform: cond.transform?.enabled
? {
tableName: cond.transform.tableName,
matchColumn: cond.transform.matchColumn,
resultColumn: cond.transform.resultColumn,
} : undefined,
}
: undefined,
})),
},
// 조회 유형 정보 추가
@ -122,7 +119,11 @@ function convertToRepeaterColumn(col: TableColumnConfig): RepeaterColumnConfig {
/**
* TableCalculationRule을 CalculationRule로
*/
function convertToCalculationRule(calc: { resultField: string; formula: string; dependencies: string[] }): CalculationRule {
function convertToCalculationRule(calc: {
resultField: string;
formula: string;
dependencies: string[];
}): CalculationRule {
return {
result: calc.resultField,
formula: calc.formula,
@ -136,7 +137,7 @@ function convertToCalculationRule(calc: { resultField: string; formula: string;
*/
async function transformValue(
value: any,
transform: { tableName: string; matchColumn: string; resultColumn: string }
transform: { tableName: string; matchColumn: string; resultColumn: string },
): Promise<any> {
if (!value || !transform.tableName || !transform.matchColumn || !transform.resultColumn) {
return value;
@ -144,19 +145,16 @@ async function transformValue(
try {
// 정확히 일치하는 검색
const response = await apiClient.post(
`/table-management/tables/${transform.tableName}/data`,
{
const response = await apiClient.post(`/table-management/tables/${transform.tableName}/data`, {
search: {
[transform.matchColumn]: {
value: value,
operator: "equals"
}
operator: "equals",
},
},
size: 1,
page: 1
}
);
page: 1,
});
if (response.data.success && response.data.data?.data?.length > 0) {
const transformedValue = response.data.data.data[0][transform.resultColumn];
@ -186,7 +184,7 @@ async function fetchExternalLookupValue(
},
rowData: any,
sourceData: any,
formData: FormDataState
formData: FormDataState,
): Promise<any> {
// 1. 비교 값 가져오기
let matchValue: any;
@ -199,31 +197,32 @@ async function fetchExternalLookupValue(
}
if (matchValue === undefined || matchValue === null || matchValue === "") {
console.warn(`외부 테이블 조회: 비교 값이 없습니다. (${externalLookup.matchSourceType}.${externalLookup.matchSourceField})`);
console.warn(
`외부 테이블 조회: 비교 값이 없습니다. (${externalLookup.matchSourceType}.${externalLookup.matchSourceField})`,
);
return undefined;
}
// 2. 외부 테이블에서 값 조회 (정확히 일치하는 검색)
try {
const response = await apiClient.post(
`/table-management/tables/${externalLookup.tableName}/data`,
{
const response = await apiClient.post(`/table-management/tables/${externalLookup.tableName}/data`, {
search: {
[externalLookup.matchColumn]: {
value: matchValue,
operator: "equals"
}
operator: "equals",
},
},
size: 1,
page: 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}" 인 행을 찾을 수 없습니다.`);
console.warn(
`외부 테이블 조회: ${externalLookup.tableName}.${externalLookup.matchColumn} = "${matchValue}" 인 행을 찾을 수 없습니다.`,
);
return undefined;
} catch (error) {
console.error("외부 테이블 조회 오류:", error);
@ -247,7 +246,7 @@ async function fetchExternalValue(
joinConditions: TableJoinCondition[],
rowData: any,
sourceData: any,
formData: FormDataState
formData: FormDataState,
): Promise<any> {
if (joinConditions.length === 0) {
return undefined;
@ -298,15 +297,16 @@ async function fetchExternalValue(
// 정확히 일치하는 검색을 위해 operator: "equals" 사용
whereConditions[condition.targetColumn] = {
value: convertedValue,
operator: "equals"
operator: "equals",
};
}
// API 호출
const response = await apiClient.post(
`/table-management/tables/${tableName}/data`,
{ search: whereConditions, size: 1, page: 1 }
);
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];
@ -389,14 +389,11 @@ export function TableSectionRenderer({
setDynamicOptionsLoading(true);
try {
// DISTINCT 값을 가져오기 위한 API 호출
const response = await apiClient.post(
`/table-management/tables/${tableName}/data`,
{
const response = await apiClient.post(`/table-management/tables/${tableName}/data`, {
search: filterCondition ? { _raw: filterCondition } : {},
size: 1000,
page: 1,
}
);
});
if (response.data.success && response.data.data?.data) {
const rows = response.data.data.data;
@ -406,7 +403,7 @@ export function TableSectionRenderer({
for (const row of rows) {
const value = row[valueColumn];
if (value && !uniqueValues.has(value)) {
const label = labelColumn ? (row[labelColumn] || value) : value;
const label = labelColumn ? row[labelColumn] || value : value;
uniqueValues.set(value, label);
}
}
@ -448,7 +445,7 @@ export function TableSectionRenderer({
// 동적 Select 옵션이 있는 컬럼 확인
const hasDynamicSelectColumns = useMemo(() => {
return tableConfig.columns?.some(col => col.dynamicSelectOptions?.enabled);
return tableConfig.columns?.some((col) => col.dynamicSelectOptions?.enabled);
}, [tableConfig.columns]);
// 소스 테이블 데이터 로드 (동적 Select 옵션용)
@ -467,14 +464,11 @@ export function TableSectionRenderer({
filterCondition[conditionalConfig.sourceFilter.filterColumn] = activeConditionTab;
}
const response = await apiClient.post(
`/table-management/tables/${tableConfig.source.tableName}/data`,
{
const response = await apiClient.post(`/table-management/tables/${tableConfig.source.tableName}/data`, {
search: filterCondition,
size: 1000,
page: 1,
}
);
});
if (response.data.success && response.data.data?.data) {
setSourceDataCache(response.data.data.data);
@ -510,14 +504,11 @@ export function TableSectionRenderer({
const filterCondition: Record<string, any> = {};
filterCondition[conditionalConfig.sourceFilter!.filterColumn] = activeConditionTab;
const response = await apiClient.post(
`/table-management/tables/${tableConfig.source!.tableName}/data`,
{
const response = await apiClient.post(`/table-management/tables/${tableConfig.source!.tableName}/data`, {
search: filterCondition,
size: 1000,
page: 1,
}
);
});
if (response.data.success && response.data.data?.data) {
setSourceDataCache(response.data.data.data);
@ -534,7 +525,13 @@ export function TableSectionRenderer({
};
loadSourceData();
}, [activeConditionTab, hasDynamicSelectColumns, conditionalConfig?.sourceFilter?.enabled, conditionalConfig?.sourceFilter?.filterColumn, tableConfig.source?.tableName]);
}, [
activeConditionTab,
hasDynamicSelectColumns,
conditionalConfig?.sourceFilter?.enabled,
conditionalConfig?.sourceFilter?.filterColumn,
tableConfig.source?.tableName,
]);
// 컬럼별 동적 Select 옵션 생성
const dynamicSelectOptionsMap = useMemo(() => {
@ -562,7 +559,7 @@ export function TableSectionRenderer({
if (distinct && seenValues.has(stringValue)) continue;
seenValues.add(stringValue);
const label = labelField ? (row[labelField] || stringValue) : stringValue;
const label = labelField ? row[labelField] || stringValue : stringValue;
options.push({ value: stringValue, label: String(label) });
}
@ -578,9 +575,7 @@ export function TableSectionRenderer({
let processedData = newData;
// 날짜 일괄 적용 로직: batchApply가 활성화된 날짜 컬럼 처리
const batchApplyColumns = tableConfig.columns.filter(
(col) => col.type === "date" && col.batchApply === true
);
const batchApplyColumns = tableConfig.columns.filter((col) => col.type === "date" && col.batchApply === true);
for (const dateCol of batchApplyColumns) {
// 이미 일괄 적용된 컬럼은 건너뜀
@ -608,20 +603,20 @@ export function TableSectionRenderer({
setTableData(processedData);
onTableDataChange(processedData);
},
[onTableDataChange, tableConfig.columns, batchAppliedFields]
[onTableDataChange, tableConfig.columns, batchAppliedFields],
);
// 행 선택 모드: 드롭다운 값 변경 시 같은 소스 행의 다른 컬럼들 자동 채움
const handleDynamicSelectChange = useCallback(
(rowIndex: number, columnField: string, selectedValue: string, conditionValue?: string) => {
const column = tableConfig.columns?.find(col => col.field === columnField);
const column = tableConfig.columns?.find((col) => col.field === columnField);
if (!column?.dynamicSelectOptions?.rowSelectionMode?.enabled) {
// 행 선택 모드가 아니면 일반 값 변경만
if (conditionValue && isConditionalMode) {
const currentData = conditionalTableData[conditionValue] || [];
const newData = [...currentData];
newData[rowIndex] = { ...newData[rowIndex], [columnField]: selectedValue };
setConditionalTableData(prev => ({ ...prev, [conditionValue]: newData }));
setConditionalTableData((prev) => ({ ...prev, [conditionValue]: newData }));
onConditionalTableDataChange?.(conditionValue, newData);
} else {
const newData = [...tableData];
@ -635,7 +630,7 @@ export function TableSectionRenderer({
const { sourceField } = column.dynamicSelectOptions;
const { autoFillColumns, sourceIdColumn, targetIdField } = column.dynamicSelectOptions.rowSelectionMode;
const sourceRow = sourceDataCache.find(row => String(row[sourceField]) === selectedValue);
const sourceRow = sourceDataCache.find((row) => String(row[sourceField]) === selectedValue);
if (!sourceRow) {
console.warn(`[TableSectionRenderer] 소스 행을 찾을 수 없음: ${sourceField} = ${selectedValue}`);
@ -672,7 +667,7 @@ export function TableSectionRenderer({
// 데이터 업데이트
if (conditionValue && isConditionalMode) {
setConditionalTableData(prev => ({ ...prev, [conditionValue]: newData }));
setConditionalTableData((prev) => ({ ...prev, [conditionValue]: newData }));
onConditionalTableDataChange?.(conditionValue, newData);
} else {
handleDataChange(newData);
@ -685,14 +680,23 @@ export function TableSectionRenderer({
updatedRow,
});
},
[tableConfig.columns, sourceDataCache, tableData, conditionalTableData, isConditionalMode, handleDataChange, onConditionalTableDataChange]
[
tableConfig.columns,
sourceDataCache,
tableData,
conditionalTableData,
isConditionalMode,
handleDataChange,
onConditionalTableDataChange,
],
);
// 참조 컬럼 값 조회 함수 (saveToTarget: false인 컬럼에 대해 소스 테이블 조회)
const loadReferenceColumnValues = useCallback(async (data: any[]) => {
const loadReferenceColumnValues = useCallback(
async (data: any[]) => {
// saveToTarget: false이고 referenceDisplay가 설정된 컬럼 찾기
const referenceColumns = (tableConfig.columns || []).filter(
(col) => col.saveConfig?.saveToTarget === false && col.saveConfig?.referenceDisplay
(col) => col.saveConfig?.saveToTarget === false && col.saveConfig?.referenceDisplay,
);
if (referenceColumns.length === 0) return;
@ -721,14 +725,11 @@ export function TableSectionRenderer({
try {
// 소스 테이블에서 참조 ID에 해당하는 데이터 조회
const response = await apiClient.post(
`/table-management/tables/${sourceTableName}/data`,
{
const response = await apiClient.post(`/table-management/tables/${sourceTableName}/data`, {
search: { id: Array.from(referenceIdSet) }, // ID 배열로 조회
size: 1000,
page: 1,
}
);
});
if (!response.data?.success || !response.data?.data?.data) {
console.warn("[TableSectionRenderer] 참조 데이터 조회 실패");
@ -771,7 +772,9 @@ export function TableSectionRenderer({
} catch (error) {
console.error("[TableSectionRenderer] 참조 데이터 조회 실패:", error);
}
}, [tableConfig.columns, tableConfig.source?.tableName]);
},
[tableConfig.columns, tableConfig.source?.tableName],
);
// formData에서 초기 테이블 데이터 로드 (수정 모드에서 _groupedData 표시)
useEffect(() => {
@ -796,7 +799,7 @@ export function TableSectionRenderer({
// RepeaterColumnConfig로 변환 (동적 Select 옵션 반영)
const columns: RepeaterColumnConfig[] = useMemo(() => {
return (tableConfig.columns || []).map(col => {
return (tableConfig.columns || []).map((col) => {
const baseColumn = convertToRepeaterColumn(col);
// 동적 Select 옵션이 있으면 적용
@ -840,22 +843,23 @@ export function TableSectionRenderer({
return updatedRow;
},
[calculationRules]
[calculationRules],
);
const calculateAll = useCallback(
(data: any[]): any[] => {
return data.map((row) => calculateRow(row));
},
[calculateRow]
[calculateRow],
);
// 행 변경 핸들러 (동적 Select 행 선택 모드 지원)
const handleRowChange = useCallback(
(index: number, newRow: any, conditionValue?: string) => {
const oldRow = conditionValue && isConditionalMode
? (conditionalTableData[conditionValue]?.[index] || {})
: (tableData[index] || {});
const oldRow =
conditionValue && isConditionalMode
? conditionalTableData[conditionValue]?.[index] || {}
: tableData[index] || {};
// 변경된 필드 찾기
const changedFields: string[] = [];
@ -867,7 +871,7 @@ export function TableSectionRenderer({
// 동적 Select 컬럼의 행 선택 모드 확인
for (const changedField of changedFields) {
const column = tableConfig.columns?.find(col => col.field === changedField);
const column = tableConfig.columns?.find((col) => col.field === changedField);
if (column?.dynamicSelectOptions?.rowSelectionMode?.enabled) {
// 행 선택 모드 처리 (자동 채움)
handleDynamicSelectChange(index, changedField, newRow[changedField], conditionValue);
@ -882,7 +886,7 @@ export function TableSectionRenderer({
const currentData = conditionalTableData[conditionValue] || [];
const newData = [...currentData];
newData[index] = calculatedRow;
setConditionalTableData(prev => ({ ...prev, [conditionValue]: newData }));
setConditionalTableData((prev) => ({ ...prev, [conditionValue]: newData }));
onConditionalTableDataChange?.(conditionValue, newData);
} else {
const newData = [...tableData];
@ -890,7 +894,16 @@ export function TableSectionRenderer({
handleDataChange(newData);
}
},
[tableData, conditionalTableData, isConditionalMode, tableConfig.columns, calculateRow, handleDataChange, handleDynamicSelectChange, onConditionalTableDataChange]
[
tableData,
conditionalTableData,
isConditionalMode,
tableConfig.columns,
calculateRow,
handleDataChange,
handleDynamicSelectChange,
onConditionalTableDataChange,
],
);
// 행 삭제 핸들러
@ -899,7 +912,7 @@ export function TableSectionRenderer({
const newData = tableData.filter((_, i) => i !== index);
handleDataChange(newData);
},
[tableData, handleDataChange]
[tableData, handleDataChange],
);
// 선택된 항목 일괄 삭제
@ -969,11 +982,13 @@ export function TableSectionRenderer({
// 외부 테이블 조회 설정
externalLookup: cond.externalLookup,
// 값 변환 설정 전달 (레거시 호환)
transform: cond.transform?.enabled ? {
transform: cond.transform?.enabled
? {
tableName: cond.transform.tableName,
matchColumn: cond.transform.matchColumn,
resultColumn: cond.transform.resultColumn,
} : undefined,
}
: undefined,
};
});
@ -984,7 +999,7 @@ export function TableSectionRenderer({
joinConditions,
{ ...sourceItem, ...newItem }, // rowData (현재 행)
sourceItem, // sourceData (소스 테이블 원본)
formData
formData,
);
if (value !== undefined) {
@ -1046,7 +1061,7 @@ export function TableSectionRenderer({
joinConditions,
{ ...sourceItem, ...newItem }, // rowData
sourceItem, // sourceData
formData
formData,
);
if (value !== undefined) {
newItem[col.field] = value;
@ -1070,7 +1085,7 @@ export function TableSectionRenderer({
}
return newItem;
})
}),
);
// 계산 필드 업데이트
@ -1080,7 +1095,7 @@ export function TableSectionRenderer({
const newData = [...tableData, ...calculatedItems];
handleDataChange(newData);
},
[tableConfig.columns, formData, tableData, calculateAll, handleDataChange, activeDataSources]
[tableConfig.columns, formData, tableData, calculateAll, handleDataChange, activeDataSources],
);
// 컬럼 모드/조회 옵션 변경 핸들러
@ -1140,11 +1155,13 @@ export function TableSectionRenderer({
// 외부 테이블 조회 설정
externalLookup: cond.externalLookup,
// 값 변환 설정 전달 (레거시 호환)
transform: cond.transform?.enabled ? {
transform: cond.transform?.enabled
? {
tableName: cond.transform.tableName,
matchColumn: cond.transform.matchColumn,
resultColumn: cond.transform.resultColumn,
} : undefined,
}
: undefined,
};
});
@ -1156,7 +1173,7 @@ export function TableSectionRenderer({
joinConditions,
row,
sourceData,
formData
formData,
);
if (value !== undefined) {
@ -1164,7 +1181,7 @@ export function TableSectionRenderer({
}
return { ...row, [columnField]: newValue };
})
}),
);
// 계산 필드 업데이트
@ -1199,14 +1216,14 @@ export function TableSectionRenderer({
}
return { ...row, [columnField]: newValue };
})
}),
);
// 계산 필드 업데이트
const calculatedData = calculateAll(updatedData);
handleDataChange(calculatedData);
},
[tableConfig.columns, tableData, formData, calculateAll, handleDataChange]
[tableConfig.columns, tableData, formData, calculateAll, handleDataChange],
);
// 소스 테이블 정보
@ -1216,10 +1233,16 @@ export function TableSectionRenderer({
const sourceSearchFields = source.searchColumns;
const columnLabels = source.columnLabels || {};
const modalTitle = uiConfig?.modalTitle || "항목 검색 및 선택";
const addButtonType = uiConfig?.addButtonType || "search";
const addButtonText = uiConfig?.addButtonText || (addButtonType === "addRow" ? "항목 추가" : "항목 검색");
const multiSelect = uiConfig?.multiSelect ?? true;
// 버튼 표시 설정 (두 버튼 동시 표시 가능)
// 레거시 호환: 기존 addButtonType 설정이 있으면 그에 맞게 변환
const legacyAddButtonType = uiConfig?.addButtonType;
const showSearchButton = legacyAddButtonType === "addRow" ? false : (uiConfig?.showSearchButton ?? true);
const showAddRowButton = legacyAddButtonType === "addRow" ? true : (uiConfig?.showAddRowButton ?? false);
const searchButtonText = uiConfig?.searchButtonText || uiConfig?.addButtonText || "품목 검색";
const addRowButtonText = uiConfig?.addRowButtonText || "직접 입력";
// 기본 필터 조건 생성 (사전 필터만 - 모달 필터는 ItemSelectionModal에서 처리)
const baseFilterCondition: Record<string, any> = useMemo(() => {
const condition: Record<string, any> = {};
@ -1253,7 +1276,7 @@ export function TableSectionRenderer({
column: filter.column,
label: filter.label || filter.column,
// category 타입을 select로 변환 (ModalFilterConfig 호환)
type: filter.type === "category" ? "select" as const : filter.type as "text" | "select",
type: filter.type === "category" ? ("select" as const) : (filter.type as "text" | "select"),
options: filter.options,
categoryRef: filter.categoryRef,
defaultValue: filter.defaultValue,
@ -1265,7 +1288,8 @@ export function TableSectionRenderer({
// ============================================
// 조건부 테이블: 조건 체크박스 토글
const handleConditionToggle = useCallback((conditionValue: string, checked: boolean) => {
const handleConditionToggle = useCallback(
(conditionValue: string, checked: boolean) => {
setSelectedConditions((prev) => {
if (checked) {
const newConditions = [...prev, conditionValue];
@ -1283,10 +1307,13 @@ export function TableSectionRenderer({
return newConditions;
}
});
}, [activeConditionTab]);
},
[activeConditionTab],
);
// 조건부 테이블: 조건별 데이터 변경
const handleConditionalDataChange = useCallback((conditionValue: string, newData: any[]) => {
const handleConditionalDataChange = useCallback(
(conditionValue: string, newData: any[]) => {
setConditionalTableData((prev) => ({
...prev,
[conditionValue]: newData,
@ -1315,26 +1342,35 @@ export function TableSectionRenderer({
}
onTableDataChange(allData);
}, [conditionalTableData, conditionalConfig?.conditionColumn, onConditionalTableDataChange, onTableDataChange]);
},
[conditionalTableData, conditionalConfig?.conditionColumn, onConditionalTableDataChange, onTableDataChange],
);
// 조건부 테이블: 조건별 행 변경
const handleConditionalRowChange = useCallback((conditionValue: string, index: number, newRow: any) => {
const handleConditionalRowChange = useCallback(
(conditionValue: string, index: number, newRow: any) => {
const calculatedRow = calculateRow(newRow);
const currentData = conditionalTableData[conditionValue] || [];
const newData = [...currentData];
newData[index] = calculatedRow;
handleConditionalDataChange(conditionValue, newData);
}, [conditionalTableData, calculateRow, handleConditionalDataChange]);
},
[conditionalTableData, calculateRow, handleConditionalDataChange],
);
// 조건부 테이블: 조건별 행 삭제
const handleConditionalRowDelete = useCallback((conditionValue: string, index: number) => {
const handleConditionalRowDelete = useCallback(
(conditionValue: string, index: number) => {
const currentData = conditionalTableData[conditionValue] || [];
const newData = currentData.filter((_, i) => i !== index);
handleConditionalDataChange(conditionValue, newData);
}, [conditionalTableData, handleConditionalDataChange]);
},
[conditionalTableData, handleConditionalDataChange],
);
// 조건부 테이블: 조건별 선택 행 일괄 삭제
const handleConditionalBulkDelete = useCallback((conditionValue: string) => {
const handleConditionalBulkDelete = useCallback(
(conditionValue: string) => {
const selected = conditionalSelectedRows[conditionValue] || new Set();
if (selected.size === 0) return;
@ -1347,10 +1383,13 @@ export function TableSectionRenderer({
...prev,
[conditionValue]: new Set(),
}));
}, [conditionalTableData, conditionalSelectedRows, handleConditionalDataChange]);
},
[conditionalTableData, conditionalSelectedRows, handleConditionalDataChange],
);
// 조건부 테이블: 아이템 추가 (특정 조건에)
const handleConditionalAddItems = useCallback(async (items: any[]) => {
const handleConditionalAddItems = useCallback(
async (items: any[]) => {
if (!modalCondition) return;
// 기존 handleAddItems 로직을 재사용하여 매핑된 아이템 생성
@ -1387,7 +1426,7 @@ export function TableSectionRenderer({
newItem._sourceData = sourceItem;
return newItem;
})
}),
);
// 현재 조건의 데이터에 추가
@ -1396,7 +1435,9 @@ export function TableSectionRenderer({
handleConditionalDataChange(modalCondition, newData);
setModalOpen(false);
}, [modalCondition, tableConfig.columns, formData, conditionalTableData, handleConditionalDataChange]);
},
[modalCondition, tableConfig.columns, formData, conditionalTableData, handleConditionalDataChange],
);
// 조건부 테이블: 모달 열기 (특정 조건에 대해)
const openConditionalModal = useCallback((conditionValue: string) => {
@ -1405,7 +1446,8 @@ export function TableSectionRenderer({
}, []);
// 조건부 테이블: 빈 행 추가 (addRow 모드에서 사용)
const addEmptyRowToCondition = useCallback((conditionValue: string) => {
const addEmptyRowToCondition = useCallback(
(conditionValue: string) => {
const newRow: Record<string, any> = {};
// 각 컬럼의 기본값으로 빈 행 생성
@ -1430,20 +1472,25 @@ export function TableSectionRenderer({
const currentData = conditionalTableData[conditionValue] || [];
const newData = [...currentData, newRow];
handleConditionalDataChange(conditionValue, newData);
}, [tableConfig.columns, conditionalConfig?.conditionColumn, conditionalTableData, handleConditionalDataChange]);
},
[tableConfig.columns, conditionalConfig?.conditionColumn, conditionalTableData, handleConditionalDataChange],
);
// 버튼 클릭 핸들러 (addButtonType에 따라 다르게 동작)
const handleAddButtonClick = useCallback((conditionValue: string) => {
const addButtonType = tableConfig.uiConfig?.addButtonType || "search";
if (addButtonType === "addRow") {
// 빈 행 직접 추가
addEmptyRowToCondition(conditionValue);
} else {
// 검색 모달 열기
// 검색 버튼 클릭 핸들러
const handleSearchButtonClick = useCallback(
(conditionValue: string) => {
openConditionalModal(conditionValue);
}
}, [tableConfig.uiConfig?.addButtonType, addEmptyRowToCondition, openConditionalModal]);
},
[openConditionalModal],
);
// 행 추가 버튼 클릭 핸들러
const handleAddRowButtonClick = useCallback(
(conditionValue: string) => {
addEmptyRowToCondition(conditionValue);
},
[addEmptyRowToCondition],
);
// 조건부 테이블: 초기 데이터 로드 (수정 모드)
useEffect(() => {
@ -1498,17 +1545,19 @@ export function TableSectionRenderer({
// 정적 옵션과 동적 옵션 병합 (동적 옵션이 있으면 우선 사용)
// 빈 value를 가진 옵션은 제외 (Select.Item은 빈 문자열 value를 허용하지 않음)
const effectiveOptions = (conditionalConfig.optionSource?.enabled && dynamicOptions.length > 0
const effectiveOptions = (
conditionalConfig.optionSource?.enabled && dynamicOptions.length > 0
? dynamicOptions
: conditionalConfig.options || []).filter(opt => opt.value && opt.value.trim() !== "");
: conditionalConfig.options || []
).filter((opt) => opt.value && opt.value.trim() !== "");
// 로딩 중이면 로딩 표시
if (dynamicOptionsLoading) {
return (
<div className={cn("space-y-4", className)}>
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
<div className="text-muted-foreground flex items-center justify-center py-8 text-sm">
<div className="flex items-center gap-2">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<div className="border-primary h-4 w-4 animate-spin rounded-full border-2 border-t-transparent" />
...
</div>
</div>
@ -1525,7 +1574,7 @@ export function TableSectionRenderer({
{effectiveOptions.map((option) => (
<label
key={option.id}
className="flex cursor-pointer items-center gap-2 rounded-lg border px-3 py-2 transition-colors hover:bg-muted/50"
className="hover:bg-muted/50 flex cursor-pointer items-center gap-2 rounded-lg border px-3 py-2 transition-colors"
>
<Checkbox
checked={selectedConditions.includes(option.value)}
@ -1542,7 +1591,7 @@ export function TableSectionRenderer({
</div>
{selectedConditions.length > 0 && (
<div className="text-xs text-muted-foreground">
<div className="text-muted-foreground text-xs">
{selectedConditions.length} , {totalConditionalItems}
</div>
)}
@ -1604,7 +1653,7 @@ export function TableSectionRenderer({
{/* 테이블 상단 컨트롤 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
<span className="text-muted-foreground text-sm">
{data.length > 0 && `${data.length}개 항목`}
{selected.size > 0 && ` (${selected.size}개 선택됨)`}
</span>
@ -1642,17 +1691,22 @@ export function TableSectionRenderer({
({selected.size})
</Button>
)}
{showSearchButton && (
<Button onClick={() => handleSearchButtonClick(conditionValue)} className="h-8 text-xs">
<Search className="mr-2 h-4 w-4" />
{searchButtonText}
</Button>
)}
{showAddRowButton && (
<Button
onClick={() => handleAddButtonClick(conditionValue)}
variant="outline"
onClick={() => handleAddRowButtonClick(conditionValue)}
className="h-8 text-xs"
>
{addButtonType === "addRow" ? (
<Plus className="mr-2 h-4 w-4" />
) : (
<Search className="mr-2 h-4 w-4" />
)}
{addButtonText}
{addRowButtonText}
</Button>
)}
</div>
</div>
@ -1711,7 +1765,7 @@ export function TableSectionRenderer({
<TabsContent key={option.id} value={option.value} className="mt-4 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
<span className="text-muted-foreground text-sm">
{data.length > 0 && `${data.length}개 항목`}
{selected.size > 0 && ` (${selected.size}개 선택됨)`}
</span>
@ -1728,17 +1782,22 @@ export function TableSectionRenderer({
({selected.size})
</Button>
)}
{showSearchButton && (
<Button onClick={() => handleSearchButtonClick(option.value)} className="h-8 text-xs">
<Search className="mr-2 h-4 w-4" />
{searchButtonText}
</Button>
)}
{showAddRowButton && (
<Button
onClick={() => handleAddButtonClick(option.value)}
variant="outline"
onClick={() => handleAddRowButtonClick(option.value)}
className="h-8 text-xs"
>
{addButtonType === "addRow" ? (
<Plus className="mr-2 h-4 w-4" />
) : (
<Search className="mr-2 h-4 w-4" />
)}
{addButtonText}
{addRowButtonText}
</Button>
)}
</div>
</div>
@ -1768,10 +1827,8 @@ export function TableSectionRenderer({
{/* 조건이 선택되지 않은 경우 안내 메시지 (checkbox/dropdown 모드에서만) */}
{selectedConditions.length === 0 && triggerType !== "tabs" && (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-12 text-center">
<p className="text-sm text-muted-foreground">
{triggerType === "checkbox"
? "위에서 유형을 선택하여 검사항목을 추가하세요."
: "유형을 선택하세요."}
<p className="text-muted-foreground text-sm">
{triggerType === "checkbox" ? "위에서 유형을 선택하여 검사항목을 추가하세요." : "유형을 선택하세요."}
</p>
</div>
)}
@ -1779,9 +1836,7 @@ export function TableSectionRenderer({
{/* 옵션이 없는 경우 안내 메시지 */}
{effectiveOptions.length === 0 && (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-12 text-center">
<p className="text-sm text-muted-foreground">
.
</p>
<p className="text-muted-foreground text-sm"> .</p>
</div>
)}
@ -1811,9 +1866,9 @@ export function TableSectionRenderer({
return (
<div className={cn("space-y-4", className)}>
{/* 추가 버튼 영역 */}
<div className="flex justify-between items-center">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
<span className="text-muted-foreground text-sm">
{tableData.length > 0 && `${tableData.length}개 항목`}
{selectedRows.size > 0 && ` (${selectedRows.size}개 선택됨)`}
</span>
@ -1822,17 +1877,17 @@ export function TableSectionRenderer({
variant="outline"
size="sm"
onClick={() => setWidthTrigger((prev) => prev + 1)}
className="h-7 text-xs px-2"
className="h-7 px-2 text-xs"
title={widthTrigger % 2 === 0 ? "내용에 맞게 자동 조정" : "균등 분배"}
>
{widthTrigger % 2 === 0 ? (
<>
<AlignJustify className="h-3.5 w-3.5 mr-1" />
<AlignJustify className="mr-1 h-3.5 w-3.5" />
</>
) : (
<>
<Columns className="h-3.5 w-3.5 mr-1" />
<Columns className="mr-1 h-3.5 w-3.5" />
</>
)}
@ -1841,17 +1896,20 @@ export function TableSectionRenderer({
</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"
>
<Button variant="destructive" onClick={handleBulkDelete} className="h-8 text-xs sm:h-10 sm:text-sm">
({selectedRows.size})
</Button>
)}
{showSearchButton && (
<Button onClick={() => setModalOpen(true)} className="h-8 text-xs sm:h-10 sm:text-sm">
<Search className="mr-2 h-4 w-4" />
{searchButtonText}
</Button>
)}
{showAddRowButton && (
<Button
variant="outline"
onClick={() => {
if (addButtonType === "addRow") {
// 빈 행 추가
const newRow: Record<string, any> = {};
for (const col of columns) {
@ -1866,20 +1924,13 @@ export function TableSectionRenderer({
}
}
handleDataChange([...tableData, newRow]);
} else {
// 검색 모달 열기
setModalOpen(true);
}
}}
className="h-8 text-xs sm:h-10 sm:text-sm"
>
{addButtonType === "addRow" ? (
<Plus className="h-4 w-4 mr-2" />
) : (
<Search className="h-4 w-4 mr-2" />
)}
{addButtonText}
<Plus className="mr-2 h-4 w-4" />
{addRowButtonText}
</Button>
)}
</div>
</div>

View File

@ -2928,54 +2928,74 @@ export function TableSectionSettingsModal({
{/* UI 설정 */}
<div className="space-y-3 border rounded-lg p-4">
<h4 className="text-sm font-medium">UI </h4>
{/* 버튼 표시 설정 */}
<div className="space-y-2 p-3 bg-muted/30 rounded-lg">
<Label className="text-xs font-medium"> </Label>
<p className="text-[10px] text-muted-foreground mb-2">
.
</p>
<div className="grid grid-cols-2 gap-4">
<div className="flex items-center gap-2">
<Switch
checked={tableConfig.uiConfig?.showSearchButton ?? true}
onCheckedChange={(checked) => updateUiConfig({ showSearchButton: checked })}
className="scale-75"
/>
<div>
<Label className="text-xs"> </Label>
<Select
value={tableConfig.uiConfig?.addButtonType || "search"}
onValueChange={(value) => updateUiConfig({ addButtonType: value as "search" | "addRow" })}
>
<SelectTrigger className="h-8 text-xs mt-1">
<SelectValue placeholder="버튼 동작 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="search">
<div className="flex flex-col">
<span> </span>
<span className="text-[10px] text-muted-foreground"> </span>
<span className="text-xs font-medium"> </span>
<p className="text-[10px] text-muted-foreground"> </p>
</div>
</SelectItem>
<SelectItem value="addRow">
<div className="flex flex-col">
<span> </span>
<span className="text-[10px] text-muted-foreground"> </span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Switch
checked={tableConfig.uiConfig?.showAddRowButton ?? false}
onCheckedChange={(checked) => updateUiConfig({ showAddRowButton: checked })}
className="scale-75"
/>
<div>
<Label className="text-xs"> </Label>
<span className="text-xs font-medium"> </span>
<p className="text-[10px] text-muted-foreground"> </p>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
{/* 검색 버튼 텍스트 */}
<div>
<Label className="text-xs"> </Label>
<Input
value={tableConfig.uiConfig?.addButtonText || ""}
onChange={(e) => updateUiConfig({ addButtonText: e.target.value })}
placeholder={tableConfig.uiConfig?.addButtonType === "addRow" ? "항목 추가" : "항목 검색"}
value={tableConfig.uiConfig?.searchButtonText || ""}
onChange={(e) => updateUiConfig({ searchButtonText: e.target.value })}
placeholder="품목 검색"
className="h-8 text-xs mt-1"
disabled={!(tableConfig.uiConfig?.showSearchButton ?? true)}
/>
</div>
{/* 행 추가 버튼 텍스트 */}
<div>
<Label className="text-xs"> </Label>
<Label className="text-xs"> </Label>
<Input
value={tableConfig.uiConfig?.addRowButtonText || ""}
onChange={(e) => updateUiConfig({ addRowButtonText: e.target.value })}
placeholder="직접 입력"
className="h-8 text-xs mt-1"
disabled={!tableConfig.uiConfig?.showAddRowButton}
/>
</div>
{/* 모달 제목 */}
<div>
<Label className="text-xs"> </Label>
<Input
value={tableConfig.uiConfig?.modalTitle || ""}
onChange={(e) => updateUiConfig({ modalTitle: e.target.value })}
placeholder="항목 검색 및 선택"
className="h-8 text-xs mt-1"
disabled={tableConfig.uiConfig?.addButtonType === "addRow"}
disabled={!(tableConfig.uiConfig?.showSearchButton ?? true)}
/>
{tableConfig.uiConfig?.addButtonType === "addRow" && (
<p className="text-[10px] text-muted-foreground mt-0.5"> </p>
)}
</div>
{/* 테이블 최대 높이 */}
<div>
<Label className="text-xs"> </Label>
<Input
@ -2985,13 +3005,14 @@ export function TableSectionSettingsModal({
className="h-8 text-xs mt-1"
/>
</div>
{/* 다중 선택 허용 */}
<div className="flex items-end">
<label className="flex items-center gap-2 text-xs cursor-pointer">
<Switch
checked={tableConfig.uiConfig?.multiSelect ?? true}
onCheckedChange={(checked) => updateUiConfig({ multiSelect: checked })}
className="scale-75"
disabled={tableConfig.uiConfig?.addButtonType === "addRow"}
disabled={!(tableConfig.uiConfig?.showSearchButton ?? true)}
/>
<span> </span>
</label>

View File

@ -253,15 +253,19 @@ export interface TableSectionConfig {
// 6. UI 설정
uiConfig?: {
addButtonText?: string; // 추가 버튼 텍스트 (기본: "품목 검색")
modalTitle?: string; // 모달 제목 (기본: "항목 검색 및 선택")
multiSelect?: boolean; // 다중 선택 허용 (기본: true)
maxHeight?: string; // 테이블 최대 높이 (기본: "400px")
// 추가 버튼 타입
// - search: 검색 모달 열기 (기본값) - 기존 데이터에서 선택
// - addRow: 빈 행 직접 추가 - 새 데이터 직접 입력
// 버튼 표시 설정 (동시 표시 가능)
showSearchButton?: boolean; // 검색 버튼 표시 (기본: true)
showAddRowButton?: boolean; // 행 추가 버튼 표시 (기본: false)
searchButtonText?: string; // 검색 버튼 텍스트 (기본: "품목 검색")
addRowButtonText?: string; // 행 추가 버튼 텍스트 (기본: "직접 입력")
// 레거시 호환용 (deprecated)
addButtonType?: "search" | "addRow";
addButtonText?: string;
};
// 7. 조건부 테이블 설정 (고급)