feat: Enhance SplitPanelLayoutComponent with improved data loading and filtering logic

- Updated loadRightData function to support loading all data when no leftItem is selected, applying data filters as needed.
- Enhanced loadTabData function to handle data loading for tabs, including support for data filters and entity joins.
- Improved comments for clarity on data loading behavior based on leftItem selection.
- Refactored UI components in SplitPanelLayoutConfigPanel for better styling and organization, including updates to table selection and display settings.
This commit is contained in:
DDD1542 2026-02-11 10:46:47 +09:00
parent 9785f098d8
commit ced25c9a54
2 changed files with 461 additions and 403 deletions

View File

@ -1091,7 +1091,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
searchValues, searchValues,
]); ]);
// 우측 데이터 로드 // 우측 데이터 로드 (leftItem이 null이면 전체 데이터 로드)
const loadRightData = useCallback( const loadRightData = useCallback(
async (leftItem: any) => { async (leftItem: any) => {
const relationshipType = componentConfig.rightPanel?.relation?.type || "detail"; const relationshipType = componentConfig.rightPanel?.relation?.type || "detail";
@ -1099,10 +1099,84 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
if (!rightTableName || isDesignMode) return; if (!rightTableName || isDesignMode) return;
// 좌측 미선택 시: 전체 데이터 로드 (dataFilter 적용)
if (!leftItem && relationshipType === "join") {
setIsLoadingRight(true);
try {
const rightJoinColumns = extractAdditionalJoinColumns(
componentConfig.rightPanel?.columns,
rightTableName,
);
const result = await entityJoinApi.getTableDataWithJoins(rightTableName, {
enableEntityJoin: true,
size: 1000,
companyCodeOverride: companyCode,
additionalJoinColumns: rightJoinColumns,
dataFilter: componentConfig.rightPanel?.dataFilter,
});
// dataFilter 적용
let filteredData = result.data || [];
const dataFilter = componentConfig.rightPanel?.dataFilter;
if (dataFilter?.enabled && dataFilter.filters?.length > 0) {
filteredData = filteredData.filter((item: any) => {
return dataFilter.filters.every((cond: any) => {
const value = item[cond.columnName];
switch (cond.operator) {
case "equals":
return value === cond.value;
case "notEquals":
return value !== cond.value;
case "contains":
return String(value || "").includes(String(cond.value));
case "is_null":
return value === null || value === undefined || value === "";
case "is_not_null":
return value !== null && value !== undefined && value !== "";
default:
return true;
}
});
});
}
// conditions 형식 dataFilter도 지원 (하위 호환성)
const dataFilterConditions = componentConfig.rightPanel?.dataFilter;
if (dataFilterConditions?.enabled && dataFilterConditions.conditions?.length > 0) {
filteredData = filteredData.filter((item: any) => {
return dataFilterConditions.conditions.every((cond: any) => {
const value = item[cond.column];
switch (cond.operator) {
case "equals":
return value === cond.value;
case "notEquals":
return value !== cond.value;
case "contains":
return String(value || "").includes(String(cond.value));
default:
return true;
}
});
});
}
setRightData(filteredData);
} catch (error) {
console.error("우측 전체 데이터 로드 실패:", error);
} finally {
setIsLoadingRight(false);
}
return;
}
// leftItem이 null이면 join 모드 이외에는 데이터 로드 불가
if (!leftItem) return;
setIsLoadingRight(true); setIsLoadingRight(true);
try { try {
if (relationshipType === "detail") { if (relationshipType === "detail") {
// 상세 모드: 동일 테이블의 상세 정보 (🆕 엔티티 조인 활성화) // 상세 모드: 동일 테이블의 상세 정보 (엔티티 조인 활성화)
const primaryKey = leftItem.id || leftItem.ID || Object.values(leftItem)[0]; const primaryKey = leftItem.id || leftItem.ID || Object.values(leftItem)[0];
// 🆕 엔티티 조인 API 사용 // 🆕 엔티티 조인 API 사용
@ -1331,11 +1405,11 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
], ],
); );
// 추가 탭 데이터 로딩 함수 // 추가 탭 데이터 로딩 함수 (leftItem이 null이면 전체 데이터 로드)
const loadTabData = useCallback( const loadTabData = useCallback(
async (tabIndex: number, leftItem: any) => { async (tabIndex: number, leftItem: any) => {
const tabConfig = componentConfig.rightPanel?.additionalTabs?.[tabIndex - 1]; const tabConfig = componentConfig.rightPanel?.additionalTabs?.[tabIndex - 1];
if (!tabConfig || !leftItem || isDesignMode) return; if (!tabConfig || isDesignMode) return;
const tabTableName = tabConfig.tableName; const tabTableName = tabConfig.tableName;
if (!tabTableName) return; if (!tabTableName) return;
@ -1346,7 +1420,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const leftColumn = tabConfig.relation?.leftColumn || keys?.[0]?.leftColumn; const leftColumn = tabConfig.relation?.leftColumn || keys?.[0]?.leftColumn;
const rightColumn = tabConfig.relation?.foreignKey || keys?.[0]?.rightColumn; const rightColumn = tabConfig.relation?.foreignKey || keys?.[0]?.rightColumn;
// 🆕 탭 config의 Entity 조인 컬럼 추출 // 탭 config의 Entity 조인 컬럼 추출
const tabJoinColumns = extractAdditionalJoinColumns(tabConfig.columns, tabTableName); const tabJoinColumns = extractAdditionalJoinColumns(tabConfig.columns, tabTableName);
if (tabJoinColumns) { if (tabJoinColumns) {
console.log(`🔗 [분할패널] 탭 ${tabIndex} additionalJoinColumns:`, tabJoinColumns); console.log(`🔗 [분할패널] 탭 ${tabIndex} additionalJoinColumns:`, tabJoinColumns);
@ -1354,7 +1428,20 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
let resultData: any[] = []; let resultData: any[] = [];
if (leftColumn && rightColumn) { // 탭의 dataFilter (API 전달용)
const tabDataFilterForApi = (tabConfig as any).dataFilter;
if (!leftItem) {
// 좌측 미선택: 전체 데이터 로드 (dataFilter는 API에 전달)
const result = await entityJoinApi.getTableDataWithJoins(tabTableName, {
enableEntityJoin: true,
size: 1000,
companyCodeOverride: companyCode,
additionalJoinColumns: tabJoinColumns,
dataFilter: tabDataFilterForApi,
});
resultData = result.data || [];
} else if (leftColumn && rightColumn) {
const searchConditions: Record<string, any> = {}; const searchConditions: Record<string, any> = {};
if (keys && keys.length > 0) { if (keys && keys.length > 0) {
@ -1380,18 +1467,46 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
search: searchConditions, search: searchConditions,
enableEntityJoin: true, enableEntityJoin: true,
size: 1000, size: 1000,
additionalJoinColumns: tabJoinColumns, // 🆕 Entity 조인 컬럼 전달 companyCodeOverride: companyCode,
additionalJoinColumns: tabJoinColumns,
dataFilter: tabDataFilterForApi,
}); });
resultData = result.data || []; resultData = result.data || [];
} else { } else {
const result = await entityJoinApi.getTableDataWithJoins(tabTableName, { const result = await entityJoinApi.getTableDataWithJoins(tabTableName, {
enableEntityJoin: true, enableEntityJoin: true,
size: 1000, size: 1000,
additionalJoinColumns: tabJoinColumns, // 🆕 Entity 조인 컬럼 전달 companyCodeOverride: companyCode,
additionalJoinColumns: tabJoinColumns,
dataFilter: tabDataFilterForApi,
}); });
resultData = result.data || []; resultData = result.data || [];
} }
// 탭별 dataFilter 적용
const tabDataFilter = (tabConfig as any).dataFilter;
if (tabDataFilter?.enabled && tabDataFilter.filters?.length > 0) {
resultData = resultData.filter((item: any) => {
return tabDataFilter.filters.every((cond: any) => {
const value = item[cond.columnName];
switch (cond.operator) {
case "equals":
return value === cond.value;
case "notEquals":
return value !== cond.value;
case "contains":
return String(value || "").includes(String(cond.value));
case "is_null":
return value === null || value === undefined || value === "";
case "is_not_null":
return value !== null && value !== undefined && value !== "";
default:
return true;
}
});
});
}
setTabsData((prev) => ({ ...prev, [tabIndex]: resultData })); setTabsData((prev) => ({ ...prev, [tabIndex]: resultData }));
} catch (error) { } catch (error) {
console.error(`추가탭 ${tabIndex} 데이터 로드 실패:`, error); console.error(`추가탭 ${tabIndex} 데이터 로드 실패:`, error);
@ -1407,29 +1522,55 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
[componentConfig.rightPanel?.additionalTabs, isDesignMode, toast], [componentConfig.rightPanel?.additionalTabs, isDesignMode, toast],
); );
// 탭 변경 핸들러 // 탭 변경 핸들러 (좌측 미선택 시에도 전체 데이터 로드)
const handleTabChange = useCallback( const handleTabChange = useCallback(
(newTabIndex: number) => { (newTabIndex: number) => {
setActiveTabIndex(newTabIndex); setActiveTabIndex(newTabIndex);
if (selectedLeftItem) { if (newTabIndex === 0) {
if (newTabIndex === 0) { if (!rightData || (Array.isArray(rightData) && rightData.length === 0)) {
if (!rightData || (Array.isArray(rightData) && rightData.length === 0)) { loadRightData(selectedLeftItem);
loadRightData(selectedLeftItem); }
} } else {
} else { if (!tabsData[newTabIndex]) {
if (!tabsData[newTabIndex]) { loadTabData(newTabIndex, selectedLeftItem);
loadTabData(newTabIndex, selectedLeftItem);
}
} }
} }
}, },
[selectedLeftItem, rightData, tabsData, loadRightData, loadTabData], [selectedLeftItem, rightData, tabsData, loadRightData, loadTabData],
); );
// 좌측 항목 선택 핸들러 // 좌측 항목 선택 핸들러 (동일 항목 재클릭 시 선택 해제 → 전체 데이터 표시)
const handleLeftItemSelect = useCallback( const handleLeftItemSelect = useCallback(
(item: any) => { (item: any) => {
// 동일 항목 클릭 시 선택 해제 (전체 보기로 복귀)
const leftPk = componentConfig.rightPanel?.relation?.leftColumn ||
componentConfig.rightPanel?.relation?.keys?.[0]?.leftColumn;
const isSameItem = selectedLeftItem && leftPk &&
selectedLeftItem[leftPk] === item[leftPk];
if (isSameItem) {
// 선택 해제 → 전체 데이터 로드
setSelectedLeftItem(null);
setExpandedRightItems(new Set());
setTabsData({});
if (activeTabIndex === 0) {
loadRightData(null);
} else {
loadTabData(activeTabIndex, null);
}
// 추가 탭들도 전체 데이터 로드
const tabs = componentConfig.rightPanel?.additionalTabs;
if (tabs && tabs.length > 0) {
tabs.forEach((_: any, idx: number) => {
if (idx + 1 !== activeTabIndex) {
loadTabData(idx + 1, null);
}
});
}
return;
}
setSelectedLeftItem(item); setSelectedLeftItem(item);
setExpandedRightItems(new Set()); // 좌측 항목 변경 시 우측 확장 초기화 setExpandedRightItems(new Set()); // 좌측 항목 변경 시 우측 확장 초기화
setTabsData({}); // 모든 탭 데이터 초기화 setTabsData({}); // 모든 탭 데이터 초기화
@ -1450,7 +1591,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
}); });
} }
}, },
[loadRightData, loadTabData, activeTabIndex, componentConfig.leftPanel?.tableName, isDesignMode], [loadRightData, loadTabData, activeTabIndex, componentConfig.leftPanel?.tableName, componentConfig.rightPanel?.relation, componentConfig.rightPanel?.additionalTabs, isDesignMode, selectedLeftItem],
); );
// 우측 항목 확장/축소 토글 // 우측 항목 확장/축소 토글
@ -2026,10 +2167,8 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
if (editModalPanel === "left") { if (editModalPanel === "left") {
loadLeftData(); loadLeftData();
// 우측 패널도 새로고침 (FK가 변경되었을 수 있음) // 우측 패널도 새로고침 (FK가 변경되었을 수 있음)
if (selectedLeftItem) { loadRightData(selectedLeftItem);
loadRightData(selectedLeftItem); } else if (editModalPanel === "right") {
}
} else if (editModalPanel === "right" && selectedLeftItem) {
loadRightData(selectedLeftItem); loadRightData(selectedLeftItem);
} }
} else { } else {
@ -2160,7 +2299,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
setSelectedLeftItem(null); setSelectedLeftItem(null);
setRightData(null); setRightData(null);
} }
} else if (deleteModalPanel === "right" && selectedLeftItem) { } else if (deleteModalPanel === "right") {
loadRightData(selectedLeftItem); loadRightData(selectedLeftItem);
} }
} else { } else {
@ -2317,7 +2456,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
if (addModalPanel === "left" || addModalPanel === "left-item") { if (addModalPanel === "left" || addModalPanel === "left-item") {
// 좌측 패널 데이터 새로고침 (일반 추가 또는 하위 항목 추가) // 좌측 패널 데이터 새로고침 (일반 추가 또는 하위 항목 추가)
loadLeftData(); loadLeftData();
} else if (addModalPanel === "right" && selectedLeftItem) { } else if (addModalPanel === "right") {
// 우측 패널 데이터 새로고침 // 우측 패널 데이터 새로고침
loadRightData(selectedLeftItem); loadRightData(selectedLeftItem);
} }
@ -2405,10 +2544,22 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
} }
}, [leftColumnVisibility, componentConfig.leftPanel?.tableName, currentUserId]); }, [leftColumnVisibility, componentConfig.leftPanel?.tableName, currentUserId]);
// 초기 데이터 로드 // 초기 데이터 로드 (좌측 + 우측 전체 데이터)
useEffect(() => { useEffect(() => {
if (!isDesignMode && componentConfig.autoLoad !== false) { if (!isDesignMode && componentConfig.autoLoad !== false) {
loadLeftData(); loadLeftData();
// 좌측 미선택 상태에서 우측 전체 데이터 기본 로드
const relationshipType = componentConfig.rightPanel?.relation?.type || "detail";
if (relationshipType === "join") {
loadRightData(null);
// 추가 탭도 전체 데이터 로드
const tabs = componentConfig.rightPanel?.additionalTabs;
if (tabs && tabs.length > 0) {
tabs.forEach((_: any, idx: number) => {
loadTabData(idx + 1, null);
});
}
}
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDesignMode, componentConfig.autoLoad]); }, [isDesignMode, componentConfig.autoLoad]);
@ -2421,19 +2572,17 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [leftFilters]); }, [leftFilters]);
// 🆕 전역 테이블 새로고침 이벤트 리스너 // 전역 테이블 새로고침 이벤트 리스너
useEffect(() => { useEffect(() => {
const handleRefreshTable = () => { const handleRefreshTable = () => {
if (!isDesignMode) { if (!isDesignMode) {
console.log("🔄 [SplitPanel] refreshTable 이벤트 수신 - 데이터 새로고침"); console.log("🔄 [SplitPanel] refreshTable 이벤트 수신 - 데이터 새로고침");
loadLeftData(); loadLeftData();
// 선택된 항목이 있으면 현재 활성 탭 데이터 새로고침 // 현재 활성 탭 데이터 새로고침 (좌측 미선택 시에도 전체 데이터 로드)
if (selectedLeftItem) { if (activeTabIndex === 0) {
if (activeTabIndex === 0) { loadRightData(selectedLeftItem);
loadRightData(selectedLeftItem); } else {
} else { loadTabData(activeTabIndex, selectedLeftItem);
loadTabData(activeTabIndex, selectedLeftItem);
}
} }
} }
}; };
@ -3339,15 +3488,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
); );
} }
if (!selectedLeftItem) { if (currentTabData.length === 0 && !isTabLoading) {
return (
<div className="text-muted-foreground flex h-full flex-col items-center justify-center gap-2 py-12 text-sm">
<p> </p>
</div>
);
}
if (currentTabData.length === 0) {
return ( return (
<div className="text-muted-foreground flex h-full flex-col items-center justify-center gap-2 py-12 text-sm"> <div className="text-muted-foreground flex h-full flex-col items-center justify-center gap-2 py-12 text-sm">
<p> .</p> <p> .</p>
@ -4107,11 +4248,20 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</div> </div>
</div> </div>
) : ( ) : (
// 선택 없음 // 데이터 없음 또는 초기 로딩 대기
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
<div className="text-muted-foreground text-center text-sm"> <div className="text-muted-foreground text-center text-sm">
<p className="mb-2"> </p> {componentConfig.rightPanel?.relation?.type === "join" ? (
<p className="text-xs"> </p> <>
<Loader2 className="text-muted-foreground mx-auto h-6 w-6 animate-spin" />
<p className="mt-2"> ...</p>
</>
) : (
<>
<p className="mb-2"> </p>
<p className="text-xs"> </p>
</>
)}
</div> </div>
</div> </div>
)} )}

