feat(UniversalFormModal): 테이블 섹션 컬럼 조회(Lookup) 기능 구현

- LookupConfig, LookupOption, LookupCondition 타입 정의
- sourceType 4가지 유형 지원 (currentRow, sourceTable, sectionField, externalTable)
- TableColumnSettingsModal에 "조회 설정" 탭 추가
- TableSectionSettingsModal에 간단 조회 설정 UI 추가
- fetchExternalValue, fetchExternalLookupValue 함수 구현
- 헤더 드롭다운에서 조회 옵션 선택 기능
This commit is contained in:
SeongHyun Kim 2025-12-19 11:48:46 +09:00
parent fdb9ef9167
commit c86140fad3
5 changed files with 1816 additions and 25 deletions

View File

@ -31,7 +31,7 @@ interface TableSectionRendererProps {
/**
* TableColumnConfig를 RepeaterColumnConfig로
* columnModes dynamicDataSource로
* columnModes lookup이 dynamicDataSource로
*/
function convertToRepeaterColumn(col: TableColumnConfig): RepeaterColumnConfig {
const baseColumn: RepeaterColumnConfig = {
@ -47,8 +47,43 @@ function convertToRepeaterColumn(col: TableColumnConfig): RepeaterColumnConfig {
// valueMapping은 별도로 처리
};
// columnModes를 dynamicDataSource로 변환
if (col.columnModes && col.columnModes.length > 0) {
// 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,
// displayLabel이 있으면 그것을 사용, 없으면 원래 label 사용
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) => ({
@ -58,12 +93,10 @@ function convertToRepeaterColumn(col: TableColumnConfig): RepeaterColumnConfig {
// 실제 조회 로직은 TableSectionRenderer에서 처리
tableConfig: {
tableName: mode.valueMapping?.externalRef?.tableName || "",
valueField: mode.valueMapping?.externalRef?.valueColumn || "",
valueColumn: mode.valueMapping?.externalRef?.valueColumn || "",
joinConditions: (mode.valueMapping?.externalRef?.joinConditions || []).map((jc) => ({
sourceTable: jc.sourceType === "row" ? "target" : "source",
sourceField: jc.sourceField,
targetField: jc.targetColumn,
operator: jc.operator || "=",
})),
},
})),
@ -85,18 +118,110 @@ function convertToCalculationRule(calc: { resultField: string; formula: string;
};
}
/**
* 함수: 중간
* : 거래처 "(무)테스트업체" "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 }, 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]: matchValue }, 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> {
console.log("📡 [fetchExternalValue] 시작:", { tableName, valueColumn, joinConditions });
if (joinConditions.length === 0) {
console.warn("조인 조건이 없습니다.");
console.warn("📡 [fetchExternalValue] 조인 조건이 없습니다.");
return undefined;
}
@ -106,20 +231,44 @@ async function fetchExternalValue(
for (const condition of joinConditions) {
let value: any;
// 값 출처에 따라 가져오기
console.log("📡 [fetchExternalValue] 조건 처리:", { condition, rowData, sourceData, formData });
// 값 출처에 따라 가져오기 (4가지 소스 타입 지원)
if (condition.sourceType === "row") {
// 현재 행에서 가져오기
// 현재 행 데이터 (설정된 컬럼 필드)
value = rowData[condition.sourceField];
console.log("📡 [fetchExternalValue] row에서 값 가져옴:", { field: condition.sourceField, value });
} else if (condition.sourceType === "sourceData") {
// 원본 소스 테이블 데이터 (_sourceData)
value = sourceData?.[condition.sourceField];
console.log("📡 [fetchExternalValue] sourceData에서 값 가져옴:", { field: condition.sourceField, value });
} else if (condition.sourceType === "formData") {
// formData에서 가져오기 (핵심 기능!)
// formData에서 가져오기 (다른 섹션)
value = formData[condition.sourceField];
console.log("📡 [fetchExternalValue] formData에서 값 가져옴:", { field: condition.sourceField, value });
} else if (condition.sourceType === "externalTable" && condition.externalLookup) {
// 외부 테이블에서 조회하여 가져오기
console.log("📡 [fetchExternalValue] externalTable 조회 시작:", condition.externalLookup);
value = await fetchExternalLookupValue(condition.externalLookup, rowData, sourceData, formData);
console.log("📡 [fetchExternalValue] externalTable 조회 결과:", { value });
}
if (value === undefined || value === null) {
console.warn(`조인 조건의 필드 "${condition.sourceField}" 값이 없습니다. (sourceType: ${condition.sourceType})`);
if (value === undefined || value === null || value === "") {
console.warn(`📡 [fetchExternalValue] 조인 조건의 필드 "${condition.sourceField}" 값이 없습니다. (sourceType: ${condition.sourceType})`);
return undefined;
}
// 값 변환이 필요한 경우 (예: 이름 → 코드) - 레거시 호환
if (condition.transform) {
console.log("📡 [fetchExternalValue] 값 변환 시작:", { originalValue: value, transform: condition.transform });
value = await transformValue(value, condition.transform);
console.log("📡 [fetchExternalValue] 값 변환 결과:", { transformedValue: value });
if (value === undefined) {
console.warn(`📡 [fetchExternalValue] 값 변환 후 결과가 없습니다. 원본 값: "${formData[condition.sourceField]}"`);
return undefined;
}
}
// 숫자형 ID 변환
let convertedValue = value;
if (condition.targetColumn.endsWith("_id") || condition.targetColumn === "id") {
@ -129,22 +278,33 @@ async function fetchExternalValue(
}
}
whereConditions[condition.targetColumn] = convertedValue;
// 정확히 일치하는 검색을 위해 operator: "equals" 사용
whereConditions[condition.targetColumn] = {
value: convertedValue,
operator: "equals"
};
console.log("📡 [fetchExternalValue] WHERE 조건 추가:", { targetColumn: condition.targetColumn, value: convertedValue });
}
// API 호출
console.log("📡 [fetchExternalValue] API 호출:", { tableName, whereConditions });
const response = await apiClient.post(
`/table-management/tables/${tableName}/data`,
{ search: whereConditions, size: 1, page: 1 }
);
console.log("📡 [fetchExternalValue] API 응답:", response.data);
if (response.data.success && response.data.data?.data?.length > 0) {
return response.data.data.data[0][valueColumn];
const result = response.data.data.data[0][valueColumn];
console.log("📡 [fetchExternalValue] 최종 결과:", { valueColumn, result });
return result;
}
console.warn("📡 [fetchExternalValue] 조회 결과 없음");
return undefined;
} catch (error) {
console.error("외부 테이블 조회 오류:", error);
console.error("📡 [fetchExternalValue] 외부 테이블 조회 오류:", error);
return undefined;
}
}
@ -269,6 +429,77 @@ export function TableSectionRenderer({
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가 명시적으로 설정된 경우
@ -316,7 +547,8 @@ export function TableSectionRenderer({
tableName,
valueColumn,
joinConditions,
{ ...sourceItem, ...newItem }, // 현재까지 빌드된 아이템
{ ...sourceItem, ...newItem }, // rowData
sourceItem, // sourceData
formData
);
if (value !== undefined) {
@ -343,12 +575,14 @@ export function TableSectionRenderer({
const newData = [...tableData, ...calculatedItems];
handleDataChange(newData);
},
[tableConfig.columns, formData, tableData, calculateAll, handleDataChange]
[tableConfig.columns, formData, tableData, calculateAll, handleDataChange, activeDataSources]
);
// 컬럼 모드 변경 핸들러
// 컬럼 모드/조회 옵션 변경 핸들러
const handleDataSourceChange = useCallback(
async (columnField: string, optionId: string) => {
console.log("🔍 [handleDataSourceChange] 시작:", { columnField, optionId });
setActiveDataSources((prev) => ({
...prev,
[columnField]: optionId,
@ -356,6 +590,109 @@ export function TableSectionRenderer({
// 해당 컬럼의 모든 행 데이터 재조회
const column = tableConfig.columns.find((col) => col.field === columnField);
console.log("🔍 [handleDataSourceChange] 컬럼 찾기:", { column: column?.field, hasLookup: column?.lookup?.enabled });
// lookup 설정이 있는 경우 (새로운 조회 기능)
if (column?.lookup?.enabled && column.lookup.options) {
const selectedOption = column.lookup.options.find((opt) => opt.id === optionId);
console.log("🔍 [handleDataSourceChange] 선택된 옵션:", { selectedOption, optionId });
if (!selectedOption) return;
// sameTable 타입: 현재 행의 소스 데이터에서 값 복사 (외부 조회 필요 없음)
if (selectedOption.type === "sameTable") {
console.log("🔍 [handleDataSourceChange] sameTable 타입 - 소스 데이터에서 복사");
const updatedData = tableData.map((row) => {
// sourceField에서 값을 가져와 해당 컬럼에 복사
// row에 _sourceData가 있으면 거기서, 없으면 row 자체에서 가져옴
const sourceData = row._sourceData || row;
const newValue = sourceData[selectedOption.valueColumn] ?? row[columnField];
console.log("🔍 [handleDataSourceChange] sameTable 값 복사:", { valueColumn: selectedOption.valueColumn, sourceData, newValue });
return { ...row, [columnField]: newValue };
});
const calculatedData = calculateAll(updatedData);
handleDataChange(calculatedData);
return;
}
// 모든 행에 대해 새 값 조회
console.log("🔍 [handleDataSourceChange] 외부 테이블 조회 시작:", {
type: selectedOption.type,
tableName: selectedOption.tableName,
valueColumn: selectedOption.valueColumn,
conditions: selectedOption.conditions,
tableDataLength: tableData.length,
});
const updatedData = await Promise.all(
tableData.map(async (row, rowIndex) => {
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,
};
});
console.log(`🔍 [handleDataSourceChange] 행 ${rowIndex} 조회:`, {
rowData: row,
sourceData: row._sourceData,
formData,
joinConditions,
});
// 외부 테이블에서 값 조회 (_sourceData 전달)
const sourceData = row._sourceData || row;
const value = await fetchExternalValue(
selectedOption.tableName,
selectedOption.valueColumn,
joinConditions,
row,
sourceData,
formData
);
console.log(`🔍 [handleDataSourceChange] 행 ${rowIndex} 조회 결과:`, { value });
if (value !== undefined) {
newValue = value;
}
return { ...row, [columnField]: newValue };
})
);
// 계산 필드 업데이트
const calculatedData = calculateAll(updatedData);
handleDataChange(calculatedData);
console.log("🔍 [handleDataSourceChange] 완료:", { calculatedData });
return;
}
// 기존 columnModes 처리 (레거시 호환)
if (!column?.columnModes) return;
const selectedMode = column.columnModes.find((mode) => mode.id === optionId);
@ -366,10 +703,11 @@ export function TableSectionRenderer({
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, formData);
const value = await fetchExternalValue(tableName, valueColumn, joinConditions, row, sourceData, formData);
if (value !== undefined) {
newValue = value;
}
@ -417,10 +755,10 @@ export function TableSectionRenderer({
return filters.modalFilters.map((filter) => ({
column: filter.column,
label: filter.label || filter.column,
type: filter.type,
// category 타입을 select로 변환 (ModalFilterConfig 호환)
type: filter.type === "category" ? "select" as const : filter.type as "text" | "select",
options: filter.options,
categoryRef: filter.categoryRef,
booleanRef: filter.booleanRef,
defaultValue: filter.defaultValue,
}));
}, [filters?.modalFilters]);

View File

@ -689,6 +689,7 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
])
)}
onLoadTableColumns={loadTableColumns}
allSections={config.sections as FormSectionConfig[]}
/>
)}
</div>

View File

@ -22,9 +22,14 @@ import {
ValueMappingConfig,
ColumnModeConfig,
TableJoinCondition,
LookupConfig,
LookupOption,
LookupCondition,
VALUE_MAPPING_TYPE_OPTIONS,
JOIN_SOURCE_TYPE_OPTIONS,
TABLE_COLUMN_TYPE_OPTIONS,
LOOKUP_TYPE_OPTIONS,
LOOKUP_CONDITION_SOURCE_OPTIONS,
} from "../types";
import {
@ -42,8 +47,10 @@ interface TableColumnSettingsModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
column: TableColumnConfig;
sourceTableName: string; // 소스 테이블명
sourceTableColumns: { column_name: string; data_type: string; comment?: string }[];
formFields: { columnName: string; label: string }[]; // formData 필드 목록
formFields: { columnName: string; label: string; sectionId?: string; sectionTitle?: string }[]; // formData 필드 목록 (섹션 정보 포함)
sections: { id: string; title: string }[]; // 섹션 목록
onSave: (updatedColumn: TableColumnConfig) => void;
tables: { table_name: string; comment?: string }[];
tableColumns: Record<string, { column_name: string; data_type: string; is_nullable: string; comment?: string }[]>;
@ -54,8 +61,10 @@ export function TableColumnSettingsModal({
open,
onOpenChange,
column,
sourceTableName,
sourceTableColumns,
formFields,
sections,
onSave,
tables,
tableColumns,
@ -67,6 +76,9 @@ export function TableColumnSettingsModal({
// 외부 테이블 검색 상태
const [externalTableOpen, setExternalTableOpen] = useState(false);
// 조회 테이블 검색 상태 (옵션별)
const [lookupTableOpenMap, setLookupTableOpenMap] = useState<Record<string, boolean>>({});
// 활성 탭
const [activeTab, setActiveTab] = useState("basic");
@ -174,6 +186,101 @@ export function TableColumnSettingsModal({
});
};
// ============================================
// 조회(Lookup) 관련 함수들
// ============================================
// 조회 설정 업데이트
const updateLookup = (updates: Partial<LookupConfig>) => {
const current = localColumn.lookup || { enabled: false, options: [] };
updateColumn({
lookup: { ...current, ...updates },
});
};
// 조회 옵션 추가
const addLookupOption = () => {
const newOption: LookupOption = {
id: `lookup_${Date.now()}`,
label: `조회 옵션 ${(localColumn.lookup?.options || []).length + 1}`,
type: "sameTable",
tableName: sourceTableName, // 기본값: 소스 테이블
valueColumn: "",
conditions: [],
isDefault: (localColumn.lookup?.options || []).length === 0, // 첫 번째 옵션은 기본값
};
updateLookup({
options: [...(localColumn.lookup?.options || []), newOption],
});
};
// 조회 옵션 삭제
const removeLookupOption = (index: number) => {
const newOptions = (localColumn.lookup?.options || []).filter((_, i) => i !== index);
// 삭제 후 기본 옵션이 없으면 첫 번째를 기본으로
if (newOptions.length > 0 && !newOptions.some(opt => opt.isDefault)) {
newOptions[0].isDefault = true;
}
updateLookup({ options: newOptions });
};
// 조회 옵션 업데이트
const updateLookupOption = (index: number, updates: Partial<LookupOption>) => {
updateLookup({
options: (localColumn.lookup?.options || []).map((opt, i) =>
i === index ? { ...opt, ...updates } : opt
),
});
};
// 조회 조건 추가
const addLookupCondition = (optionIndex: number) => {
const option = localColumn.lookup?.options?.[optionIndex];
if (!option) return;
const newCondition: LookupCondition = {
sourceType: "currentRow",
sourceField: "",
targetColumn: "",
};
updateLookupOption(optionIndex, {
conditions: [...(option.conditions || []), newCondition],
});
};
// 조회 조건 삭제
const removeLookupCondition = (optionIndex: number, conditionIndex: number) => {
const option = localColumn.lookup?.options?.[optionIndex];
if (!option) return;
updateLookupOption(optionIndex, {
conditions: option.conditions.filter((_, i) => i !== conditionIndex),
});
};
// 조회 조건 업데이트
const updateLookupCondition = (optionIndex: number, conditionIndex: number, updates: Partial<LookupCondition>) => {
const option = localColumn.lookup?.options?.[optionIndex];
if (!option) return;
updateLookupOption(optionIndex, {
conditions: option.conditions.map((c, i) =>
i === conditionIndex ? { ...c, ...updates } : c
),
});
};
// 조회 옵션의 테이블 컬럼 로드
useEffect(() => {
if (localColumn.lookup?.enabled) {
localColumn.lookup.options?.forEach(option => {
if (option.tableName) {
onLoadTableColumns(option.tableName);
}
});
}
}, [localColumn.lookup?.enabled, localColumn.lookup?.options, onLoadTableColumns]);
// 저장 함수
const handleSave = () => {
onSave(localColumn);
@ -432,8 +539,9 @@ export function TableColumnSettingsModal({
<ScrollArea className="h-[calc(90vh-200px)]">
<div className="space-y-4 p-1">
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="w-full grid grid-cols-3">
<TabsList className="w-full grid grid-cols-4">
<TabsTrigger value="basic" className="text-xs"> </TabsTrigger>
<TabsTrigger value="lookup" className="text-xs"> </TabsTrigger>
<TabsTrigger value="mapping" className="text-xs"> </TabsTrigger>
<TabsTrigger value="modes" className="text-xs"> </TabsTrigger>
</TabsList>
@ -595,6 +703,376 @@ export function TableColumnSettingsModal({
)}
</TabsContent>
{/* 조회 설정 탭 */}
<TabsContent value="lookup" className="mt-4 space-y-4">
{/* 조회 여부 토글 */}
<div className="flex items-center justify-between p-3 border rounded-lg bg-muted/20">
<div>
<Label className="text-sm font-medium"> </Label>
<p className="text-xs text-muted-foreground mt-0.5">
.
</p>
</div>
<Switch
checked={localColumn.lookup?.enabled ?? false}
onCheckedChange={(checked) => {
if (checked) {
updateLookup({ enabled: true, options: [] });
} else {
updateColumn({ lookup: undefined });
}
}}
/>
</div>
{/* 조회 설정 (활성화 시) */}
{localColumn.lookup?.enabled && (
<div className="space-y-4">
<Separator />
<div className="flex justify-between items-center">
<div>
<Label className="text-sm font-medium"> </Label>
<p className="text-xs text-muted-foreground">
.
</p>
</div>
<Button size="sm" variant="outline" onClick={addLookupOption} className="h-8 text-xs">
<Plus className="h-3.5 w-3.5 mr-1" />
</Button>
</div>
{(localColumn.lookup?.options || []).length === 0 ? (
<div className="text-center py-8 border border-dashed rounded-lg bg-muted/20">
<p className="text-sm text-muted-foreground"> </p>
<p className="text-xs text-muted-foreground mt-1">
"옵션 추가" .
</p>
</div>
) : (
<div className="space-y-4">
{(localColumn.lookup?.options || []).map((option, optIndex) => (
<div key={option.id} className="border rounded-lg p-4 space-y-4 bg-card">
{/* 옵션 헤더 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{option.label || `옵션 ${optIndex + 1}`}</span>
{option.isDefault && (
<Badge variant="secondary" className="text-xs"></Badge>
)}
</div>
<Button
size="sm"
variant="ghost"
onClick={() => removeLookupOption(optIndex)}
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
{/* 기본 설정 */}
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs"></Label>
<Input
value={option.label}
onChange={(e) => updateLookupOption(optIndex, { label: e.target.value })}
placeholder="예: 기준단가"
className="h-8 text-xs mt-1"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<Select
value={option.type}
onValueChange={(value: "sameTable" | "relatedTable" | "combinedLookup") => {
// 유형 변경 시 테이블 초기화
const newTableName = value === "sameTable" ? sourceTableName : "";
updateLookupOption(optIndex, {
type: value,
tableName: newTableName,
conditions: [],
});
}}
>
<SelectTrigger className="h-8 text-xs mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LOOKUP_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 조회 테이블 선택 */}
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs"> </Label>
{option.type === "sameTable" ? (
<Input
value={sourceTableName}
disabled
className="h-8 text-xs mt-1 bg-muted"
/>
) : (
<Popover
open={lookupTableOpenMap[option.id]}
onOpenChange={(open) => setLookupTableOpenMap(prev => ({ ...prev, [option.id]: open }))}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-8 w-full justify-between text-xs mt-1"
>
{option.tableName || "테이블 선택..."}
<ChevronsUpDown className="ml-2 h-3.5 w-3.5 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-full min-w-[250px]" align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="text-xs" />
<CommandList className="max-h-[200px]">
<CommandEmpty className="text-xs py-4 text-center">
.
</CommandEmpty>
<CommandGroup>
{tables.map((table) => (
<CommandItem
key={table.table_name}
value={table.table_name}
onSelect={() => {
updateLookupOption(optIndex, { tableName: table.table_name });
onLoadTableColumns(table.table_name);
setLookupTableOpenMap(prev => ({ ...prev, [option.id]: false }));
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3.5 w-3.5",
option.tableName === table.table_name ? "opacity-100" : "opacity-0"
)}
/>
{table.table_name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)}
</div>
<div>
<Label className="text-xs"> </Label>
<Select
value={option.valueColumn}
onValueChange={(value) => updateLookupOption(optIndex, { valueColumn: value })}
>
<SelectTrigger className="h-8 text-xs mt-1">
<SelectValue placeholder="컬럼 선택..." />
</SelectTrigger>
<SelectContent>
{(tableColumns[option.tableName] || []).map((col) => (
<SelectItem key={col.column_name} value={col.column_name}>
{col.column_name}
{col.comment && ` (${col.comment})`}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 기본 옵션 체크박스 */}
<div className="flex items-center gap-2">
<Switch
checked={option.isDefault ?? false}
onCheckedChange={(checked) => {
if (checked) {
// 기본 옵션은 하나만
updateLookup({
options: (localColumn.lookup?.options || []).map((opt, i) => ({
...opt,
isDefault: i === optIndex,
})),
});
} else {
updateLookupOption(optIndex, { isDefault: false });
}
}}
className="scale-75"
/>
<span className="text-xs"> </span>
</div>
<Separator />
{/* 조회 조건 */}
<div className="space-y-3">
<div className="flex justify-between items-center">
<Label className="text-xs font-medium"> </Label>
<Button
size="sm"
variant="outline"
onClick={() => addLookupCondition(optIndex)}
className="h-7 text-xs"
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
{(option.conditions || []).length === 0 ? (
<p className="text-xs text-muted-foreground text-center py-2 border border-dashed rounded">
.
</p>
) : (
<div className="space-y-2">
{option.conditions.map((condition, condIndex) => (
<div key={condIndex} className="flex items-center gap-2 p-2 border rounded-lg bg-muted/30">
{/* 소스 타입 */}
<Select
value={condition.sourceType}
onValueChange={(value: "currentRow" | "sectionField") =>
updateLookupCondition(optIndex, condIndex, {
sourceType: value,
sourceField: "",
sectionId: value === "sectionField" ? "" : undefined,
})
}
>
<SelectTrigger className="h-7 text-xs w-[110px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LOOKUP_CONDITION_SOURCE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 섹션 선택 (sectionField일 때) */}
{condition.sourceType === "sectionField" && (
<Select
value={condition.sectionId || ""}
onValueChange={(value) =>
updateLookupCondition(optIndex, condIndex, {
sectionId: value,
sourceField: "",
})
}
>
<SelectTrigger className="h-7 text-xs w-[100px]">
<SelectValue placeholder="섹션" />
</SelectTrigger>
<SelectContent>
{sections.map((section) => (
<SelectItem key={section.id} value={section.id}>
{section.title}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{/* 소스 필드 */}
<Select
value={condition.sourceField}
onValueChange={(value) => updateLookupCondition(optIndex, condIndex, { sourceField: value })}
>
<SelectTrigger className="h-7 text-xs w-[110px]">
<SelectValue placeholder="필드" />
</SelectTrigger>
<SelectContent>
{condition.sourceType === "currentRow"
? sourceTableColumns.map((col) => (
<SelectItem key={col.column_name} value={col.column_name}>
{col.column_name}
</SelectItem>
))
: formFields
.filter(f => !condition.sectionId || f.sectionId === condition.sectionId)
.map((field) => (
<SelectItem key={field.columnName} value={field.columnName}>
{field.label}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-xs text-muted-foreground">=</span>
{/* 타겟 컬럼 */}
<Select
value={condition.targetColumn}
onValueChange={(value) => updateLookupCondition(optIndex, condIndex, { targetColumn: value })}
>
<SelectTrigger className="h-7 text-xs flex-1">
<SelectValue placeholder="대상 컬럼" />
</SelectTrigger>
<SelectContent>
{(tableColumns[option.tableName] || []).map((col) => (
<SelectItem key={col.column_name} value={col.column_name}>
{col.column_name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
variant="ghost"
onClick={() => removeLookupCondition(optIndex, condIndex)}
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
)}
{/* 조회 유형별 설명 */}
<div className="text-xs text-muted-foreground p-2 bg-muted/30 rounded">
{option.type === "sameTable" && (
<>
<strong> :</strong> .
<br />: 품목
</>
)}
{option.type === "relatedTable" && (
<>
<strong> :</strong> .
<br />: 품목코드로
</>
)}
{option.type === "combinedLookup" && (
<>
<strong> :</strong> .
<br />: 거래처(1) + ()
</>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
</TabsContent>
{/* 값 매핑 탭 */}
<TabsContent value="mapping" className="mt-4 space-y-4">
<div>

View File

@ -13,7 +13,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, Trash2, GripVertical, ChevronUp, ChevronDown, Settings, Check, ChevronsUpDown, Filter, Table as TableIcon } from "lucide-react";
import { Plus, Trash2, GripVertical, ChevronUp, ChevronDown, Settings, Check, ChevronsUpDown, Filter, Table as TableIcon, Search } from "lucide-react";
import { cn } from "@/lib/utils";
// 타입 import
@ -24,9 +24,13 @@ import {
TablePreFilter,
TableModalFilter,
TableCalculationRule,
LookupOption,
ExternalTableLookup,
TABLE_COLUMN_TYPE_OPTIONS,
FILTER_OPERATOR_OPTIONS,
MODAL_FILTER_TYPE_OPTIONS,
LOOKUP_TYPE_OPTIONS,
LOOKUP_CONDITION_SOURCE_OPTIONS,
} from "../types";
import {
@ -52,6 +56,13 @@ interface ColumnSettingItemProps {
saveTableColumns: { column_name: string; data_type: string; is_nullable: string; comment?: string }[];
displayColumns: string[]; // 검색 설정에서 선택한 표시 컬럼 목록
sourceTableColumns: { column_name: string; data_type: string; is_nullable: string; comment?: string }[]; // 소스 테이블 컬럼
sourceTableName: string; // 소스 테이블명
tables: { table_name: string; comment?: string }[]; // 전체 테이블 목록
tableColumns: Record<string, { column_name: string; data_type: string; is_nullable: string; comment?: string }[]>; // 테이블별 컬럼
sections: { id: string; title: string }[]; // 섹션 목록
formFields: { columnName: string; label: string; sectionId?: string }[]; // 폼 필드 목록
tableConfig: TableSectionConfig; // 현재 행 필드 목록 표시용
onLoadTableColumns: (tableName: string) => void;
onUpdate: (updates: Partial<TableColumnConfig>) => void;
onMoveUp: () => void;
onMoveDown: () => void;
@ -65,6 +76,13 @@ function ColumnSettingItem({
saveTableColumns,
displayColumns,
sourceTableColumns,
sourceTableName,
tables,
tableColumns,
sections,
formFields,
tableConfig,
onLoadTableColumns,
onUpdate,
onMoveUp,
onMoveDown,
@ -72,6 +90,86 @@ function ColumnSettingItem({
}: ColumnSettingItemProps) {
const [fieldSearchOpen, setFieldSearchOpen] = useState(false);
const [sourceFieldSearchOpen, setSourceFieldSearchOpen] = useState(false);
const [lookupTableOpenMap, setLookupTableOpenMap] = useState<Record<string, boolean>>({});
// 조회 옵션 추가
const addLookupOption = () => {
const newOption: LookupOption = {
id: `lookup_${Date.now()}`,
label: `조회 옵션 ${(col.lookup?.options || []).length + 1}`,
type: "sameTable",
tableName: sourceTableName,
valueColumn: "",
conditions: [],
isDefault: (col.lookup?.options || []).length === 0,
};
onUpdate({
lookup: {
enabled: true,
options: [...(col.lookup?.options || []), newOption],
},
});
};
// 조회 옵션 삭제
const removeLookupOption = (optIndex: number) => {
const newOptions = (col.lookup?.options || []).filter((_, i) => i !== optIndex);
if (newOptions.length > 0 && !newOptions.some((opt) => opt.isDefault)) {
newOptions[0].isDefault = true;
}
onUpdate({
lookup: {
enabled: col.lookup?.enabled ?? false,
options: newOptions,
},
});
};
// 조회 옵션 업데이트
const updateLookupOption = (optIndex: number, updates: Partial<LookupOption>) => {
onUpdate({
lookup: {
enabled: col.lookup?.enabled ?? false,
options: (col.lookup?.options || []).map((opt, i) =>
i === optIndex ? { ...opt, ...updates } : opt
),
},
});
};
// 조회 조건 추가
const addLookupCondition = (optIndex: number) => {
const option = col.lookup?.options?.[optIndex];
if (!option) return;
const newCondition: LookupCondition = {
sourceType: "currentRow",
sourceField: "",
targetColumn: "",
};
updateLookupOption(optIndex, {
conditions: [...(option.conditions || []), newCondition],
});
};
// 조회 조건 삭제
const removeLookupCondition = (optIndex: number, condIndex: number) => {
const option = col.lookup?.options?.[optIndex];
if (!option) return;
updateLookupOption(optIndex, {
conditions: option.conditions.filter((_, i) => i !== condIndex),
});
};
// 조회 조건 업데이트
const updateLookupCondition = (optIndex: number, condIndex: number, updates: Partial<LookupCondition>) => {
const option = col.lookup?.options?.[optIndex];
if (!option) return;
updateLookupOption(optIndex, {
conditions: option.conditions.map((c, i) =>
i === condIndex ? { ...c, ...updates } : c
),
});
};
return (
<div className="border rounded-lg p-3 space-y-3 bg-card">
@ -304,7 +402,695 @@ function ColumnSettingItem({
/>
<span></span>
</label>
<label className="flex items-center gap-2 text-xs cursor-pointer">
<Switch
checked={col.lookup?.enabled ?? false}
onCheckedChange={(checked) => {
if (checked) {
onUpdate({ lookup: { enabled: true, options: [] } });
} else {
onUpdate({ lookup: undefined });
}
}}
className="scale-75"
/>
<span className="flex items-center gap-1">
<Search className="h-3 w-3" />
</span>
</label>
</div>
{/* 조회 설정 (조회 ON일 때만 표시) */}
{col.lookup?.enabled && (
<div className="border-t pt-3 mt-3 space-y-3">
<div className="flex justify-between items-center">
<Label className="text-xs font-medium text-blue-600"> </Label>
<Button size="sm" variant="outline" onClick={addLookupOption} className="h-6 text-xs px-2">
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
{(col.lookup?.options || []).length === 0 ? (
<p className="text-xs text-muted-foreground text-center py-2 border border-dashed rounded bg-muted/20">
"옵션 추가" .
</p>
) : (
<div className="space-y-3">
{(col.lookup?.options || []).map((option, optIndex) => (
<div key={option.id} className="border rounded-lg p-3 space-y-3 bg-blue-50/30">
{/* 옵션 헤더 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs font-medium">{option.label || `옵션 ${optIndex + 1}`}</span>
{option.isDefault && (
<Badge variant="secondary" className="text-[10px] h-4"></Badge>
)}
</div>
<Button
size="sm"
variant="ghost"
onClick={() => removeLookupOption(optIndex)}
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
{/* 기본 설정 - 첫 번째 줄: 옵션명, 표시 라벨 */}
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-[10px]"></Label>
<Input
value={option.label}
onChange={(e) => updateLookupOption(optIndex, { label: e.target.value })}
placeholder="예: 기준단가"
className="h-7 text-xs mt-0.5"
/>
</div>
<div>
<Label className="text-[10px]"> ( )</Label>
<Input
value={option.displayLabel || ""}
onChange={(e) => updateLookupOption(optIndex, { displayLabel: e.target.value })}
placeholder={`예: 단가 (${option.label || "옵션명"})`}
className="h-7 text-xs mt-0.5"
/>
<p className="text-[9px] text-muted-foreground mt-0.5">
</p>
</div>
</div>
{/* 기본 설정 - 두 번째 줄: 조회 유형, 테이블, 가져올 컬럼 */}
<div className="grid grid-cols-3 gap-2">
<div>
<Label className="text-[10px]"> </Label>
<Select
value={option.type}
onValueChange={(value: "sameTable" | "relatedTable" | "combinedLookup") => {
const newTableName = value === "sameTable" ? sourceTableName : "";
updateLookupOption(optIndex, { type: value, tableName: newTableName, conditions: [] });
if (newTableName) onLoadTableColumns(newTableName);
}}
>
<SelectTrigger className="h-7 text-xs mt-0.5">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LOOKUP_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-[10px]"> </Label>
{option.type === "sameTable" ? (
<Input value={sourceTableName} disabled className="h-7 text-xs mt-0.5 bg-muted" />
) : (
<Popover
open={lookupTableOpenMap[option.id]}
onOpenChange={(open) => setLookupTableOpenMap((prev) => ({ ...prev, [option.id]: open }))}
>
<PopoverTrigger asChild>
<Button variant="outline" className="h-7 w-full justify-between text-xs mt-0.5">
{option.tableName || "선택..."}
<ChevronsUpDown className="ml-1 h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-[200px]" align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="text-xs" />
<CommandList className="max-h-[150px]">
<CommandEmpty className="text-xs py-2 text-center"></CommandEmpty>
<CommandGroup>
{tables.map((table) => (
<CommandItem
key={table.table_name}
value={table.table_name}
onSelect={() => {
updateLookupOption(optIndex, { tableName: table.table_name });
onLoadTableColumns(table.table_name);
setLookupTableOpenMap((prev) => ({ ...prev, [option.id]: false }));
}}
className="text-xs"
>
<Check className={cn("mr-2 h-3 w-3", option.tableName === table.table_name ? "opacity-100" : "opacity-0")} />
{table.table_name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)}
</div>
<div>
<Label className="text-[10px]"> </Label>
<Select
value={option.valueColumn}
onValueChange={(value) => updateLookupOption(optIndex, { valueColumn: value })}
>
<SelectTrigger className="h-7 text-xs mt-0.5">
<SelectValue placeholder="선택..." />
</SelectTrigger>
<SelectContent>
{(tableColumns[option.tableName] || []).map((c) => (
<SelectItem key={c.column_name} value={c.column_name} className="text-xs">
{c.column_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 기본 옵션 & 조회 조건 */}
<div className="flex items-center justify-between">
<label className="flex items-center gap-1.5 text-xs cursor-pointer">
<Switch
checked={option.isDefault ?? false}
onCheckedChange={(checked) => {
if (checked) {
onUpdate({
lookup: {
enabled: true,
options: (col.lookup?.options || []).map((o, i) => ({ ...o, isDefault: i === optIndex })),
},
});
} else {
updateLookupOption(optIndex, { isDefault: false });
}
}}
className="scale-[0.6]"
/>
<span> </span>
</label>
<Button size="sm" variant="ghost" onClick={() => addLookupCondition(optIndex)} className="h-6 text-xs px-2">
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
{/* 조회 조건 목록 */}
{(option.conditions || []).length > 0 && (
<div className="space-y-2">
{option.conditions.map((cond, condIndex) => (
<div key={condIndex} className="border rounded bg-white p-2 space-y-2">
{/* 기본 조건 행 */}
<div className="flex items-center gap-1.5">
<Select
value={cond.sourceType}
onValueChange={(value: "currentRow" | "sourceTable" | "sectionField" | "externalTable") =>
updateLookupCondition(optIndex, condIndex, {
sourceType: value,
sourceField: "",
sectionId: undefined,
transform: undefined,
externalLookup: value === "externalTable" ? {
tableName: "",
matchColumn: "",
matchSourceType: "sourceTable",
matchSourceField: "",
resultColumn: "",
} : undefined,
})
}
>
<SelectTrigger className="h-6 text-[10px] w-[90px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LOOKUP_CONDITION_SOURCE_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value} className="text-xs">{o.label}</SelectItem>
))}
</SelectContent>
</Select>
{/* 다른 섹션 선택 시 - 섹션 드롭다운 */}
{cond.sourceType === "sectionField" && (
<Select
value={cond.sectionId || ""}
onValueChange={(value) => updateLookupCondition(optIndex, condIndex, { sectionId: value, sourceField: "" })}
>
<SelectTrigger className="h-6 text-[10px] w-[70px]">
<SelectValue placeholder="섹션" />
</SelectTrigger>
<SelectContent>
{sections.map((s) => (
<SelectItem key={s.id} value={s.id} className="text-xs">{s.title}</SelectItem>
))}
</SelectContent>
</Select>
)}
{/* 현재 행 / 소스 테이블 / 다른 섹션 - 필드 선택 */}
{cond.sourceType !== "externalTable" && (
<div className="space-y-0.5">
<Select
value={cond.sourceField}
onValueChange={(value) => updateLookupCondition(optIndex, condIndex, { sourceField: value })}
>
<SelectTrigger className="h-6 text-[10px] w-[110px]">
<SelectValue placeholder="필드" />
</SelectTrigger>
<SelectContent>
{cond.sourceType === "currentRow" ? (
// 현재 행: 테이블에 설정된 컬럼 필드 표시
<>
<div className="px-2 py-1 text-[10px] font-medium text-green-600 bg-green-50 border-b">
</div>
{tableConfig?.columns?.map((c) => (
<SelectItem key={c.field} value={c.field} className="text-xs">
{c.label} ({c.field})
</SelectItem>
))}
</>
) : cond.sourceType === "sourceTable" ? (
// 소스 테이블: 원본 테이블의 컬럼 표시
<>
<div className="px-2 py-1 text-[10px] font-medium text-orange-600 bg-orange-50 border-b">
{sourceTableName}
</div>
{sourceTableColumns.map((c) => (
<SelectItem key={c.column_name} value={c.column_name} className="text-xs">
{c.column_name}
</SelectItem>
))}
</>
) : (
// 다른 섹션: 폼 필드 표시
<>
{cond.sectionId && (
<div className="px-2 py-1 text-[10px] font-medium text-purple-600 bg-purple-50 border-b">
{sections.find(s => s.id === cond.sectionId)?.title || cond.sectionId}
</div>
)}
{formFields
.filter((f) => !cond.sectionId || f.sectionId === cond.sectionId)
.map((f) => (
<SelectItem key={f.columnName} value={f.columnName} className="text-xs">{f.label}</SelectItem>
))}
</>
)}
</SelectContent>
</Select>
{cond.sourceField && (
<p className="text-[9px] text-muted-foreground truncate">
{cond.sourceType === "currentRow"
? `rowData.${cond.sourceField}`
: cond.sourceType === "sourceTable"
? `${sourceTableName}.${cond.sourceField}`
: `formData.${cond.sourceField}`
}
</p>
)}
</div>
)}
{/* 현재 행 / 소스 테이블 / 다른 섹션일 때 = 기호와 조회 컬럼 */}
{cond.sourceType !== "externalTable" && (
<>
<span className="text-[10px] text-muted-foreground">=</span>
<div className="flex-1 space-y-0.5">
<Select
value={cond.targetColumn}
onValueChange={(value) => updateLookupCondition(optIndex, condIndex, { targetColumn: value })}
>
<SelectTrigger className="h-6 text-[10px]">
<SelectValue placeholder="조회 컬럼" />
</SelectTrigger>
<SelectContent>
{option.tableName && (
<div className="px-2 py-1 text-[10px] font-medium text-blue-600 bg-blue-50 border-b">
{option.tableName}
</div>
)}
{(tableColumns[option.tableName] || []).map((c) => (
<SelectItem key={c.column_name} value={c.column_name} className="text-xs">{c.column_name}</SelectItem>
))}
</SelectContent>
</Select>
{cond.targetColumn && option.tableName && (
<p className="text-[9px] text-muted-foreground truncate">
{option.tableName}.{cond.targetColumn}
</p>
)}
</div>
</>
)}
<Button
size="sm"
variant="ghost"
onClick={() => removeLookupCondition(optIndex, condIndex)}
className="h-6 w-6 p-0 text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
{/* 외부 테이블 조회 설정 */}
{cond.sourceType === "externalTable" && cond.externalLookup && (
<div className="pl-2 border-l-2 border-orange-200 space-y-2">
<p className="text-[10px] text-orange-600 font-medium"> </p>
{/* 1행: 조회 테이블 선택 */}
<div className="grid grid-cols-3 gap-1.5">
<div>
<p className="text-[9px] text-muted-foreground mb-0.5"> </p>
<Popover
open={lookupTableOpenMap[`ext_${optIndex}_${condIndex}`] || false}
onOpenChange={(open) => setLookupTableOpenMap(prev => ({ ...prev, [`ext_${optIndex}_${condIndex}`]: open }))}
>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="h-6 w-full justify-between text-[10px]">
{cond.externalLookup.tableName || "테이블 선택"}
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-[180px]" align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="text-xs py-2 text-center"> .</CommandEmpty>
<CommandGroup>
{tables.map((table) => (
<CommandItem
key={table.table_name}
value={table.table_name}
onSelect={() => {
onLoadTableColumns(table.table_name);
updateLookupCondition(optIndex, condIndex, {
externalLookup: { ...cond.externalLookup!, tableName: table.table_name, matchColumn: "", resultColumn: "" }
});
setLookupTableOpenMap(prev => ({ ...prev, [`ext_${optIndex}_${condIndex}`]: false }));
}}
className="text-xs"
>
<Check className={cn("mr-2 h-3 w-3", cond.externalLookup?.tableName === table.table_name ? "opacity-100" : "opacity-0")} />
{table.table_name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div>
<p className="text-[9px] text-muted-foreground mb-0.5"> (WHERE)</p>
<Select
value={cond.externalLookup.matchColumn}
onValueChange={(value) => updateLookupCondition(optIndex, condIndex, {
externalLookup: { ...cond.externalLookup!, matchColumn: value }
})}
>
<SelectTrigger className="h-6 text-[10px]">
<SelectValue placeholder="컬럼" />
</SelectTrigger>
<SelectContent>
{(tableColumns[cond.externalLookup.tableName] || []).map((c) => (
<SelectItem key={c.column_name} value={c.column_name} className="text-xs">{c.column_name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<p className="text-[9px] text-muted-foreground mb-0.5"> (SELECT)</p>
<Select
value={cond.externalLookup.resultColumn}
onValueChange={(value) => {
updateLookupCondition(optIndex, condIndex, {
externalLookup: { ...cond.externalLookup!, resultColumn: value },
sourceField: value // sourceField에도 저장
});
}}
>
<SelectTrigger className="h-6 text-[10px]">
<SelectValue placeholder="컬럼" />
</SelectTrigger>
<SelectContent>
{(tableColumns[cond.externalLookup.tableName] || []).map((c) => (
<SelectItem key={c.column_name} value={c.column_name} className="text-xs">{c.column_name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 2행: 비교 값 출처 */}
<div className="p-1.5 bg-orange-50/50 rounded">
<p className="text-[9px] text-muted-foreground mb-1"> </p>
<div className="flex items-center gap-1.5">
<Select
value={cond.externalLookup.matchSourceType}
onValueChange={(value: "currentRow" | "sourceTable" | "sectionField") => updateLookupCondition(optIndex, condIndex, {
externalLookup: { ...cond.externalLookup!, matchSourceType: value, matchSourceField: "", matchSectionId: undefined }
})}
>
<SelectTrigger className="h-5 text-[9px] w-[80px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="currentRow" className="text-xs"> </SelectItem>
<SelectItem value="sourceTable" className="text-xs"> </SelectItem>
<SelectItem value="sectionField" className="text-xs"> </SelectItem>
</SelectContent>
</Select>
{cond.externalLookup.matchSourceType === "sectionField" && (
<Select
value={cond.externalLookup.matchSectionId || ""}
onValueChange={(value) => updateLookupCondition(optIndex, condIndex, {
externalLookup: { ...cond.externalLookup!, matchSectionId: value, matchSourceField: "" }
})}
>
<SelectTrigger className="h-5 text-[9px] w-[60px]">
<SelectValue placeholder="섹션" />
</SelectTrigger>
<SelectContent>
{sections.map((s) => (
<SelectItem key={s.id} value={s.id} className="text-xs">{s.title}</SelectItem>
))}
</SelectContent>
</Select>
)}
<Select
value={cond.externalLookup.matchSourceField}
onValueChange={(value) => updateLookupCondition(optIndex, condIndex, {
externalLookup: { ...cond.externalLookup!, matchSourceField: value }
})}
>
<SelectTrigger className="h-5 text-[9px] flex-1">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
{cond.externalLookup.matchSourceType === "currentRow" ? (
<>
<div className="px-2 py-1 text-[9px] font-medium text-green-600 bg-green-50 border-b">
</div>
{tableConfig?.columns?.map((c) => (
<SelectItem key={c.field} value={c.field} className="text-xs">{c.label} ({c.field})</SelectItem>
))}
</>
) : cond.externalLookup.matchSourceType === "sourceTable" ? (
<>
<div className="px-2 py-1 text-[9px] font-medium text-orange-600 bg-orange-50 border-b">
{sourceTableName}
</div>
{sourceTableColumns.map((c) => (
<SelectItem key={c.column_name} value={c.column_name} className="text-xs">{c.column_name}</SelectItem>
))}
</>
) : (
<>
{cond.externalLookup.matchSectionId && (
<div className="px-2 py-1 text-[9px] font-medium text-purple-600 bg-purple-50 border-b">
{sections.find(s => s.id === cond.externalLookup?.matchSectionId)?.title}
</div>
)}
{formFields
.filter((f) => !cond.externalLookup?.matchSectionId || f.sectionId === cond.externalLookup?.matchSectionId)
.map((f) => (
<SelectItem key={f.columnName} value={f.columnName} className="text-xs">{f.label}</SelectItem>
))}
</>
)}
</SelectContent>
</Select>
</div>
</div>
{/* 3행: 최종 조회 컬럼 */}
<div className="flex items-center gap-1.5">
<span className="text-[9px] text-muted-foreground"> </span>
<span className="text-[10px] text-muted-foreground">=</span>
<Select
value={cond.targetColumn}
onValueChange={(value) => updateLookupCondition(optIndex, condIndex, { targetColumn: value })}
>
<SelectTrigger className="h-5 text-[9px] flex-1">
<SelectValue placeholder="조회 컬럼" />
</SelectTrigger>
<SelectContent>
{option.tableName && (
<div className="px-2 py-1 text-[9px] font-medium text-blue-600 bg-blue-50 border-b">
{option.tableName}
</div>
)}
{(tableColumns[option.tableName] || []).map((c) => (
<SelectItem key={c.column_name} value={c.column_name} className="text-xs">{c.column_name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 설명 텍스트 */}
{cond.externalLookup.tableName && cond.externalLookup.matchColumn && cond.externalLookup.resultColumn && (
<p className="text-[9px] text-orange-600 bg-orange-100/50 rounded px-1.5 py-0.5">
{cond.externalLookup.tableName} {cond.externalLookup.matchColumn} = {" "}
{cond.externalLookup.resultColumn} {option.tableName}.{cond.targetColumn}
</p>
)}
</div>
)}
{/* 값 변환 설정 (다른 섹션일 때만 표시) */}
{cond.sourceType === "sectionField" && (
<div className="pl-2 border-l-2 border-blue-200">
<label className="flex items-center gap-1.5 text-[10px] cursor-pointer">
<Switch
checked={cond.transform?.enabled ?? false}
onCheckedChange={(checked) => {
if (checked) {
updateLookupCondition(optIndex, condIndex, {
transform: { enabled: true, tableName: "", matchColumn: "", resultColumn: "" }
});
} else {
updateLookupCondition(optIndex, condIndex, { transform: undefined });
}
}}
className="scale-[0.5]"
/>
<span className="text-blue-600 font-medium"> </span>
<span className="text-muted-foreground">( )</span>
</label>
{cond.transform?.enabled && (
<div className="mt-1.5 p-2 bg-blue-50/50 rounded space-y-1.5">
<div className="grid grid-cols-3 gap-1.5">
<div>
<Label className="text-[9px] text-muted-foreground"> </Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="h-6 w-full justify-between text-[10px]">
{cond.transform.tableName || "선택..."}
<ChevronsUpDown className="ml-1 h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-[180px]" align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="text-xs" />
<CommandList className="max-h-[150px]">
<CommandEmpty className="text-xs py-2 text-center"></CommandEmpty>
<CommandGroup>
{tables.map((table) => (
<CommandItem
key={table.table_name}
value={table.table_name}
onSelect={() => {
updateLookupCondition(optIndex, condIndex, {
transform: { ...cond.transform!, tableName: table.table_name, matchColumn: "", resultColumn: "" }
});
onLoadTableColumns(table.table_name);
}}
className="text-xs"
>
<Check className={cn("mr-2 h-3 w-3", cond.transform?.tableName === table.table_name ? "opacity-100" : "opacity-0")} />
{table.table_name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div>
<Label className="text-[9px] text-muted-foreground"> </Label>
<Select
value={cond.transform.matchColumn}
onValueChange={(value) => updateLookupCondition(optIndex, condIndex, {
transform: { ...cond.transform!, matchColumn: value }
})}
>
<SelectTrigger className="h-6 text-[10px]">
<SelectValue placeholder="선택..." />
</SelectTrigger>
<SelectContent>
{(tableColumns[cond.transform.tableName] || []).map((c) => (
<SelectItem key={c.column_name} value={c.column_name} className="text-xs">{c.column_name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-[9px] text-muted-foreground"> </Label>
<Select
value={cond.transform.resultColumn}
onValueChange={(value) => updateLookupCondition(optIndex, condIndex, {
transform: { ...cond.transform!, resultColumn: value }
})}
>
<SelectTrigger className="h-6 text-[10px]">
<SelectValue placeholder="선택..." />
</SelectTrigger>
<SelectContent>
{(tableColumns[cond.transform.tableName] || []).map((c) => (
<SelectItem key={c.column_name} value={c.column_name} className="text-xs">{c.column_name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{cond.transform.tableName && cond.transform.matchColumn && cond.transform.resultColumn && (
<p className="text-[9px] text-blue-600 bg-blue-100/50 rounded px-1.5 py-0.5">
{cond.transform.tableName} {cond.transform.matchColumn} = {cond.transform.resultColumn}
</p>
)}
</div>
)}
</div>
)}
</div>
))}
</div>
)}
{/* 조회 유형 설명 */}
<p className="text-[10px] text-muted-foreground bg-muted/50 rounded p-1.5">
{option.type === "sameTable" && "동일 테이블: 검색 모달에서 선택한 행의 다른 컬럼 값"}
{option.type === "relatedTable" && "연관 테이블: 현재 행 데이터로 다른 테이블 조회"}
{option.type === "combinedLookup" && "복합 조건: 다른 섹션 필드 + 현재 행 조합 조회"}
</p>
</div>
))}
</div>
)}
</div>
)}
</div>
);
}
@ -320,6 +1106,8 @@ interface TableSectionSettingsModalProps {
// 카테고리 목록 (table_column_category_values에서 가져옴)
categoryList?: { tableName: string; columnName: string; displayName?: string }[];
onLoadCategoryList?: () => void;
// 전체 섹션 목록 (다른 섹션 필드 참조용)
allSections?: FormSectionConfig[];
}
export function TableSectionSettingsModal({
@ -332,6 +1120,7 @@ export function TableSectionSettingsModal({
onLoadTableColumns,
categoryList = [],
onLoadCategoryList,
allSections = [],
}: TableSectionSettingsModalProps) {
// 로컬 상태
const [title, setTitle] = useState(section.title);
@ -370,6 +1159,38 @@ export function TableSectionSettingsModal({
}
}, [tableConfig.saveConfig?.targetTable, onLoadTableColumns]);
// 조회 설정에 있는 테이블들의 컬럼 로드 (모달 열릴 때)
useEffect(() => {
if (open && tableConfig.columns) {
const tablesToLoad = new Set<string>();
// 각 컬럼의 lookup 설정에서 테이블 수집
tableConfig.columns.forEach((col) => {
if (col.lookup?.enabled && col.lookup.options) {
col.lookup.options.forEach((option) => {
// 조회 테이블
if (option.tableName) {
tablesToLoad.add(option.tableName);
}
// 변환 테이블
option.conditions?.forEach((cond) => {
if (cond.transform?.enabled && cond.transform.tableName) {
tablesToLoad.add(cond.transform.tableName);
}
});
});
}
});
// 수집된 테이블들의 컬럼 로드
tablesToLoad.forEach((tableName) => {
if (!tableColumns[tableName]) {
onLoadTableColumns(tableName);
}
});
}
}, [open, tableConfig.columns, tableColumns, onLoadTableColumns]);
// 소스 테이블의 컬럼 목록
const sourceTableColumns = useMemo(() => {
return tableColumns[tableConfig.source.tableName] || [];
@ -385,6 +1206,30 @@ export function TableSectionSettingsModal({
return sourceTableColumns;
}, [tableColumns, tableConfig.saveConfig?.targetTable, sourceTableColumns]);
// 다른 섹션 목록 (현재 섹션 제외, 테이블 타입이 아닌 섹션만)
const otherSections = useMemo(() => {
return allSections
.filter((s) => s.id !== section.id && s.type !== "table")
.map((s) => ({ id: s.id, title: s.title }));
}, [allSections, section.id]);
// 다른 섹션의 필드 목록
const otherSectionFields = useMemo(() => {
const fields: { columnName: string; label: string; sectionId: string }[] = [];
allSections
.filter((s) => s.id !== section.id && s.type !== "table")
.forEach((s) => {
(s.fields || []).forEach((f) => {
fields.push({
columnName: f.columnName,
label: f.label,
sectionId: s.id,
});
});
});
return fields;
}, [allSections, section.id]);
// 설정 업데이트 함수
const updateTableConfig = (updates: Partial<TableSectionConfig>) => {
setTableConfig((prev) => ({ ...prev, ...updates }));
@ -831,6 +1676,13 @@ export function TableSectionSettingsModal({
saveTableColumns={saveTableColumns}
displayColumns={tableConfig.source.displayColumns || []}
sourceTableColumns={sourceTableColumns}
sourceTableName={tableConfig.source.tableName}
tables={tables}
tableColumns={tableColumns}
sections={otherSections}
formFields={otherSectionFields}
tableConfig={tableConfig}
onLoadTableColumns={onLoadTableColumns}
onUpdate={(updates) => updateColumn(index, updates)}
onMoveUp={() => moveColumn(index, "up")}
onMoveDown={() => moveColumn(index, "down")}

View File

@ -328,6 +328,97 @@ export interface TableColumnConfig {
// 컬럼 모드 전환 (동적 데이터 소스)
columnModes?: ColumnModeConfig[];
// 조회 설정 (동적 값 조회)
lookup?: LookupConfig;
}
// ============================================
// 조회(Lookup) 설정 관련 타입 정의
// ============================================
/**
*
* - sameTable: 동일 ( )
* - relatedTable: 연관 ( )
* - combinedLookup: 복합 ( + )
*/
export type LookupType = "sameTable" | "relatedTable" | "combinedLookup";
/**
*
* : 거래처
*/
export interface LookupTransform {
enabled: boolean; // 변환 사용 여부
tableName: string; // 변환 테이블 (예: customer_mng)
matchColumn: string; // 찾을 컬럼 (예: customer_name)
resultColumn: string; // 가져올 컬럼 (예: customer_code)
}
/**
*
* ( )
*/
export interface ExternalTableLookup {
tableName: string; // 조회할 테이블
matchColumn: string; // 조회 조건 컬럼 (WHERE 절에서 비교할 컬럼)
matchSourceType: "currentRow" | "sourceTable" | "sectionField"; // 비교값 출처
matchSourceField: string; // 비교값 필드명
matchSectionId?: string; // sectionField인 경우 섹션 ID
resultColumn: string; // 가져올 컬럼 (SELECT 절)
}
/**
*
*
* sourceType :
* - "currentRow": (rowData에서 , : part_code, quantity)
* - "sourceTable": (_sourceData에서 , : item_number, company_code)
* - "sectionField": (formData에서 , : partner_id)
* - "externalTable": ( )
*/
export interface LookupCondition {
sourceType: "currentRow" | "sourceTable" | "sectionField" | "externalTable"; // 값 출처
sourceField: string; // 출처의 필드명 (참조할 필드)
sectionId?: string; // sectionField인 경우 섹션 ID
targetColumn: string; // 조회 테이블의 컬럼
// 외부 테이블 조회 설정 (sourceType이 "externalTable"인 경우)
externalLookup?: ExternalTableLookup;
// 값 변환 설정 (선택) - 이름→코드 등 변환이 필요할 때 (레거시 호환)
transform?: LookupTransform;
}
/**
*
*
*/
export interface LookupOption {
id: string; // 옵션 고유 ID
label: string; // 옵션 라벨 (예: "기준단가", "거래처별 단가")
displayLabel?: string; // 헤더 드롭다운에 표시될 텍스트 (예: "기준단가" → "단가 (기준단가)")
type: LookupType; // 조회 유형
// 조회 테이블 설정
tableName: string; // 조회할 테이블
valueColumn: string; // 가져올 컬럼
// 조회 조건 (여러 조건 AND로 결합)
conditions: LookupCondition[];
// 기본 옵션 여부
isDefault?: boolean;
}
/**
*
*/
export interface LookupConfig {
enabled: boolean; // 조회 사용 여부
options: LookupOption[]; // 조회 옵션 목록
defaultOptionId?: string; // 기본 선택 옵션 ID
}
/**
@ -354,12 +445,28 @@ export interface ValueMappingConfig {
/**
*
*
*
* sourceType :
* - "row": (rowData)
* - "sourceData": (_sourceData)
* - "formData": (formData)
* - "externalTable":
*/
export interface TableJoinCondition {
sourceType: "row" | "formData"; // 값 출처 (현재 행 또는 폼 데이터)
sourceType: "row" | "sourceData" | "formData" | "externalTable"; // 값 출처
sourceField: string; // 출처의 필드명
targetColumn: string; // 조회 테이블의 컬럼
operator?: "=" | "!=" | ">" | "<" | ">=" | "<="; // 연산자 (기본: "=")
// 외부 테이블 조회 설정 (sourceType이 "externalTable"인 경우)
externalLookup?: ExternalTableLookup;
// 값 변환 설정 (선택) - 이름→코드 등 중간 변환이 필요할 때 (레거시 호환)
transform?: {
tableName: string; // 변환 테이블 (예: customer_mng)
matchColumn: string; // 찾을 컬럼 (예: customer_name)
resultColumn: string; // 가져올 컬럼 (예: customer_code)
};
}
/**
@ -665,3 +772,18 @@ export const MODAL_FILTER_TYPE_OPTIONS = [
{ value: "category", label: "테이블 조회" },
{ value: "text", label: "텍스트 입력" },
] as const;
// 조회 유형 옵션
export const LOOKUP_TYPE_OPTIONS = [
{ value: "sameTable", label: "동일 테이블 조회" },
{ value: "relatedTable", label: "연관 테이블 조회" },
{ value: "combinedLookup", label: "복합 조건 조회" },
] as const;
// 조회 조건 소스 타입 옵션
export const LOOKUP_CONDITION_SOURCE_OPTIONS = [
{ value: "currentRow", label: "현재 행" },
{ value: "sourceTable", label: "소스 테이블" },
{ value: "sectionField", label: "다른 섹션" },
{ value: "externalTable", label: "외부 테이블" },
] as const;