feat: Enhance screen management with conditional layer and zone handling

- Updated the ScreenManagementService to allow general companies to query both their own zones and common zones.
- Improved the ScreenViewPage to include detailed logging for loaded conditional layers and zones.
- Added functionality to ignore empty targetComponentId in condition evaluations.
- Enhanced the EditModal and LayerManagerPanel to support loading conditional layers and dynamic options based on selected zones.
- Implemented additional tab configurations to manage entity join columns effectively in the SplitPanelLayout components.
This commit is contained in:
DDD1542
2026-02-09 19:36:06 +09:00
parent 30ee36f881
commit 45029bf5f4
9 changed files with 1729 additions and 489 deletions

View File

@@ -199,6 +199,11 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const [isLoadingRight, setIsLoadingRight] = useState(false);
const [rightTableColumns, setRightTableColumns] = useState<any[]>([]); // 우측 테이블 컬럼 정보
const [expandedItems, setExpandedItems] = useState<Set<any>>(new Set()); // 펼쳐진 항목들
// 추가 탭 관련 상태
const [activeTabIndex, setActiveTabIndex] = useState(0); // 0 = 기본 탭, 1+ = 추가 탭
const [tabsData, setTabsData] = useState<Record<number, any[]>>({}); // 탭별 데이터
const [tabsLoading, setTabsLoading] = useState<Record<number, boolean>>({}); // 탭별 로딩 상태
const [leftColumnLabels, setLeftColumnLabels] = useState<Record<string, string>>({}); // 좌측 컬럼 라벨
const [rightColumnLabels, setRightColumnLabels] = useState<Record<string, string>>({}); // 우측 컬럼 라벨
const [leftCategoryMappings, setLeftCategoryMappings] = useState<
@@ -1255,14 +1260,109 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
],
);
// 추가 탭 데이터 로딩 함수
const loadTabData = useCallback(
async (tabIndex: number, leftItem: any) => {
const tabConfig = componentConfig.rightPanel?.additionalTabs?.[tabIndex - 1];
if (!tabConfig || !leftItem || isDesignMode) return;
const tabTableName = tabConfig.tableName;
if (!tabTableName) return;
setTabsLoading((prev) => ({ ...prev, [tabIndex]: true }));
try {
const keys = tabConfig.relation?.keys;
const leftColumn = tabConfig.relation?.leftColumn || keys?.[0]?.leftColumn;
const rightColumn = tabConfig.relation?.foreignKey || keys?.[0]?.rightColumn;
let resultData: any[] = [];
if (leftColumn && rightColumn) {
const searchConditions: Record<string, any> = {};
if (keys && keys.length > 0) {
keys.forEach((key: any) => {
if (key.leftColumn && key.rightColumn && leftItem[key.leftColumn] !== undefined) {
searchConditions[key.rightColumn] = {
value: leftItem[key.leftColumn],
operator: "equals",
};
}
});
} else {
const leftValue = leftItem[leftColumn];
if (leftValue !== undefined) {
searchConditions[rightColumn] = {
value: leftValue,
operator: "equals",
};
}
}
const result = await entityJoinApi.getTableDataWithJoins(tabTableName, {
search: searchConditions,
enableEntityJoin: true,
size: 1000,
});
resultData = result.data || [];
} else {
const result = await entityJoinApi.getTableDataWithJoins(tabTableName, {
enableEntityJoin: true,
size: 1000,
});
resultData = result.data || [];
}
setTabsData((prev) => ({ ...prev, [tabIndex]: resultData }));
} catch (error) {
console.error(`추가탭 ${tabIndex} 데이터 로드 실패:`, error);
toast({
title: "데이터 로드 실패",
description: `탭 데이터를 불러올 수 없습니다.`,
variant: "destructive",
});
} finally {
setTabsLoading((prev) => ({ ...prev, [tabIndex]: false }));
}
},
[componentConfig.rightPanel?.additionalTabs, isDesignMode, toast],
);
// 탭 변경 핸들러
const handleTabChange = useCallback(
(newTabIndex: number) => {
setActiveTabIndex(newTabIndex);
if (selectedLeftItem) {
if (newTabIndex === 0) {
if (!rightData || (Array.isArray(rightData) && rightData.length === 0)) {
loadRightData(selectedLeftItem);
}
} else {
if (!tabsData[newTabIndex]) {
loadTabData(newTabIndex, selectedLeftItem);
}
}
}
},
[selectedLeftItem, rightData, tabsData, loadRightData, loadTabData],
);
// 좌측 항목 선택 핸들러
const handleLeftItemSelect = useCallback(
(item: any) => {
setSelectedLeftItem(item);
setExpandedRightItems(new Set()); // 좌측 항목 변경 시 우측 확장 초기화
loadRightData(item);
setTabsData({}); // 모든 탭 데이터 초기화
// 🆕 modalDataStore에 선택된 좌측 항목 저장 (단일 선택)
// 현재 활성 탭에 따라 데이터 로드
if (activeTabIndex === 0) {
loadRightData(item);
} else {
loadTabData(activeTabIndex, item);
}
// modalDataStore에 선택된 좌측 항목 저장 (단일 선택)
const leftTableName = componentConfig.leftPanel?.tableName;
if (leftTableName && !isDesignMode) {
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
@@ -1271,7 +1371,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
});
}
},
[loadRightData, componentConfig.leftPanel?.tableName, isDesignMode],
[loadRightData, loadTabData, activeTabIndex, componentConfig.leftPanel?.tableName, isDesignMode],
);
// 우측 항목 확장/축소 토글
@@ -1574,6 +1674,22 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
}
});
// 🆕 추가 탭의 테이블도 카테고리 로드 대상에 포함
const additionalTabs = componentConfig.rightPanel?.additionalTabs || [];
additionalTabs.forEach((tab: any) => {
if (tab.tableName) {
tablesToLoad.add(tab.tableName);
}
// 추가 탭 컬럼에서 조인된 테이블 추출
(tab.columns || []).forEach((col: any) => {
const colName = col.name || col.columnName;
if (colName && colName.includes(".")) {
const joinTableName = colName.split(".")[0];
tablesToLoad.add(joinTableName);
}
});
});
console.log("🔍 우측 패널 카테고리 로드 대상 테이블:", Array.from(tablesToLoad));
// 각 테이블에 대해 카테고리 매핑 로드
@@ -1625,7 +1741,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
};
loadRightCategoryMappings();
}, [componentConfig.rightPanel?.tableName, componentConfig.rightPanel?.columns, isDesignMode]);
}, [componentConfig.rightPanel?.tableName, componentConfig.rightPanel?.columns, componentConfig.rightPanel?.additionalTabs, isDesignMode]);
// 항목 펼치기/접기 토글
const toggleExpand = useCallback((itemId: any) => {
@@ -1668,13 +1784,24 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
// 수정 버튼 핸들러
const handleEditClick = useCallback(
(panel: "left" | "right", item: any) => {
// 🆕 우측 패널 수정 버튼 설정 확인
if (panel === "right" && componentConfig.rightPanel?.editButton?.mode === "modal") {
const modalScreenId = componentConfig.rightPanel?.editButton?.modalScreenId;
// 우측 패널 수정 버튼 설정 확인 (탭별 설정 지원)
if (panel === "right") {
const editButtonConfig =
activeTabIndex === 0
? componentConfig.rightPanel?.editButton
: (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.editButton;
if (modalScreenId) {
// 커스텀 모달 화면 열기
const rightTableName = componentConfig.rightPanel?.tableName || "";
const currentTableName =
activeTabIndex === 0
? componentConfig.rightPanel?.tableName || ""
: (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.tableName || "";
if (editButtonConfig?.mode === "modal") {
const modalScreenId = editButtonConfig?.modalScreenId;
if (modalScreenId) {
// 커스텀 모달 화면 열기
const rightTableName = currentTableName;
// Primary Key 찾기 (우선순위: id > ID > user_id > {table}_id > 첫 번째 필드)
let primaryKeyName = "id";
@@ -1750,7 +1877,8 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
groupByColumns: groupByColumns.length > 0 ? JSON.stringify(groupByColumns) : "없음",
});
return;
return;
}
}
}
@@ -1760,7 +1888,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
setEditModalFormData({ ...item });
setShowEditModal(true);
},
[componentConfig],
[componentConfig, activeTabIndex],
);
// 수정 모달 저장
@@ -2220,9 +2348,13 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
if (!isDesignMode) {
console.log("🔄 [SplitPanel] refreshTable 이벤트 수신 - 데이터 새로고침");
loadLeftData();
// 선택된 항목이 있으면 우측 패널도 새로고침
// 선택된 항목이 있으면 현재 활성 탭 데이터 새로고침
if (selectedLeftItem) {
loadRightData(selectedLeftItem);
if (activeTabIndex === 0) {
loadRightData(selectedLeftItem);
} else {
loadTabData(activeTabIndex, selectedLeftItem);
}
}
}
};
@@ -2232,7 +2364,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
return () => {
window.removeEventListener("refreshTable", handleRefreshTable);
};
}, [isDesignMode, loadLeftData, loadRightData, selectedLeftItem]);
}, [isDesignMode, loadLeftData, loadRightData, loadTabData, activeTabIndex, selectedLeftItem]);
// 리사이저 드래그 핸들러
const handleMouseDown = (e: React.MouseEvent) => {
@@ -3021,24 +3153,63 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
style={{
height: componentConfig.rightPanel?.panelHeaderHeight || 48,
minHeight: componentConfig.rightPanel?.panelHeaderHeight || 48,
padding: "0 1rem",
padding: "0 0.75rem",
display: "flex",
alignItems: "center",
}}
>
<div className="flex w-full items-center justify-between">
<CardTitle className="text-base font-semibold">
{componentConfig.rightPanel?.title || "우측 패널"}
</CardTitle>
<div className="flex items-center gap-0">
{/* 탭이 없으면 제목만, 있으면 탭으로 전환 */}
{(componentConfig.rightPanel?.additionalTabs?.length || 0) > 0 ? (
<div className="flex items-center gap-0">
<button
onClick={() => handleTabChange(0)}
className={cn(
"px-3 py-1 text-sm font-medium transition-colors",
activeTabIndex === 0
? "text-foreground border-b-2 border-primary"
: "text-muted-foreground hover:text-foreground"
)}
>
{componentConfig.rightPanel?.title || "기본"}
</button>
{componentConfig.rightPanel?.additionalTabs?.map((tab: any, index: number) => (
<button
key={tab.tabId || `tab-${index}`}
onClick={() => handleTabChange(index + 1)}
className={cn(
"px-3 py-1 text-sm font-medium transition-colors",
activeTabIndex === index + 1
? "text-foreground border-b-2 border-primary"
: "text-muted-foreground hover:text-foreground"
)}
>
{tab.label || `${index + 1}`}
</button>
))}
</div>
) : (
<CardTitle className="text-base font-semibold">
{componentConfig.rightPanel?.title || "우측 패널"}
</CardTitle>
)}
</div>
{!isDesignMode && (
<div className="flex items-center gap-2">
{componentConfig.rightPanel?.showAdd && (
<Button size="sm" variant="outline" onClick={() => handleAddClick("right")}>
<Plus className="mr-1 h-4 w-4" />
</Button>
)}
{/* 우측 패널 수정/삭제는 각 카드에서 처리 */}
{activeTabIndex === 0
? componentConfig.rightPanel?.showAdd && (
<Button size="sm" variant="outline" onClick={() => handleAddClick("right")}>
<Plus className="mr-1 h-4 w-4" />
</Button>
)
: (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.showAdd && (
<Button size="sm" variant="outline" onClick={() => handleAddClick("right")}>
<Plus className="mr-1 h-4 w-4" />
</Button>
)}
</div>
)}
</div>
@@ -3057,8 +3228,139 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</div>
)}
<CardContent className="flex-1 overflow-auto p-4">
{/* 우측 데이터/커스텀 */}
{componentConfig.rightPanel?.displayMode === "custom" ? (
{/* 추가 탭 컨텐츠 */}
{activeTabIndex > 0 ? (
(() => {
const currentTabConfig = componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any;
const currentTabData = tabsData[activeTabIndex] || [];
const isTabLoading = tabsLoading[activeTabIndex];
if (isTabLoading) {
return (
<div className="flex h-32 items-center justify-center">
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
</div>
);
}
if (!selectedLeftItem) {
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 (
<div className="text-muted-foreground flex h-full flex-col items-center justify-center gap-2 py-12 text-sm">
<p> .</p>
</div>
);
}
// 탭 컬럼 설정
const tabColumns = currentTabConfig?.columns || [];
// 테이블 모드로 표시
if (currentTabConfig?.displayMode === "table") {
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
{tabColumns.map((col: any) => (
<th key={col.name} className="text-muted-foreground px-3 py-2 text-left text-xs font-medium">
{col.label || col.name}
</th>
))}
{(currentTabConfig?.showEdit || currentTabConfig?.showDelete) && (
<th className="text-muted-foreground px-3 py-2 text-right text-xs font-medium"></th>
)}
</tr>
</thead>
<tbody>
{currentTabData.map((item: any, idx: number) => (
<tr key={item.id || idx} className="hover:bg-muted/50 border-b">
{tabColumns.map((col: any) => (
<td key={col.name} className="px-3 py-2 text-xs">
{formatCellValue(
col.name,
getEntityJoinValue(item, col.name),
rightCategoryMappings,
col.format,
)}
</td>
))}
{(currentTabConfig?.showEdit || currentTabConfig?.showDelete) && (
<td className="px-3 py-2 text-right">
<div className="flex items-center justify-end gap-1">
{currentTabConfig?.showEdit && (
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs"
onClick={() => handleEditClick("right", item)}
>
<Pencil className="h-3 w-3" />
</Button>
)}
{currentTabConfig?.showDelete && (
<Button size="sm" variant="ghost" className="text-destructive h-7 px-2 text-xs"
onClick={() => handleDeleteClick("right", item)}
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
);
}
// 리스트(카드) 모드로 표시
return (
<div className="space-y-2">
{currentTabData.map((item: any, idx: number) => (
<div key={item.id || idx} className="flex items-center justify-between rounded-lg border p-3">
<div className="flex flex-wrap items-center gap-2 text-xs">
{tabColumns.map((col: any) => (
<span key={col.name}>
{formatCellValue(
col.name,
getEntityJoinValue(item, col.name),
rightCategoryMappings,
col.format,
)}
</span>
))}
</div>
{(currentTabConfig?.showEdit || currentTabConfig?.showDelete) && (
<div className="flex items-center gap-1">
{currentTabConfig?.showEdit && (
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs"
onClick={() => handleEditClick("right", item)}
>
<Pencil className="h-3 w-3" />
</Button>
)}
{currentTabConfig?.showDelete && (
<Button size="sm" variant="ghost" className="text-destructive h-7 px-2 text-xs"
onClick={() => handleDeleteClick("right", item)}
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
)}
</div>
))}
</div>
);
})()
) : componentConfig.rightPanel?.displayMode === "custom" ? (
// 🆕 커스텀 모드: 패널 안에 자유롭게 컴포넌트 배치
<div
className="relative h-full w-full"

View File

@@ -50,6 +50,14 @@ export interface AdditionalTabConfig {
suffix?: string;
dateFormat?: string;
};
// Entity 조인 컬럼 정보
isEntityJoin?: boolean;
joinInfo?: {
sourceTable: string;
sourceColumn: string;
referenceTable: string;
joinAlias: string;
};
}>;
addModalColumns?: Array<{
@@ -145,6 +153,14 @@ export interface SplitPanelLayoutConfig {
suffix?: string; // 접미사 (예: "원", "개")
dateFormat?: string; // 날짜 포맷 (type: "date")
};
// Entity 조인 컬럼 정보
isEntityJoin?: boolean;
joinInfo?: {
sourceTable: string;
sourceColumn: string;
referenceTable: string;
joinAlias: string;
};
}>;
// 추가 모달에서 입력받을 컬럼 설정
addModalColumns?: Array<{
@@ -217,6 +233,14 @@ export interface SplitPanelLayoutConfig {
suffix?: string; // 접미사 (예: "원", "개")
dateFormat?: string; // 날짜 포맷 (type: "date")
};
// Entity 조인 컬럼 정보
isEntityJoin?: boolean;
joinInfo?: {
sourceTable: string;
sourceColumn: string;
referenceTable: string;
joinAlias: string;
};
}>;
// 추가 모달에서 입력받을 컬럼 설정
addModalColumns?: Array<{