View File

@ -328,7 +328,7 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
<AccordionItem <AccordionItem
key={tab.tabId} key={tab.tabId}
value={tab.tabId} value={tab.tabId}
className="rounded-lg border bg-gray-50" className="rounded-lg border bg-card"
> >
<AccordionTrigger className="px-3 py-2 hover:no-underline"> <AccordionTrigger className="px-3 py-2 hover:no-underline">
<div className="flex flex-1 items-center gap-2"> <div className="flex flex-1 items-center gap-2">
@ -341,11 +341,11 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
)} )}
</div> </div>
</AccordionTrigger> </AccordionTrigger>
<AccordionContent className="px-3 pb-3"> <AccordionContent className="space-y-4 px-3 pb-3">
<div className="space-y-4">
{/* ===== 1. 기본 정보 ===== */} {/* ===== 1. 기본 정보 ===== */}
<div className="space-y-3 rounded-lg border bg-white p-3"> <div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-3">
<Label className="text-xs font-semibold text-blue-600"> </Label> <h3 className="border-l-2 border-l-primary/40 pl-2 text-xs font-semibold"> </h3>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs"> </Label> <Label className="text-xs"> </Label>
@ -366,123 +366,120 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
/> />
</div> </div>
</div> </div>
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
type="number"
value={tab.panelHeaderHeight ?? 48}
onChange={(e) => updateTab({ panelHeaderHeight: parseInt(e.target.value) || 48 })}
placeholder="48"
className="h-8 w-24 text-xs"
/>
</div>
</div> </div>
{/* ===== 2. 테이블 선택 ===== */} {/* ===== 2. 테이블 선택 ===== */}
<div className="space-y-3 rounded-lg border bg-white p-3"> <div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-3">
<Label className="text-xs font-semibold text-blue-600"> </Label> <h3 className="border-l-2 border-l-primary/40 pl-2 text-xs font-semibold"> </h3>
<div className="space-y-1"> <Popover>
<Label className="text-xs"> </Label> <PopoverTrigger asChild>
<Popover> <Button
<PopoverTrigger asChild> variant="outline"
<Button role="combobox"
variant="outline" className="h-8 w-full justify-between text-xs"
role="combobox" >
className="h-8 w-full justify-between text-xs" {tab.tableName || "테이블을 선택하세요"}
> <ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
{tab.tableName || "테이블을 선택하세요"} </Button>
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" /> </PopoverTrigger>
</Button> <PopoverContent className="w-full p-0">
</PopoverTrigger> <Command>
<PopoverContent className="w-full p-0"> <CommandInput placeholder="테이블 검색..." className="text-xs" />
<Command> <CommandEmpty> .</CommandEmpty>
<CommandInput placeholder="테이블 검색..." className="text-xs" /> <CommandGroup className="max-h-[200px] overflow-auto">
<CommandEmpty> .</CommandEmpty> {availableRightTables.map((table) => (
<CommandGroup className="max-h-[200px] overflow-auto"> <CommandItem
{availableRightTables.map((table) => ( key={table.tableName}
<CommandItem value={`${table.displayName || ""} ${table.tableName}`}
key={table.tableName} onSelect={() => updateTab({ tableName: table.tableName, columns: [] })}
value={`${table.displayName || ""} ${table.tableName}`} >
onSelect={() => updateTab({ tableName: table.tableName, columns: [] })} <Check
> className={cn(
<Check "mr-2 h-4 w-4",
className={cn( tab.tableName === table.tableName ? "opacity-100" : "opacity-0"
"mr-2 h-4 w-4", )}
tab.tableName === table.tableName ? "opacity-100" : "opacity-0" />
)} {table.displayName || table.tableName}
/> {table.displayName && <span className="ml-2 text-xs text-gray-500">({table.tableName})</span>}
{table.displayName || table.tableName} </CommandItem>
</CommandItem> ))}
))} </CommandGroup>
</CommandGroup> </Command>
</Command> </PopoverContent>
</PopoverContent> </Popover>
</Popover>
</div>
</div> </div>
{/* ===== 3. 표시 모드 ===== */} {/* ===== 3. 표시 모드 + 요약 설정 ===== */}
<div className="space-y-3 rounded-lg border bg-white p-3"> <div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-3">
<Label className="text-xs font-semibold text-blue-600"> </Label> <h3 className="border-l-2 border-l-primary/40 pl-2 text-xs font-semibold"> </h3>
<div className="space-y-1"> <div className="space-y-2">
<Label className="text-xs"> </Label> <Label className="text-xs"> </Label>
<Select <Select
value={tab.displayMode || "list"} value={tab.displayMode || "list"}
onValueChange={(value: "list" | "table") => updateTab({ displayMode: value })} onValueChange={(value: "list" | "table") => updateTab({ displayMode: value })}
> >
<SelectTrigger className="h-8 text-xs"> <SelectTrigger className="h-8 bg-white text-xs">
<SelectValue /> <SelectValue>
{(tab.displayMode || "list") === "list" ? "목록 (LIST)" : "테이블 (TABLE)"}
</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="list"> ()</SelectItem> <SelectItem value="list">
<SelectItem value="table"></SelectItem> <div className="flex flex-col py-1">
<span className="text-sm font-medium"> (LIST)</span>
<span className="text-xs text-gray-500"> ()</span>
</div>
</SelectItem>
<SelectItem value="table">
<div className="flex flex-col py-1">
<span className="text-sm font-medium"> (TABLE)</span>
<span className="text-xs text-gray-500"> </span>
</div>
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
{/* 요약 설정 (목록 모드) */} {/* 요약 설정 (목록 모드) */}
{tab.displayMode === "list" && ( {(tab.displayMode || "list") === "list" && (
<div className="grid grid-cols-2 gap-3"> <div className="space-y-3 rounded-lg border border-gray-200 bg-gray-50 p-3">
<div className="space-y-1"> <Label className="text-sm font-semibold"> </Label>
<Label className="text-xs"> </Label> <div className="space-y-2">
<Label className="text-xs"> </Label>
<Input <Input
type="number" type="number" min="1" max="10"
value={tab.summaryColumnCount ?? 3} value={tab.summaryColumnCount ?? 3}
onChange={(e) => updateTab({ summaryColumnCount: parseInt(e.target.value) || 3 })} onChange={(e) => updateTab({ summaryColumnCount: parseInt(e.target.value) || 3 })}
min={1} className="bg-white"
max={10}
className="h-8 text-xs"
/> />
<p className="text-xs text-gray-500"> (기본: 3개)</p>
</div> </div>
<div className="flex items-center gap-2 pt-5"> <div className="flex items-center justify-between space-x-2">
<div className="flex-1">
<Label className="text-xs"> </Label>
<p className="text-xs text-gray-500"> </p>
</div>
<Checkbox <Checkbox
id={`tab-${tabIndex}-summary-label`}
checked={tab.summaryShowLabel ?? true} checked={tab.summaryShowLabel ?? true}
onCheckedChange={(checked) => updateTab({ summaryShowLabel: !!checked })} onCheckedChange={(checked) => updateTab({ summaryShowLabel: checked as boolean })}
/> />
<label htmlFor={`tab-${tabIndex}-summary-label`} className="text-xs"> </label>
</div> </div>
</div> </div>
)} )}
</div> </div>
{/* ===== 4. 컬럼 매핑 (연결 키) ===== */} {/* ===== 4. 컬럼 매핑 (연결 키) ===== */}
<div className="space-y-3 rounded-lg border bg-white p-3"> <div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-3">
<Label className="text-xs font-semibold text-blue-600"> ( )</Label> <h3 className="border-l-2 border-l-primary/40 pl-2 text-xs font-semibold"> ( )</h3>
<p className="text-[10px] text-gray-500"> <p className="text-muted-foreground text-[10px]"> </p>
<div className="grid grid-cols-2 gap-2">
</p>
<div className="mt-2 grid grid-cols-2 gap-2">
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-[10px]"> </Label> <Label className="text-[10px]"> </Label>
<Select <Select
value={tab.relation?.keys?.[0]?.leftColumn || tab.relation?.leftColumn || "__none__"} value={tab.relation?.keys?.[0]?.leftColumn || tab.relation?.leftColumn || "__none__"}
onValueChange={(value) => { onValueChange={(value) => {
if (value === "__none__") { if (value === "__none__") {
// 선택 안 함 - 조인 키 제거 updateTab({ relation: undefined });
updateTab({
relation: undefined,
});
} else { } else {
updateTab({ updateTab({
relation: { relation: {
@ -494,17 +491,13 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
} }
}} }}
> >
<SelectTrigger className="h-7 text-xs"> <SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="선택" /> <SelectValue placeholder="선택" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="__none__"> <SelectItem value="__none__"><span className="text-muted-foreground"> ( )</span></SelectItem>
<span className="text-muted-foreground"> ( )</span>
</SelectItem>
{leftTableColumns.map((col) => ( {leftTableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}> <SelectItem key={col.columnName} value={col.columnName}>{col.columnLabel || col.columnName}</SelectItem>
{col.columnLabel || col.columnName}
</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
@ -515,10 +508,7 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
value={tab.relation?.keys?.[0]?.rightColumn || tab.relation?.foreignKey || "__none__"} value={tab.relation?.keys?.[0]?.rightColumn || tab.relation?.foreignKey || "__none__"}
onValueChange={(value) => { onValueChange={(value) => {
if (value === "__none__") { if (value === "__none__") {
// 선택 안 함 - 조인 키 제거 updateTab({ relation: undefined });
updateTab({
relation: undefined,
});
} else { } else {
updateTab({ updateTab({
relation: { relation: {
@ -530,17 +520,13 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
} }
}} }}
> >
<SelectTrigger className="h-7 text-xs"> <SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="선택" /> <SelectValue placeholder="선택" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="__none__"> <SelectItem value="__none__"><span className="text-muted-foreground"> ( )</span></SelectItem>
<span className="text-muted-foreground"> ( )</span>
</SelectItem>
{tabColumns.map((col) => ( {tabColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}> <SelectItem key={col.columnName} value={col.columnName}>{col.columnLabel || col.columnName}</SelectItem>
{col.columnLabel || col.columnName}
</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
@ -549,215 +535,202 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
</div> </div>
{/* ===== 5. 기능 버튼 ===== */} {/* ===== 5. 기능 버튼 ===== */}
<div className="space-y-3 rounded-lg border bg-white p-3"> <div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-3">
<Label className="text-xs font-semibold text-blue-600"> </Label> <h3 className="border-l-2 border-l-primary/40 pl-2 text-xs font-semibold"> </h3>
<div className="grid grid-cols-4 gap-2"> <div className="grid grid-cols-4 gap-2">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Checkbox <Checkbox id={`tab-${tabIndex}-search`} checked={tab.showSearch} onCheckedChange={(checked) => updateTab({ showSearch: !!checked })} />
id={`tab-${tabIndex}-search`}
checked={tab.showSearch}
onCheckedChange={(checked) => updateTab({ showSearch: !!checked })}
/>
<label htmlFor={`tab-${tabIndex}-search`} className="text-xs"></label> <label htmlFor={`tab-${tabIndex}-search`} className="text-xs"></label>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Checkbox <Checkbox id={`tab-${tabIndex}-add`} checked={tab.showAdd} onCheckedChange={(checked) => updateTab({ showAdd: !!checked })} />
id={`tab-${tabIndex}-add`}
checked={tab.showAdd}
onCheckedChange={(checked) => updateTab({ showAdd: !!checked })}
/>
<label htmlFor={`tab-${tabIndex}-add`} className="text-xs"></label> <label htmlFor={`tab-${tabIndex}-add`} className="text-xs"></label>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Checkbox <Checkbox id={`tab-${tabIndex}-edit`} checked={tab.showEdit} onCheckedChange={(checked) => updateTab({ showEdit: !!checked })} />
id={`tab-${tabIndex}-edit`}
checked={tab.showEdit}
onCheckedChange={(checked) => updateTab({ showEdit: !!checked })}
/>
<label htmlFor={`tab-${tabIndex}-edit`} className="text-xs"></label> <label htmlFor={`tab-${tabIndex}-edit`} className="text-xs"></label>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Checkbox <Checkbox id={`tab-${tabIndex}-delete`} checked={tab.showDelete} onCheckedChange={(checked) => updateTab({ showDelete: !!checked })} />
id={`tab-${tabIndex}-delete`}
checked={tab.showDelete}
onCheckedChange={(checked) => updateTab({ showDelete: !!checked })}
/>
<label htmlFor={`tab-${tabIndex}-delete`} className="text-xs"></label> <label htmlFor={`tab-${tabIndex}-delete`} className="text-xs"></label>
</div> </div>
</div> </div>
</div> </div>
{/* ===== 6. 표시 컬럼 설정 ===== */} {/* ===== 6. 표시할 컬럼 - DnD + Entity 조인 통합 ===== */}
<div className="space-y-3 rounded-lg border border-green-200 bg-green-50 p-3"> {(() => {
<div className="flex items-center justify-between"> const selectedColumns = tab.columns || [];
<Label className="text-xs font-semibold text-green-700"> </Label> const filteredTabCols = tabColumns.filter((c) => !["company_code", "company_name"].includes(c.columnName));
<Button const unselectedCols = filteredTabCols.filter((c) => !selectedColumns.some((sc) => sc.name === c.columnName));
size="sm" const dbNumericTypes = ["numeric", "decimal", "integer", "bigint", "double precision", "real", "smallint", "int4", "int8", "float4", "float8"];
variant="outline" const inputNumericTypes = ["number", "decimal", "currency", "integer"];
onClick={() => {
const currentColumns = tab.columns || [];
const newColumns = [...currentColumns, { name: "", label: "", width: 100 }];
updateTab({ columns: newColumns });
}}
className="h-7 text-xs"
disabled={!tab.tableName || loadingTabColumns}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
<p className="text-[10px] text-gray-600">
. .
</p>
{/* 테이블 미선택 상태 */} const handleTabDragEnd = (event: DragEndEvent) => {
{!tab.tableName && ( const { active, over } = event;
<div className="rounded-md border border-dashed border-gray-300 bg-white p-3 text-center"> if (over && active.id !== over.id) {
<p className="text-xs text-gray-500"> </p> const oldIndex = selectedColumns.findIndex((c) => c.name === active.id);
</div> const newIndex = selectedColumns.findIndex((c) => c.name === over.id);
)} if (oldIndex !== -1 && newIndex !== -1) {
updateTab({ columns: arrayMove([...selectedColumns], oldIndex, newIndex) });
}
}
};
{/* 테이블 선택됨 - 컬럼 목록 */} return (
{tab.tableName && ( <div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-3">
<div className="space-y-2"> <h3 className="border-l-2 border-l-primary/40 pl-2 text-xs font-semibold"> ({selectedColumns.length} )</h3>
{/* 로딩 상태 */} <div className="max-h-[400px] overflow-y-auto rounded-md border bg-white p-2">
{loadingTabColumns && ( {!tab.tableName ? (
<div className="rounded-md border border-dashed border-gray-300 bg-white p-3 text-center"> <p className="text-muted-foreground py-2 text-center text-xs"> </p>
<p className="text-xs text-gray-500"> ...</p> ) : loadingTabColumns ? (
</div> <p className="text-muted-foreground py-2 text-center text-xs"> ...</p>
)} ) : (
<>
{selectedColumns.length > 0 && (
<DndContext collisionDetection={closestCenter} onDragEnd={handleTabDragEnd}>
<SortableContext items={selectedColumns.map((c) => c.name)} strategy={verticalListSortingStrategy}>
<div className="space-y-1">
{selectedColumns.map((col, index) => {
const colInfo = tabColumns.find((c) => c.columnName === col.name);
const isNumeric = colInfo && (
dbNumericTypes.includes(colInfo.dataType?.toLowerCase() || "") ||
inputNumericTypes.includes(colInfo.input_type?.toLowerCase() || "") ||
inputNumericTypes.includes(colInfo.webType?.toLowerCase() || "")
);
return (
<SortableColumnRow
key={col.name}
id={col.name}
col={col}
index={index}
isNumeric={!!isNumeric}
isEntityJoin={!!(col as any).isEntityJoin}
onLabelChange={(value) => {
const newColumns = [...selectedColumns];
newColumns[index] = { ...newColumns[index], label: value };
updateTab({ columns: newColumns });
}}
onWidthChange={(value) => {
const newColumns = [...selectedColumns];
newColumns[index] = { ...newColumns[index], width: value };
updateTab({ columns: newColumns });
}}
onFormatChange={(checked) => {
const newColumns = [...selectedColumns];
newColumns[index] = { ...newColumns[index], format: { ...newColumns[index].format, type: "number", thousandSeparator: checked } };
updateTab({ columns: newColumns });
}}
onRemove={() => updateTab({ columns: selectedColumns.filter((_, i) => i !== index) })}
/>
);
})}
</div>
</SortableContext>
</DndContext>
)}
{/* 설정된 컬럼이 없을 때 */} {selectedColumns.length > 0 && unselectedCols.length > 0 && (
{!loadingTabColumns && (tab.columns || []).length === 0 && ( <div className="border-border/60 my-2 flex items-center gap-2 border-t pt-2">
<div className="rounded-md border border-dashed border-gray-300 bg-white p-3 text-center"> <span className="text-muted-foreground text-[10px]"> </span>
<p className="text-xs text-gray-500"> </p> </div>
<p className="mt-1 text-[10px] text-gray-400"> </p> )}
</div>
)}
{/* 설정된 컬럼 목록 */} <div className="space-y-0.5">
{!loadingTabColumns && (tab.columns || []).length > 0 && ( {unselectedCols.map((column) => (
(tab.columns || []).map((col, colIndex) => ( <div
<div key={colIndex} className="space-y-2 rounded-md border bg-white p-3"> key={column.columnName}
{/* 상단: 순서 변경 + 삭제 버튼 */} className="hover:bg-muted/50 flex cursor-pointer items-center gap-2 rounded px-2 py-1.5"
<div className="flex items-center justify-between">
<div className="flex items-center gap-1">
<Button
size="sm"
variant="ghost"
onClick={() => { onClick={() => {
if (colIndex === 0) return; updateTab({ columns: [...selectedColumns, { name: column.columnName, label: column.columnLabel || column.columnName, width: 100 }] });
const newColumns = [...(tab.columns || [])];
[newColumns[colIndex - 1], newColumns[colIndex]] = [newColumns[colIndex], newColumns[colIndex - 1]];
updateTab({ columns: newColumns });
}} }}
disabled={colIndex === 0}
className="h-6 w-6 p-0"
> >
<ArrowUp className="h-3 w-3" /> <Checkbox checked={false} className="pointer-events-none h-3.5 w-3.5 shrink-0" />
</Button> <span className="text-muted-foreground truncate text-xs">{column.columnLabel || column.columnName}</span>
<Button </div>
size="sm" ))}
variant="ghost"
onClick={() => {
const columns = tab.columns || [];
if (colIndex === columns.length - 1) return;
const newColumns = [...columns];
[newColumns[colIndex], newColumns[colIndex + 1]] = [newColumns[colIndex + 1], newColumns[colIndex]];
updateTab({ columns: newColumns });
}}
disabled={colIndex === (tab.columns || []).length - 1}
className="h-6 w-6 p-0"
>
<ArrowDown className="h-3 w-3" />
</Button>
<span className="text-[10px] text-gray-400">#{colIndex + 1}</span>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => {
const newColumns = (tab.columns || []).filter((_, i) => i !== colIndex);
updateTab({ columns: newColumns });
}}
className="h-6 w-6 p-0 text-red-500 hover:bg-red-50 hover:text-red-600"
>
<X className="h-3 w-3" />
</Button>
</div> </div>
{/* 컬럼 선택 */} {/* Entity 조인 컬럼 - 아코디언 (접기/펼치기) */}
<div className="space-y-1"> {(() => {
<Label className="text-[10px] text-gray-500"></Label> const joinData = tab.tableName ? entityJoinColumnsMap?.[tab.tableName] : null;
<Select if (!joinData || joinData.joinTables.length === 0) return null;
value={col.name}
onValueChange={(value) => {
const selectedCol = tabColumns.find((c) => c.columnName === value);
const newColumns = [...(tab.columns || [])];
newColumns[colIndex] = {
...col,
name: value,
label: selectedCol?.columnLabel || value,
};
updateTab({ columns: newColumns });
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="컬럼을 선택하세요" />
</SelectTrigger>
<SelectContent>
{tabColumns.map((column) => (
<SelectItem key={column.columnName} value={column.columnName}>
{column.columnLabel || column.columnName}
<span className="ml-1 text-[10px] text-gray-400">({column.columnName})</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 라벨 + 너비 */} return joinData.joinTables.map((joinTable, tableIndex) => {
<div className="grid grid-cols-2 gap-2"> const joinColumnsToShow = joinTable.availableColumns.filter((column) => {
<div className="space-y-1"> const matchingJoinColumn = joinData.availableColumns.find(
<Label className="text-[10px] text-gray-500"></Label> (jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName,
<Input );
value={col.label} if (!matchingJoinColumn) return false;
onChange={(e) => { return !selectedColumns.some((c) => c.name === matchingJoinColumn.joinAlias);
const newColumns = [...(tab.columns || [])]; });
newColumns[colIndex] = { ...col, label: e.target.value }; const addedCount = joinTable.availableColumns.length - joinColumnsToShow.length;
updateTab({ columns: newColumns }); if (joinColumnsToShow.length === 0 && addedCount === 0) return null;
}}
placeholder="표시 라벨" return (
className="h-8 text-xs" <details key={`tab-join-${tableIndex}`} className="group">
/> <summary className="border-border/60 my-2 flex cursor-pointer list-none items-center gap-2 border-t pt-2 select-none">
</div> <ChevronRight className="h-3 w-3 shrink-0 text-blue-500 transition-transform group-open:rotate-90" />
<div className="space-y-1"> <Link2 className="h-3 w-3 shrink-0 text-blue-500" />
<Label className="text-[10px] text-gray-500"> (px)</Label> <span className="text-[10px] font-medium text-blue-600">{joinTable.tableName}</span>
<Input {addedCount > 0 && (
type="number" <span className="rounded-full bg-blue-100 px-1.5 text-[9px] font-medium text-blue-600">{addedCount} </span>
value={col.width || 100} )}
onChange={(e) => { <span className="text-[9px] text-gray-400">{joinColumnsToShow.length} </span>
const newColumns = [...(tab.columns || [])]; </summary>
newColumns[colIndex] = { ...col, width: parseInt(e.target.value) || 100 }; <div className="space-y-0.5 pt-1">
updateTab({ columns: newColumns }); {joinColumnsToShow.map((column, colIndex) => {
}} const matchingJoinColumn = joinData.availableColumns.find(
placeholder="100" (jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName,
className="h-8 text-xs" );
/> if (!matchingJoinColumn) return null;
</div>
</div> return (
</div> <div
)) key={colIndex}
)} className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 hover:bg-blue-50/60"
onClick={() => {
updateTab({
columns: [...selectedColumns, {
name: matchingJoinColumn.joinAlias,
label: matchingJoinColumn.suggestedLabel || matchingJoinColumn.columnLabel,
width: 100,
isEntityJoin: true,
joinInfo: {
sourceTable: tab.tableName!,
sourceColumn: (joinTable as any).joinConfig?.sourceColumn || "",
referenceTable: matchingJoinColumn.tableName,
joinAlias: matchingJoinColumn.joinAlias,
},
}],
});
}}
>
<Checkbox checked={false} className="pointer-events-none h-3.5 w-3.5 shrink-0" />
<Link2 className="h-3 w-3 shrink-0 text-blue-500" />
<span className="truncate text-xs text-blue-700">{column.columnLabel || column.columnName}</span>
</div>
);
})}
{joinColumnsToShow.length === 0 && (
<p className="px-2 py-1 text-[10px] text-gray-400"> </p>
)}
</div>
</details>
);
});
})()}
</>
)}
</div>
</div> </div>
)} );
</div> })()}
{/* ===== 7. 추가 모달 컬럼 설정 (showAdd일 때) ===== */} {/* ===== 7. 추가 모달 컬럼 설정 (showAdd일 때) ===== */}
{tab.showAdd && ( {tab.showAdd && (
<div className="space-y-3 rounded-lg border border-purple-200 bg-purple-50 p-3"> <div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label className="text-xs font-semibold text-purple-700"> </Label> <h3 className="border-l-2 border-l-primary/40 pl-2 text-xs font-semibold"> </h3>
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
@ -845,76 +818,11 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
</div> </div>
)} )}
{/* ===== 7.5 Entity 조인 컬럼 ===== */} {/* Entity 조인 컬럼은 표시 컬럼 목록에 통합됨 */}
{(() => {
const joinData = tab.tableName ? entityJoinColumnsMap?.[tab.tableName] : null;
if (!joinData || joinData.joinTables.length === 0) return null;
return (
<div className="space-y-2 rounded-lg border bg-white p-3">
<Label className="text-xs font-semibold text-blue-600">Entity </Label>
<p className="text-muted-foreground text-[10px]"> </p>
{joinData.joinTables.map((joinTable, tableIndex) => (
<div key={tableIndex} className="space-y-1">
<div className="mb-1 flex items-center gap-2 text-[10px] font-medium text-blue-600">
<Link2 className="h-3 w-3" />
<span>{joinTable.tableName}</span>
<Badge variant="outline" className="text-[10px]">{joinTable.currentDisplayColumn}</Badge>
</div>
<div className="max-h-32 space-y-0.5 overflow-y-auto rounded-md border p-2">
{joinTable.availableColumns.map((column, colIndex) => {
const matchingJoinColumn = joinData.availableColumns.find(
(jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName,
);
if (!matchingJoinColumn) return null;
const tabColumns2 = tab.columns || [];
const isAdded = tabColumns2.some((c) => c.name === matchingJoinColumn.joinAlias);
return (
<div
key={colIndex}
className={cn(
"flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-blue-50",
isAdded && "bg-blue-50",
)}
onClick={() => {
if (isAdded) {
updateTab({ columns: tabColumns2.filter((c) => c.name !== matchingJoinColumn.joinAlias) });
} else {
updateTab({
columns: [...tabColumns2, {
name: matchingJoinColumn.joinAlias,
label: matchingJoinColumn.suggestedLabel || matchingJoinColumn.columnLabel,
width: 100,
isEntityJoin: true,
joinInfo: {
sourceTable: tab.tableName!,
sourceColumn: (joinTable as any).joinConfig?.sourceColumn || "",
referenceTable: matchingJoinColumn.tableName,
joinAlias: matchingJoinColumn.joinAlias,
},
}],
});
}
}}
>
<Checkbox checked={isAdded} className="pointer-events-none h-3.5 w-3.5" />
<Link2 className="h-3 w-3 shrink-0 text-blue-500" />
<span className="truncate text-xs">{column.columnLabel}</span>
<span className="ml-auto text-[10px] text-blue-400">{column.dataType}</span>
</div>
);
})}
</div>
</div>
))}
</div>
);
})()}
{/* ===== 8. 데이터 필터링 ===== */} {/* ===== 8. 데이터 필터링 ===== */}
<div className="space-y-3 rounded-lg border bg-white p-3"> <div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-3">
<Label className="text-xs font-semibold text-blue-600"> </Label> <h3 className="border-l-2 border-l-primary/40 pl-2 text-xs font-semibold"> </h3>
<DataFilterConfigPanel <DataFilterConfigPanel
tableName={tab.tableName} tableName={tab.tableName}
columns={tabColumns} columns={tabColumns}
@ -925,9 +833,9 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
</div> </div>
{/* ===== 9. 중복 데이터 제거 ===== */} {/* ===== 9. 중복 데이터 제거 ===== */}
<div className="space-y-3 rounded-lg border bg-white p-3"> <div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label className="text-xs font-semibold text-blue-600"> </Label> <h3 className="border-l-2 border-l-primary/40 pl-2 text-xs font-semibold"> </h3>
<Switch <Switch
checked={tab.deduplication?.enabled ?? false} checked={tab.deduplication?.enabled ?? false}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
@ -1019,8 +927,8 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
{/* ===== 10. 수정 버튼 설정 ===== */} {/* ===== 10. 수정 버튼 설정 ===== */}
{tab.showEdit && ( {tab.showEdit && (
<div className="space-y-3 rounded-lg border border-blue-200 bg-blue-50 p-3"> <div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-3">
<Label className="text-xs font-semibold text-blue-700"> </Label> <h3 className="border-l-2 border-l-primary/40 pl-2 text-xs font-semibold"> </h3>
<div className="space-y-2"> <div className="space-y-2">
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-[10px]"> </Label> <Label className="text-[10px]"> </Label>
@ -1125,8 +1033,8 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
{/* ===== 11. 삭제 버튼 설정 ===== */} {/* ===== 11. 삭제 버튼 설정 ===== */}
{tab.showDelete && ( {tab.showDelete && (
<div className="space-y-3 rounded-lg border border-red-200 bg-red-50 p-3"> <div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-3">
<Label className="text-xs font-semibold text-red-700"> </Label> <h3 className="border-l-2 border-l-primary/40 pl-2 text-xs font-semibold"> </h3>
<div className="space-y-2"> <div className="space-y-2">
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<div className="space-y-1"> <div className="space-y-1">
@ -1196,7 +1104,7 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
</Button> </Button>
</div> </div>
</div>
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
); );