Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management
This commit is contained in:
@@ -32,6 +32,7 @@ import {
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
||||
import { TableFilter, ColumnVisibility, GroupSumConfig } from "@/types/table-options";
|
||||
@@ -169,6 +170,11 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
const [rightSearchQuery, setRightSearchQuery] = useState("");
|
||||
const [isLoadingLeft, setIsLoadingLeft] = useState(false);
|
||||
const [isLoadingRight, setIsLoadingRight] = useState(false);
|
||||
|
||||
// 🆕 추가 탭 관련 상태
|
||||
const [activeTabIndex, setActiveTabIndex] = useState(0); // 0 = 기본 탭 (우측 패널), 1+ = 추가 탭
|
||||
const [tabsData, setTabsData] = useState<Record<number, any[]>>({}); // 탭별 데이터 캐시
|
||||
const [tabsLoading, setTabsLoading] = useState<Record<number, boolean>>({}); // 탭별 로딩 상태
|
||||
const [rightTableColumns, setRightTableColumns] = useState<any[]>([]); // 우측 테이블 컬럼 정보
|
||||
const [expandedItems, setExpandedItems] = useState<Set<any>>(new Set()); // 펼쳐진 항목들
|
||||
const [leftColumnLabels, setLeftColumnLabels] = useState<Record<string, string>>({}); // 좌측 컬럼 라벨
|
||||
@@ -610,6 +616,41 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
return result;
|
||||
}, []);
|
||||
|
||||
// 🆕 간단한 값 포맷팅 함수 (추가 탭용)
|
||||
const formatValue = useCallback(
|
||||
(
|
||||
value: any,
|
||||
format?: {
|
||||
type?: "number" | "currency" | "date" | "text";
|
||||
thousandSeparator?: boolean;
|
||||
decimalPlaces?: number;
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
dateFormat?: string;
|
||||
},
|
||||
): string => {
|
||||
if (value === null || value === undefined) return "-";
|
||||
|
||||
// 날짜 포맷
|
||||
if (format?.type === "date" || format?.dateFormat) {
|
||||
return formatDateValue(value, format?.dateFormat || "YYYY-MM-DD");
|
||||
}
|
||||
|
||||
// 숫자 포맷
|
||||
if (
|
||||
format?.type === "number" ||
|
||||
format?.type === "currency" ||
|
||||
format?.thousandSeparator ||
|
||||
format?.decimalPlaces !== undefined
|
||||
) {
|
||||
return formatNumberValue(value, format);
|
||||
}
|
||||
|
||||
return String(value);
|
||||
},
|
||||
[formatDateValue, formatNumberValue],
|
||||
);
|
||||
|
||||
// 셀 값 포맷팅 함수 (카테고리 타입 처리 + 날짜/숫자 포맷)
|
||||
const formatCellValue = useCallback(
|
||||
(
|
||||
@@ -960,11 +1001,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
|
||||
console.log("🔗 [분할패널] 복합키 조건:", searchConditions);
|
||||
|
||||
// 엔티티 조인 API로 데이터 조회
|
||||
// 엔티티 조인 API로 데이터 조회 (🆕 deduplication 전달)
|
||||
const result = await entityJoinApi.getTableDataWithJoins(rightTableName, {
|
||||
search: searchConditions,
|
||||
enableEntityJoin: true,
|
||||
size: 1000,
|
||||
deduplication: componentConfig.rightPanel?.deduplication, // 🆕 중복 제거 설정 전달
|
||||
});
|
||||
|
||||
console.log("🔗 [분할패널] 복합키 조회 결과:", result);
|
||||
@@ -1037,12 +1079,137 @@ 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 { entityJoinApi } = await import("@/lib/api/entityJoin");
|
||||
const searchConditions: Record<string, any> = {};
|
||||
|
||||
if (keys && keys.length > 0) {
|
||||
// 복합키
|
||||
keys.forEach((key) => {
|
||||
if (key.leftColumn && key.rightColumn && leftItem[key.leftColumn] !== undefined) {
|
||||
searchConditions[key.rightColumn] = leftItem[key.leftColumn];
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 단일키
|
||||
const leftValue = leftItem[leftColumn];
|
||||
if (leftValue !== undefined) {
|
||||
searchConditions[rightColumn] = leftValue;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`🔗 [추가탭 ${tabIndex}] 조회 조건:`, searchConditions);
|
||||
|
||||
const result = await entityJoinApi.getTableDataWithJoins(tabTableName, {
|
||||
search: searchConditions,
|
||||
enableEntityJoin: true,
|
||||
size: 1000,
|
||||
});
|
||||
|
||||
resultData = result.data || [];
|
||||
} else {
|
||||
// 조인 조건이 없는 경우: 전체 데이터 조회 (독립 탭)
|
||||
const { entityJoinApi } = await import("@/lib/api/entityJoin");
|
||||
const result = await entityJoinApi.getTableDataWithJoins(tabTableName, {
|
||||
enableEntityJoin: true,
|
||||
size: 1000,
|
||||
});
|
||||
resultData = result.data || [];
|
||||
}
|
||||
|
||||
// 데이터 필터 적용
|
||||
const dataFilter = tabConfig.dataFilter;
|
||||
if (dataFilter?.enabled && dataFilter.conditions?.length > 0) {
|
||||
resultData = resultData.filter((item: any) => {
|
||||
return dataFilter.conditions.every((cond: any) => {
|
||||
const value = item[cond.column];
|
||||
const condValue = cond.value;
|
||||
switch (cond.operator) {
|
||||
case "equals":
|
||||
return value === condValue;
|
||||
case "notEquals":
|
||||
return value !== condValue;
|
||||
case "contains":
|
||||
return String(value).includes(String(condValue));
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 중복 제거 적용
|
||||
const deduplication = tabConfig.deduplication;
|
||||
if (deduplication?.enabled && deduplication.groupByColumn) {
|
||||
const groupedMap = new Map<string, any>();
|
||||
resultData.forEach((item) => {
|
||||
const key = String(item[deduplication.groupByColumn] || "");
|
||||
const existing = groupedMap.get(key);
|
||||
if (!existing) {
|
||||
groupedMap.set(key, item);
|
||||
} else {
|
||||
// keepStrategy에 따라 유지할 항목 결정
|
||||
const sortCol = deduplication.sortColumn || "start_date";
|
||||
const existingVal = existing[sortCol];
|
||||
const newVal = item[sortCol];
|
||||
if (deduplication.keepStrategy === "latest" && newVal > existingVal) {
|
||||
groupedMap.set(key, item);
|
||||
} else if (deduplication.keepStrategy === "earliest" && newVal < existingVal) {
|
||||
groupedMap.set(key, item);
|
||||
}
|
||||
}
|
||||
});
|
||||
resultData = Array.from(groupedMap.values());
|
||||
}
|
||||
|
||||
console.log(`🔗 [추가탭 ${tabIndex}] 결과 데이터:`, resultData.length);
|
||||
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 handleLeftItemSelect = useCallback(
|
||||
(item: any) => {
|
||||
setSelectedLeftItem(item);
|
||||
setExpandedRightItems(new Set()); // 좌측 항목 변경 시 우측 확장 초기화
|
||||
loadRightData(item);
|
||||
setTabsData({}); // 모든 탭 데이터 초기화
|
||||
|
||||
// 현재 활성 탭에 따라 데이터 로드
|
||||
if (activeTabIndex === 0) {
|
||||
loadRightData(item);
|
||||
} else {
|
||||
loadTabData(activeTabIndex, item);
|
||||
}
|
||||
|
||||
// 🆕 modalDataStore에 선택된 좌측 항목 저장 (단일 선택)
|
||||
const leftTableName = componentConfig.leftPanel?.tableName;
|
||||
@@ -1053,7 +1220,30 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
});
|
||||
}
|
||||
},
|
||||
[loadRightData, componentConfig.leftPanel?.tableName, isDesignMode],
|
||||
[loadRightData, loadTabData, activeTabIndex, componentConfig.leftPanel?.tableName, isDesignMode],
|
||||
);
|
||||
|
||||
// 🆕 탭 변경 핸들러
|
||||
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],
|
||||
);
|
||||
|
||||
// 우측 항목 확장/축소 토글
|
||||
@@ -1449,14 +1639,19 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
|
||||
// 수정 버튼 핸들러
|
||||
const handleEditClick = useCallback(
|
||||
(panel: "left" | "right", item: any) => {
|
||||
async (panel: "left" | "right", item: any) => {
|
||||
// 🆕 현재 활성 탭의 설정 가져오기
|
||||
const currentTabConfig =
|
||||
activeTabIndex === 0
|
||||
? componentConfig.rightPanel
|
||||
: componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1];
|
||||
// 🆕 우측 패널 수정 버튼 설정 확인
|
||||
if (panel === "right" && componentConfig.rightPanel?.editButton?.mode === "modal") {
|
||||
const modalScreenId = componentConfig.rightPanel?.editButton?.modalScreenId;
|
||||
if (panel === "right" && currentTabConfig?.editButton?.mode === "modal") {
|
||||
const modalScreenId = currentTabConfig?.editButton?.modalScreenId;
|
||||
|
||||
if (modalScreenId) {
|
||||
// 커스텀 모달 화면 열기
|
||||
const rightTableName = componentConfig.rightPanel?.tableName || "";
|
||||
const rightTableName = currentTabConfig?.tableName || "";
|
||||
|
||||
console.log("✅ 수정 모달 열기:", {
|
||||
tableName: rightTableName,
|
||||
@@ -1470,33 +1665,108 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
});
|
||||
|
||||
// 🆕 groupByColumns 추출
|
||||
const groupByColumns = componentConfig.rightPanel?.editButton?.groupByColumns || [];
|
||||
const groupByColumns = currentTabConfig?.editButton?.groupByColumns || [];
|
||||
|
||||
console.log("🔧 [SplitPanel] 수정 버튼 클릭 - groupByColumns 확인:", {
|
||||
groupByColumns,
|
||||
editButtonConfig: componentConfig.rightPanel?.editButton,
|
||||
editButtonConfig: currentTabConfig?.editButton,
|
||||
hasGroupByColumns: groupByColumns.length > 0,
|
||||
});
|
||||
|
||||
// 🆕 groupByColumns 기준으로 모든 관련 레코드 조회 (API 직접 호출)
|
||||
let allRelatedRecords = [item]; // 기본값: 현재 아이템만
|
||||
|
||||
if (groupByColumns.length > 0) {
|
||||
// groupByColumns 값으로 검색 조건 생성
|
||||
const matchConditions: Record<string, any> = {};
|
||||
groupByColumns.forEach((col: string) => {
|
||||
if (item[col] !== undefined && item[col] !== null) {
|
||||
matchConditions[col] = item[col];
|
||||
}
|
||||
});
|
||||
|
||||
console.log("🔍 [SplitPanel] 그룹 레코드 조회 시작:", {
|
||||
테이블: rightTableName,
|
||||
조건: matchConditions,
|
||||
});
|
||||
|
||||
if (Object.keys(matchConditions).length > 0) {
|
||||
// 🆕 deduplication 없이 원본 데이터 다시 조회 (API 직접 호출)
|
||||
try {
|
||||
const { entityJoinApi } = await import("@/lib/api/entityJoin");
|
||||
|
||||
// 🔧 dataFilter로 정확 매칭 조건 생성 (search는 LIKE 검색이라 부정확)
|
||||
const exactMatchFilters = Object.entries(matchConditions).map(([key, value]) => ({
|
||||
id: `exact-${key}`,
|
||||
columnName: key,
|
||||
operator: "equals",
|
||||
value: value,
|
||||
valueType: "text",
|
||||
}));
|
||||
|
||||
console.log("🔍 [SplitPanel] 정확 매칭 필터:", exactMatchFilters);
|
||||
|
||||
const result = await entityJoinApi.getTableDataWithJoins(rightTableName, {
|
||||
// search 대신 dataFilter 사용 (정확 매칭)
|
||||
dataFilter: {
|
||||
enabled: true,
|
||||
matchType: "all",
|
||||
filters: exactMatchFilters,
|
||||
},
|
||||
enableEntityJoin: true,
|
||||
size: 1000,
|
||||
// 🔧 명시적으로 deduplication 비활성화 (모든 레코드 가져오기)
|
||||
deduplication: { enabled: false, groupByColumn: "", keepStrategy: "latest" },
|
||||
});
|
||||
|
||||
// 🔍 디버깅: API 응답 구조 확인
|
||||
console.log("🔍 [SplitPanel] API 응답 전체:", result);
|
||||
console.log("🔍 [SplitPanel] result.data:", result.data);
|
||||
console.log("🔍 [SplitPanel] result 타입:", typeof result);
|
||||
|
||||
// result 자체가 배열일 수도 있음 (entityJoinApi 응답 구조에 따라)
|
||||
const dataArray = Array.isArray(result) ? result : (result.data || []);
|
||||
|
||||
if (dataArray.length > 0) {
|
||||
allRelatedRecords = dataArray;
|
||||
console.log("✅ [SplitPanel] 그룹 레코드 조회 완료:", {
|
||||
조건: matchConditions,
|
||||
결과수: allRelatedRecords.length,
|
||||
레코드들: allRelatedRecords.map((r: any) => ({ id: r.id, supplier_item_code: r.supplier_item_code })),
|
||||
});
|
||||
} else {
|
||||
console.warn("⚠️ [SplitPanel] 그룹 레코드 조회 결과 없음, 현재 아이템만 사용");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ [SplitPanel] 그룹 레코드 조회 실패:", error);
|
||||
allRelatedRecords = [item];
|
||||
}
|
||||
} else {
|
||||
console.warn("⚠️ [SplitPanel] groupByColumns 값이 없음, 현재 아이템만 사용");
|
||||
}
|
||||
}
|
||||
|
||||
// 🔧 수정: URL 파라미터 대신 editData로 직접 전달
|
||||
// 이렇게 하면 테이블의 Primary Key가 무엇이든 상관없이 데이터가 정확히 전달됨
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("openScreenModal", {
|
||||
detail: {
|
||||
screenId: modalScreenId,
|
||||
editData: item, // 전체 데이터를 직접 전달
|
||||
...(groupByColumns.length > 0 && {
|
||||
urlParams: {
|
||||
editData: allRelatedRecords, // 🆕 모든 관련 레코드 전달 (배열)
|
||||
urlParams: {
|
||||
mode: "edit", // 🆕 수정 모드 표시
|
||||
...(groupByColumns.length > 0 && {
|
||||
groupByColumns: JSON.stringify(groupByColumns),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
console.log("✅ [SplitPanel] openScreenModal 이벤트 발생 (editData 직접 전달):", {
|
||||
screenId: modalScreenId,
|
||||
editData: item,
|
||||
editData: allRelatedRecords,
|
||||
recordCount: allRelatedRecords.length,
|
||||
groupByColumns: groupByColumns.length > 0 ? JSON.stringify(groupByColumns) : "없음",
|
||||
});
|
||||
|
||||
@@ -1510,7 +1780,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
setEditModalFormData({ ...item });
|
||||
setShowEditModal(true);
|
||||
},
|
||||
[componentConfig],
|
||||
[componentConfig, activeTabIndex],
|
||||
);
|
||||
|
||||
// 수정 모달 저장
|
||||
@@ -1610,13 +1880,19 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
|
||||
// 삭제 확인
|
||||
const handleDeleteConfirm = useCallback(async () => {
|
||||
// 🆕 현재 활성 탭의 설정 가져오기
|
||||
const currentTabConfig =
|
||||
activeTabIndex === 0
|
||||
? componentConfig.rightPanel
|
||||
: componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1];
|
||||
|
||||
// 우측 패널 삭제 시 중계 테이블 확인
|
||||
let tableName =
|
||||
deleteModalPanel === "left" ? componentConfig.leftPanel?.tableName : componentConfig.rightPanel?.tableName;
|
||||
deleteModalPanel === "left" ? componentConfig.leftPanel?.tableName : currentTabConfig?.tableName;
|
||||
|
||||
// 우측 패널 + 중계 테이블 모드인 경우
|
||||
if (deleteModalPanel === "right" && componentConfig.rightPanel?.addConfig?.targetTable) {
|
||||
tableName = componentConfig.rightPanel.addConfig.targetTable;
|
||||
if (deleteModalPanel === "right" && currentTabConfig?.addConfig?.targetTable) {
|
||||
tableName = currentTabConfig.addConfig.targetTable;
|
||||
console.log("🔗 중계 테이블 모드: 삭제 대상 테이블 =", tableName);
|
||||
}
|
||||
|
||||
@@ -1746,7 +2022,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
setRightData(null);
|
||||
}
|
||||
} else if (deleteModalPanel === "right" && selectedLeftItem) {
|
||||
loadRightData(selectedLeftItem);
|
||||
// 🆕 현재 활성 탭에 따라 새로고침
|
||||
if (activeTabIndex === 0) {
|
||||
loadRightData(selectedLeftItem);
|
||||
} else {
|
||||
loadTabData(activeTabIndex, selectedLeftItem);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
toast({
|
||||
@@ -1770,7 +2051,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}, [deleteModalPanel, componentConfig, deleteModalItem, toast, selectedLeftItem, loadLeftData, loadRightData]);
|
||||
}, [deleteModalPanel, componentConfig, deleteModalItem, toast, selectedLeftItem, loadLeftData, loadRightData, activeTabIndex, loadTabData]);
|
||||
|
||||
// 항목별 추가 버튼 핸들러 (좌측 항목의 + 버튼 - 하위 항목 추가)
|
||||
const handleItemAddClick = useCallback(
|
||||
@@ -2591,6 +2872,34 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
className="flex flex-shrink-0 flex-col"
|
||||
>
|
||||
<Card className="flex flex-col border-0 shadow-none" style={{ height: "100%" }}>
|
||||
{/* 🆕 탭 바 (추가 탭이 있을 때만 표시) */}
|
||||
{(componentConfig.rightPanel?.additionalTabs?.length || 0) > 0 && (
|
||||
<div className="flex-shrink-0 border-b">
|
||||
<Tabs
|
||||
value={String(activeTabIndex)}
|
||||
onValueChange={(value) => handleTabChange(Number(value))}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="h-9 w-full justify-start rounded-none border-b-0 bg-transparent p-0 px-2">
|
||||
<TabsTrigger
|
||||
value="0"
|
||||
className="h-8 rounded-none border-b-2 border-transparent px-4 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
|
||||
>
|
||||
{componentConfig.rightPanel?.title || "기본"}
|
||||
</TabsTrigger>
|
||||
{componentConfig.rightPanel?.additionalTabs?.map((tab, index) => (
|
||||
<TabsTrigger
|
||||
key={tab.tabId}
|
||||
value={String(index + 1)}
|
||||
className="h-8 rounded-none border-b-2 border-transparent px-4 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
|
||||
>
|
||||
{tab.label || `탭 ${index + 1}`}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
<CardHeader
|
||||
className="flex-shrink-0 border-b"
|
||||
style={{
|
||||
@@ -2603,16 +2912,28 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<CardTitle className="text-base font-semibold">
|
||||
{componentConfig.rightPanel?.title || "우측 패널"}
|
||||
{activeTabIndex === 0
|
||||
? componentConfig.rightPanel?.title || "우측 패널"
|
||||
: componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1]?.title ||
|
||||
componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1]?.label ||
|
||||
"우측 패널"}
|
||||
</CardTitle>
|
||||
{!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]?.showAdd && (
|
||||
<Button size="sm" variant="outline" onClick={() => handleAddClick("right")}>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
추가
|
||||
</Button>
|
||||
)}
|
||||
{/* 우측 패널 수정/삭제는 각 카드에서 처리 */}
|
||||
</div>
|
||||
)}
|
||||
@@ -2632,16 +2953,228 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
</div>
|
||||
)}
|
||||
<CardContent className="flex-1 overflow-auto p-4">
|
||||
{/* 우측 데이터 */}
|
||||
{isLoadingRight ? (
|
||||
// 로딩 중
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Loader2 className="text-primary mx-auto h-8 w-8 animate-spin" />
|
||||
<p className="text-muted-foreground mt-2 text-sm">데이터를 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : rightData ? (
|
||||
{/* 🆕 추가 탭 데이터 렌더링 */}
|
||||
{activeTabIndex > 0 ? (
|
||||
// 추가 탭 컨텐츠
|
||||
(() => {
|
||||
const currentTabConfig = componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1];
|
||||
const currentTabData = tabsData[activeTabIndex] || [];
|
||||
const isTabLoading = tabsLoading[activeTabIndex];
|
||||
|
||||
if (isTabLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Loader2 className="text-primary mx-auto h-8 w-8 animate-spin" />
|
||||
<p className="text-muted-foreground mt-2 text-sm">데이터를 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedLeftItem) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground text-sm">좌측에서 항목을 선택하세요</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (currentTabData.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground text-sm">데이터가 없습니다</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 탭 데이터 렌더링 (목록/테이블 모드)
|
||||
const isTableMode = currentTabConfig?.displayMode === "table";
|
||||
|
||||
if (isTableMode) {
|
||||
// 테이블 모드
|
||||
const displayColumns = currentTabConfig?.columns || [];
|
||||
const columnsToShow =
|
||||
displayColumns.length > 0
|
||||
? displayColumns.map((col) => ({
|
||||
...col,
|
||||
label: col.label || col.name,
|
||||
}))
|
||||
: Object.keys(currentTabData[0] || {})
|
||||
.filter(shouldShowField)
|
||||
.slice(0, 8)
|
||||
.map((key) => ({ name: key, label: key }));
|
||||
|
||||
return (
|
||||
<div className="overflow-auto rounded-lg border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 sticky top-0">
|
||||
<tr>
|
||||
{columnsToShow.map((col: any) => (
|
||||
<th
|
||||
key={col.name}
|
||||
className="px-3 py-2 text-left font-medium"
|
||||
style={{ width: col.width ? `${col.width}px` : "auto" }}
|
||||
>
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
{(currentTabConfig?.showEdit || currentTabConfig?.showDelete) && (
|
||||
<th className="w-20 px-3 py-2 text-center font-medium">작업</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{currentTabData.map((item: any, idx: number) => (
|
||||
<tr key={item.id || idx} className="hover:bg-muted/30 border-t">
|
||||
{columnsToShow.map((col: any) => (
|
||||
<td key={col.name} className="px-3 py-2">
|
||||
{formatValue(item[col.name], col.format)}
|
||||
</td>
|
||||
))}
|
||||
{(currentTabConfig?.showEdit || currentTabConfig?.showDelete) && (
|
||||
<td className="px-3 py-2 text-center">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
{currentTabConfig?.showEdit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => handleEditClick("right", item)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{currentTabConfig?.showDelete && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-red-500 hover:text-red-600"
|
||||
onClick={() => handleDeleteClick("right", item)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
// 목록 (카드) 모드
|
||||
const displayColumns = currentTabConfig?.columns || [];
|
||||
const summaryCount = currentTabConfig?.summaryColumnCount ?? 3;
|
||||
const showLabel = currentTabConfig?.summaryShowLabel ?? true;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{currentTabData.map((item: any, idx: number) => {
|
||||
const itemId = item.id || idx;
|
||||
const isExpanded = expandedRightItems.has(itemId);
|
||||
|
||||
// 표시할 컬럼 결정
|
||||
const columnsToShow =
|
||||
displayColumns.length > 0
|
||||
? displayColumns
|
||||
: Object.keys(item)
|
||||
.filter(shouldShowField)
|
||||
.slice(0, 8)
|
||||
.map((key) => ({ name: key, label: key }));
|
||||
|
||||
const summaryColumns = columnsToShow.slice(0, summaryCount);
|
||||
const detailColumns = columnsToShow.slice(summaryCount);
|
||||
|
||||
return (
|
||||
<div key={itemId} className="rounded-lg border bg-white p-3">
|
||||
<div
|
||||
className="flex cursor-pointer items-start justify-between"
|
||||
onClick={() => toggleRightItemExpansion(itemId)}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||
{summaryColumns.map((col: any) => (
|
||||
<div key={col.name} className="text-sm">
|
||||
{showLabel && (
|
||||
<span className="text-muted-foreground mr-1">{col.label}:</span>
|
||||
)}
|
||||
<span className={col.bold ? "font-semibold" : ""}>
|
||||
{formatValue(item[col.name], col.format)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-2 flex items-center gap-1">
|
||||
{currentTabConfig?.showEdit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEditClick("right", item);
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{currentTabConfig?.showDelete && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-red-500 hover:text-red-600"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteClick("right", item);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{detailColumns.length > 0 && (
|
||||
isExpanded ? (
|
||||
<ChevronUp className="h-4 w-4 text-gray-400" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-gray-400" />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isExpanded && detailColumns.length > 0 && (
|
||||
<div className="mt-2 border-t pt-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{detailColumns.map((col: any) => (
|
||||
<div key={col.name} className="text-sm">
|
||||
<span className="text-muted-foreground">{col.label}:</span>
|
||||
<span className="ml-1">{formatValue(item[col.name], col.format)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})()
|
||||
) : (
|
||||
/* 기본 탭 (우측 패널) 데이터 */
|
||||
<>
|
||||
{isLoadingRight ? (
|
||||
// 로딩 중
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Loader2 className="text-primary mx-auto h-8 w-8 animate-spin" />
|
||||
<p className="text-muted-foreground mt-2 text-sm">데이터를 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : rightData ? (
|
||||
// 실제 데이터 표시
|
||||
Array.isArray(rightData) ? (
|
||||
// 조인 모드: 여러 데이터를 테이블/리스트로 표시
|
||||
@@ -3084,6 +3617,8 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user