Merge branch 'mhkim-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node

This commit is contained in:
kjs
2026-03-17 09:46:07 +09:00
9 changed files with 594 additions and 277 deletions

View File

@@ -2,7 +2,7 @@
import React, { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { ComponentRendererProps } from "../../types";
import { SplitPanelLayoutConfig } from "./types";
import { SplitPanelLayoutConfig, MAX_LOAD_ALL_SIZE } from "./types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -16,6 +16,9 @@ import {
ChevronUp,
Save,
ChevronRight,
ChevronLeft,
ChevronsLeft,
ChevronsRight,
Pencil,
Trash2,
Settings,
@@ -48,6 +51,66 @@ import { cn } from "@/lib/utils";
import { ResponsiveGridRenderer } from "@/components/screen/ResponsiveGridRenderer";
import { BomExcelUploadModal } from "../v2-bom-tree/BomExcelUploadModal";
/** 클라이언트 사이드 데이터 필터 (페이징 OFF 전용) */
function applyClientSideFilter(data: any[], dataFilter: any): any[] {
if (!dataFilter?.enabled) return data;
let result = data;
if (dataFilter.filters?.length > 0) {
const matchFn = dataFilter.matchType === "any" ? "some" : "every";
result = result.filter((item: any) =>
dataFilter.filters[matchFn]((cond: any) => {
const val = item[cond.columnName];
switch (cond.operator) {
case "equals":
return val === cond.value;
case "notEquals":
case "not_equals":
return val !== cond.value;
case "in": {
const arr = Array.isArray(cond.value) ? cond.value : [cond.value];
return arr.includes(val);
}
case "not_in": {
const arr = Array.isArray(cond.value) ? cond.value : [cond.value];
return !arr.includes(val);
}
case "contains":
return String(val || "").includes(String(cond.value));
case "is_null":
return val === null || val === undefined || val === "";
case "is_not_null":
return val !== null && val !== undefined && val !== "";
default:
return true;
}
}),
);
}
// legacy conditions 형식 (하위 호환성)
if (dataFilter.conditions?.length > 0) {
result = result.filter((item: any) =>
dataFilter.conditions.every((cond: any) => {
const val = item[cond.column];
switch (cond.operator) {
case "equals":
return val === cond.value;
case "notEquals":
return val !== cond.value;
case "contains":
return String(val || "").includes(String(cond.value));
default:
return true;
}
}),
);
}
return result;
}
export interface SplitPanelLayoutComponentProps extends ComponentRendererProps {
// 추가 props
onUpdateComponent?: (component: any) => void;
@@ -351,6 +414,22 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const [columnInputTypes, setColumnInputTypes] = useState<Record<string, string>>({});
const [expandedItems, setExpandedItems] = useState<Set<any>>(new Set()); // 펼쳐진 항목들
// 🆕 페이징 상태
const [leftCurrentPage, setLeftCurrentPage] = useState(1);
const [leftTotalPages, setLeftTotalPages] = useState(1);
const [leftTotal, setLeftTotal] = useState(0);
const [leftPageSize, setLeftPageSize] = useState(componentConfig.leftPanel?.pagination?.pageSize ?? 20);
const [rightCurrentPage, setRightCurrentPage] = useState(1);
const [rightTotalPages, setRightTotalPages] = useState(1);
const [rightTotal, setRightTotal] = useState(0);
const [rightPageSize, setRightPageSize] = useState(componentConfig.rightPanel?.pagination?.pageSize ?? 20);
const [tabsPagination, setTabsPagination] = useState<Record<number, { currentPage: number; totalPages: number; total: number; pageSize: number }>>({});
const [leftPageInput, setLeftPageInput] = useState("1");
const [rightPageInput, setRightPageInput] = useState("1");
const leftPaginationEnabled = componentConfig.leftPanel?.pagination?.enabled ?? false;
const rightPaginationEnabled = componentConfig.rightPanel?.pagination?.enabled ?? false;
// 추가 탭 관련 상태
const [activeTabIndex, setActiveTabIndex] = useState(0); // 0 = 기본 탭, 1+ = 추가 탭
const [tabsData, setTabsData] = useState<Record<number, any[]>>({}); // 탭별 데이터
@@ -919,13 +998,24 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
let columns = displayColumns;
// columnVisibility가 있으면 가시성 적용
// columnVisibility가 있으면 가시성 + 너비 적용
if (leftColumnVisibility.length > 0) {
const visibilityMap = new Map(leftColumnVisibility.map((cv) => [cv.columnName, cv.visible]));
columns = columns.filter((col: any) => {
const colName = typeof col === "string" ? col : col.name || col.columnName;
return visibilityMap.get(colName) !== false;
});
const visibilityMap = new Map(
leftColumnVisibility.map((cv) => [cv.columnName, cv])
);
columns = columns
.filter((col: any) => {
const colName = typeof col === "string" ? col : col.name || col.columnName;
return visibilityMap.get(colName)?.visible !== false;
})
.map((col: any) => {
const colName = typeof col === "string" ? col : col.name || col.columnName;
const cv = visibilityMap.get(colName);
if (cv?.width && typeof col === "object") {
return { ...col, width: cv.width };
}
return col;
});
}
// 🔧 컬럼 순서 적용
@@ -1241,87 +1331,62 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
return joinColumns.length > 0 ? joinColumns : undefined;
}, []);
// 좌측 데이터 로드
const loadLeftData = useCallback(async () => {
// 좌측 데이터 로드 (페이징 ON: page 파라미터 사용, OFF: 전체 로드)
const loadLeftData = useCallback(async (page?: number, pageSizeOverride?: number) => {
const leftTableName = componentConfig.leftPanel?.tableName;
if (!leftTableName || isDesignMode) return;
setIsLoadingLeft(true);
try {
// 🎯 필터 조건을 API에 전달 (entityJoinApi 사용)
const filters = Object.keys(searchValues).length > 0 ? searchValues : undefined;
// 🆕 좌측 패널 config의 Entity 조인 컬럼 추출 (헬퍼 함수 사용)
const leftJoinColumns = extractAdditionalJoinColumns(
componentConfig.leftPanel?.columns,
leftTableName,
);
console.log("🔗 [분할패널] 좌측 additionalJoinColumns:", leftJoinColumns);
if (leftPaginationEnabled) {
const currentPageToLoad = page ?? leftCurrentPage;
const effectivePageSize = pageSizeOverride ?? leftPageSize;
const result = await entityJoinApi.getTableDataWithJoins(leftTableName, {
page: currentPageToLoad,
size: effectivePageSize,
search: filters,
enableEntityJoin: true,
dataFilter: componentConfig.leftPanel?.dataFilter,
additionalJoinColumns: leftJoinColumns,
companyCodeOverride: companyCode,
});
const result = await entityJoinApi.getTableDataWithJoins(leftTableName, {
page: 1,
size: 100,
search: filters,
enableEntityJoin: true,
dataFilter: componentConfig.leftPanel?.dataFilter,
additionalJoinColumns: leftJoinColumns,
companyCodeOverride: companyCode,
});
setLeftData(result.data || []);
setLeftCurrentPage(result.page || currentPageToLoad);
setLeftTotalPages(result.totalPages || 1);
setLeftTotal(result.total || 0);
setLeftPageInput(String(result.page || currentPageToLoad));
} else {
const result = await entityJoinApi.getTableDataWithJoins(leftTableName, {
page: 1,
size: MAX_LOAD_ALL_SIZE,
search: filters,
enableEntityJoin: true,
dataFilter: componentConfig.leftPanel?.dataFilter,
additionalJoinColumns: leftJoinColumns,
companyCodeOverride: companyCode,
});
// 🔍 디버깅: API 응답 데이터의 키 확인
if (result.data && result.data.length > 0) {
console.log("🔗 [분할패널] API 응답 첫 번째 데이터 키:", Object.keys(result.data[0]));
console.log("🔗 [분할패널] API 응답 첫 번째 데이터:", result.data[0]);
}
let filteredLeftData = applyClientSideFilter(result.data || [], componentConfig.leftPanel?.dataFilter);
// 좌측 패널 dataFilter 클라이언트 사이드 적용
let filteredLeftData = result.data || [];
const leftDataFilter = componentConfig.leftPanel?.dataFilter;
if (leftDataFilter?.enabled && leftDataFilter.filters?.length > 0) {
const matchFn = leftDataFilter.matchType === "any" ? "some" : "every";
filteredLeftData = filteredLeftData.filter((item: any) => {
return leftDataFilter.filters[matchFn]((cond: any) => {
const val = item[cond.columnName];
switch (cond.operator) {
case "equals":
return val === cond.value;
case "not_equals":
return val !== cond.value;
case "in": {
const arr = Array.isArray(cond.value) ? cond.value : [cond.value];
return arr.includes(val);
}
case "not_in": {
const arr = Array.isArray(cond.value) ? cond.value : [cond.value];
return !arr.includes(val);
}
case "contains":
return String(val || "").includes(String(cond.value));
case "is_null":
return val === null || val === undefined || val === "";
case "is_not_null":
return val !== null && val !== undefined && val !== "";
default:
return true;
}
const leftColumn = componentConfig.rightPanel?.relation?.leftColumn;
if (leftColumn && filteredLeftData.length > 0) {
filteredLeftData.sort((a, b) => {
const aValue = String(a[leftColumn] || "");
const bValue = String(b[leftColumn] || "");
return aValue.localeCompare(bValue, "ko-KR");
});
});
}
}
// 가나다순 정렬 (좌측 패널의 표시 컬럼 기준)
const leftColumn = componentConfig.rightPanel?.relation?.leftColumn;
if (leftColumn && filteredLeftData.length > 0) {
filteredLeftData.sort((a, b) => {
const aValue = String(a[leftColumn] || "");
const bValue = String(b[leftColumn] || "");
return aValue.localeCompare(bValue, "ko-KR");
});
const hierarchicalData = buildHierarchy(filteredLeftData);
setLeftData(hierarchicalData);
}
// 계층 구조 빌드
const hierarchicalData = buildHierarchy(filteredLeftData);
setLeftData(hierarchicalData);
} catch (error) {
console.error("좌측 데이터 로드 실패:", error);
toast({
@@ -1337,15 +1402,25 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
componentConfig.leftPanel?.columns,
componentConfig.leftPanel?.dataFilter,
componentConfig.rightPanel?.relation?.leftColumn,
leftPaginationEnabled,
leftCurrentPage,
leftPageSize,
isDesignMode,
toast,
buildHierarchy,
searchValues,
]);
// 우측 데이터 로드 (leftItem이 null이면 전체 데이터 로드)
const updateRightPaginationState = useCallback((result: any, fallbackPage: number) => {
setRightCurrentPage(result.page || fallbackPage);
setRightTotalPages(result.totalPages || 1);
setRightTotal(result.total || 0);
setRightPageInput(String(result.page || fallbackPage));
}, []);
// 우측 데이터 로드 (leftItem이 null이면 전체 데이터 로드, page: 서버 페이징용)
const loadRightData = useCallback(
async (leftItem: any) => {
async (leftItem: any, page?: number, pageSizeOverride?: number) => {
const relationshipType = componentConfig.rightPanel?.relation?.type || "detail";
const rightTableName = componentConfig.rightPanel?.tableName;
@@ -1359,70 +1434,33 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
componentConfig.rightPanel?.columns,
rightTableName,
);
const effectivePageSize = pageSizeOverride ?? rightPageSize;
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":
case "not_equals":
return value !== cond.value;
case "in": {
const arr = Array.isArray(cond.value) ? cond.value : [cond.value];
return arr.includes(value);
}
case "not_in": {
const arr = Array.isArray(cond.value) ? cond.value : [cond.value];
return !arr.includes(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;
}
});
if (rightPaginationEnabled) {
const currentPageToLoad = page ?? rightCurrentPage;
const result = await entityJoinApi.getTableDataWithJoins(rightTableName, {
page: currentPageToLoad,
size: effectivePageSize,
enableEntityJoin: true,
companyCodeOverride: companyCode,
additionalJoinColumns: rightJoinColumns,
dataFilter: componentConfig.rightPanel?.dataFilter,
});
}
// 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(result.data || []);
updateRightPaginationState(result, currentPageToLoad);
} else {
const result = await entityJoinApi.getTableDataWithJoins(rightTableName, {
enableEntityJoin: true,
size: MAX_LOAD_ALL_SIZE,
companyCodeOverride: companyCode,
additionalJoinColumns: rightJoinColumns,
dataFilter: componentConfig.rightPanel?.dataFilter,
});
}
setRightData(filteredData);
const filteredData = applyClientSideFilter(result.data || [], componentConfig.rightPanel?.dataFilter);
setRightData(filteredData);
}
} catch (error) {
console.error("우측 전체 데이터 로드 실패:", error);
} finally {
@@ -1499,9 +1537,9 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const result = await entityJoinApi.getTableDataWithJoins(rightTableName, {
search: searchConditions,
enableEntityJoin: true,
size: 1000,
size: MAX_LOAD_ALL_SIZE,
companyCodeOverride: companyCode,
additionalJoinColumns: rightJoinColumnsForGroup, // 🆕 Entity 조인 컬럼 전달
additionalJoinColumns: rightJoinColumnsForGroup,
});
if (result.data) {
allResults.push(...result.data);
@@ -1540,16 +1578,19 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
console.log("🔗 [분할패널] 우측 패널 additionalJoinColumns:", rightJoinColumns);
}
// 엔티티 조인 API로 데이터 조회
const effectivePageSize = pageSizeOverride ?? rightPageSize;
const result = await entityJoinApi.getTableDataWithJoins(rightTableName, {
search: searchConditions,
enableEntityJoin: true,
size: 1000,
size: rightPaginationEnabled ? effectivePageSize : MAX_LOAD_ALL_SIZE,
page: rightPaginationEnabled ? (page ?? rightCurrentPage) : undefined,
companyCodeOverride: companyCode,
additionalJoinColumns: rightJoinColumns,
});
console.log("🔗 [분할패널] 복합키 조회 결과:", result);
if (rightPaginationEnabled) {
updateRightPaginationState(result, page ?? rightCurrentPage);
}
setRightData(result.data || []);
} else {
@@ -1576,14 +1617,20 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
console.log("🔗 [분할패널] 단일키 모드 additionalJoinColumns:", rightJoinColumnsLegacy);
}
const effectivePageSizeLegacy = pageSizeOverride ?? rightPageSize;
const result = await entityJoinApi.getTableDataWithJoins(rightTableName, {
search: searchConditions,
enableEntityJoin: true,
size: 1000,
size: rightPaginationEnabled ? effectivePageSizeLegacy : MAX_LOAD_ALL_SIZE,
page: rightPaginationEnabled ? (page ?? rightCurrentPage) : undefined,
companyCodeOverride: companyCode,
additionalJoinColumns: rightJoinColumnsLegacy,
});
if (rightPaginationEnabled) {
updateRightPaginationState(result, page ?? rightCurrentPage);
}
setRightData(result.data || []);
}
}
@@ -1604,14 +1651,18 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
componentConfig.rightPanel?.tableName,
componentConfig.rightPanel?.relation,
componentConfig.leftPanel?.tableName,
rightPaginationEnabled,
rightCurrentPage,
rightPageSize,
isDesignMode,
toast,
updateRightPaginationState,
],
);
// 추가 탭 데이터 로딩 함수 (leftItem이 null이면 전체 데이터 로드)
// 추가 탭 데이터 로딩 함수 (leftItem이 null이면 전체 데이터 로드, page: 서버 페이징용)
const loadTabData = useCallback(
async (tabIndex: number, leftItem: any) => {
async (tabIndex: number, leftItem: any, page?: number, pageSizeOverride?: number) => {
const tabConfig = componentConfig.rightPanel?.additionalTabs?.[tabIndex - 1];
if (!tabConfig || isDesignMode) return;
@@ -1623,109 +1674,73 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const keys = tabConfig.relation?.keys;
const leftColumn = tabConfig.relation?.leftColumn || keys?.[0]?.leftColumn;
const rightColumn = tabConfig.relation?.foreignKey || keys?.[0]?.rightColumn;
// 탭 config의 Entity 조인 컬럼 추출
const tabJoinColumns = extractAdditionalJoinColumns(tabConfig.columns, tabTableName);
if (tabJoinColumns) {
console.log(`🔗 [분할패널] 탭 ${tabIndex} additionalJoinColumns:`, tabJoinColumns);
}
let resultData: any[] = [];
// 탭의 dataFilter (API 전달용)
let apiResult: any = null;
const tabDataFilterForApi = (tabConfig as any).dataFilter;
// 탭의 relation type 확인 (detail이면 초기 전체 로드 안 함)
const tabRelationType = tabConfig.relation?.type || "join";
const tabPagState = tabsPagination[tabIndex];
const currentTabPage = page ?? tabPagState?.currentPage ?? 1;
const currentTabPageSize = pageSizeOverride ?? tabPagState?.pageSize ?? rightPageSize;
const apiSize = rightPaginationEnabled ? currentTabPageSize : MAX_LOAD_ALL_SIZE;
const apiPage = rightPaginationEnabled ? currentTabPage : undefined;
const commonApiParams = {
enableEntityJoin: true,
size: apiSize,
page: apiPage,
companyCodeOverride: companyCode,
additionalJoinColumns: tabJoinColumns,
dataFilter: tabDataFilterForApi,
};
if (!leftItem) {
if (tabRelationType === "detail") {
// detail 모드: 선택 안 하면 아무것도 안 뜸
resultData = [];
} else {
// join 모드: 좌측 미선택 시 전체 데이터 로드 (dataFilter는 API에 전달)
const result = await entityJoinApi.getTableDataWithJoins(tabTableName, {
enableEntityJoin: true,
size: 1000,
companyCodeOverride: companyCode,
additionalJoinColumns: tabJoinColumns,
dataFilter: tabDataFilterForApi,
});
resultData = result.data || [];
if (tabRelationType !== "detail") {
apiResult = await entityJoinApi.getTableDataWithJoins(tabTableName, commonApiParams);
resultData = apiResult.data || [];
}
} else 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",
};
searchConditions[key.rightColumn] = { value: leftItem[key.leftColumn], operator: "equals" };
}
});
} else {
const leftValue = leftItem[leftColumn];
if (leftValue !== undefined) {
searchConditions[rightColumn] = {
value: leftValue,
operator: "equals",
};
searchConditions[rightColumn] = { value: leftValue, operator: "equals" };
}
}
const result = await entityJoinApi.getTableDataWithJoins(tabTableName, {
apiResult = await entityJoinApi.getTableDataWithJoins(tabTableName, {
search: searchConditions,
enableEntityJoin: true,
size: 1000,
companyCodeOverride: companyCode,
additionalJoinColumns: tabJoinColumns,
dataFilter: tabDataFilterForApi,
...commonApiParams,
});
resultData = result.data || [];
resultData = apiResult.data || [];
} else {
const result = await entityJoinApi.getTableDataWithJoins(tabTableName, {
enableEntityJoin: true,
size: 1000,
companyCodeOverride: companyCode,
additionalJoinColumns: tabJoinColumns,
dataFilter: tabDataFilterForApi,
});
resultData = result.data || [];
apiResult = await entityJoinApi.getTableDataWithJoins(tabTableName, commonApiParams);
resultData = apiResult.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":
case "not_equals":
return value !== cond.value;
case "in": {
const arr = Array.isArray(cond.value) ? cond.value : [cond.value];
return arr.includes(value);
}
case "not_in": {
const arr = Array.isArray(cond.value) ? cond.value : [cond.value];
return !arr.includes(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;
}
});
});
// 공통 페이징 상태 업데이트
if (rightPaginationEnabled && apiResult) {
setTabsPagination((prev) => ({
...prev,
[tabIndex]: {
currentPage: apiResult.page || currentTabPage,
totalPages: apiResult.totalPages || 1,
total: apiResult.total || 0,
pageSize: currentTabPageSize,
},
}));
}
if (!rightPaginationEnabled) {
resultData = applyClientSideFilter(resultData, (tabConfig as any).dataFilter);
}
setTabsData((prev) => ({ ...prev, [tabIndex]: resultData }));
@@ -1740,9 +1755,148 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
setTabsLoading((prev) => ({ ...prev, [tabIndex]: false }));
}
},
[componentConfig.rightPanel?.additionalTabs, isDesignMode, toast],
[componentConfig.rightPanel?.additionalTabs, rightPaginationEnabled, rightPageSize, tabsPagination, isDesignMode, toast],
);
// 🆕 좌측 페이지 변경 핸들러
const handleLeftPageChange = useCallback((newPage: number) => {
if (newPage < 1 || newPage > leftTotalPages) return;
setLeftCurrentPage(newPage);
setLeftPageInput(String(newPage));
loadLeftData(newPage);
}, [leftTotalPages, loadLeftData]);
const commitLeftPageInput = useCallback(() => {
const parsed = parseInt(leftPageInput, 10);
if (!isNaN(parsed) && parsed >= 1 && parsed <= leftTotalPages) {
handleLeftPageChange(parsed);
} else {
setLeftPageInput(String(leftCurrentPage));
}
}, [leftPageInput, leftTotalPages, leftCurrentPage, handleLeftPageChange]);
// 🆕 좌측 페이지 크기 변경
const handleLeftPageSizeChange = useCallback((newSize: number) => {
setLeftPageSize(newSize);
setLeftCurrentPage(1);
setLeftPageInput("1");
loadLeftData(1, newSize);
}, [loadLeftData]);
// 🆕 우측 페이지 변경 핸들러
const handleRightPageChange = useCallback((newPage: number) => {
if (newPage < 1 || newPage > rightTotalPages) return;
setRightCurrentPage(newPage);
setRightPageInput(String(newPage));
if (activeTabIndex === 0) {
loadRightData(selectedLeftItem, newPage);
} else {
loadTabData(activeTabIndex, selectedLeftItem, newPage);
}
}, [rightTotalPages, activeTabIndex, selectedLeftItem, loadRightData, loadTabData]);
const commitRightPageInput = useCallback(() => {
const parsed = parseInt(rightPageInput, 10);
const tp = activeTabIndex === 0 ? rightTotalPages : (tabsPagination[activeTabIndex]?.totalPages ?? 1);
if (!isNaN(parsed) && parsed >= 1 && parsed <= tp) {
handleRightPageChange(parsed);
} else {
const cp = activeTabIndex === 0 ? rightCurrentPage : (tabsPagination[activeTabIndex]?.currentPage ?? 1);
setRightPageInput(String(cp));
}
}, [rightPageInput, rightTotalPages, rightCurrentPage, activeTabIndex, tabsPagination, handleRightPageChange]);
// 🆕 우측 페이지 크기 변경
const handleRightPageSizeChange = useCallback((newSize: number) => {
setRightPageSize(newSize);
setRightCurrentPage(1);
setRightPageInput("1");
setTabsPagination({});
if (activeTabIndex === 0) {
loadRightData(selectedLeftItem, 1, newSize);
} else {
loadTabData(activeTabIndex, selectedLeftItem, 1, newSize);
}
}, [activeTabIndex, selectedLeftItem, loadRightData, loadTabData]);
// 🆕 페이징 UI 컴포넌트 (공통)
const renderPaginationBar = useCallback((params: {
currentPage: number;
totalPages: number;
total: number;
pageSize: number;
pageInput: string;
setPageInput: (v: string) => void;
onPageChange: (p: number) => void;
onPageSizeChange: (s: number) => void;
commitPageInput: () => void;
loading: boolean;
}) => {
const { currentPage, totalPages, total, pageSize, pageInput, setPageInput, onPageChange, onPageSizeChange, commitPageInput: commitFn, loading } = params;
return (
<div className="border-border bg-background relative flex h-10 w-full flex-shrink-0 items-center justify-center border-t px-2">
<div className="absolute left-2 flex items-center gap-1">
<span className="text-muted-foreground text-[10px]">:</span>
<input
type="number"
min={1}
max={MAX_LOAD_ALL_SIZE}
value={pageSize}
onChange={(e) => {
const v = Math.min(MAX_LOAD_ALL_SIZE, Math.max(1, Number(e.target.value) || 1));
onPageSizeChange(v);
}}
className="border-input bg-background focus:ring-ring h-6 w-12 rounded border px-1 text-center text-[10px] focus:ring-1 focus:outline-none"
/>
<span className="text-muted-foreground text-[10px]">/ {total}</span>
</div>
<div className="flex items-center gap-1">
<Button variant="outline" size="sm" onClick={() => onPageChange(1)} disabled={currentPage === 1 || loading} className="h-6 w-6 p-0">
<ChevronsLeft className="h-3 w-3" />
</Button>
<Button variant="outline" size="sm" onClick={() => onPageChange(currentPage - 1)} disabled={currentPage === 1 || loading} className="h-6 w-6 p-0">
<ChevronLeft className="h-3 w-3" />
</Button>
<div className="flex items-center gap-1">
<input
type="text"
inputMode="numeric"
value={pageInput}
onChange={(e) => setPageInput(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { commitFn(); (e.target as HTMLInputElement).blur(); } }}
onBlur={commitFn}
onFocus={(e) => e.target.select()}
disabled={loading}
className="border-input bg-background focus:ring-ring h-6 w-8 rounded border px-1 text-center text-[10px] font-medium focus:ring-1 focus:outline-none"
/>
<span className="text-muted-foreground text-[10px]">/</span>
<span className="text-foreground text-[10px] font-medium">{totalPages || 1}</span>
</div>
<Button variant="outline" size="sm" onClick={() => onPageChange(currentPage + 1)} disabled={currentPage >= totalPages || loading} className="h-6 w-6 p-0">
<ChevronRight className="h-3 w-3" />
</Button>
<Button variant="outline" size="sm" onClick={() => onPageChange(totalPages)} disabled={currentPage >= totalPages || loading} className="h-6 w-6 p-0">
<ChevronsRight className="h-3 w-3" />
</Button>
</div>
</div>
);
}, []);
// 우측/탭 페이징 상태 (IIFE 대신 useMemo로 사전 계산)
const rightPagState = useMemo(() => {
const isTab = activeTabIndex > 0;
const tabPag = isTab ? tabsPagination[activeTabIndex] : null;
return {
isTab,
currentPage: isTab ? (tabPag?.currentPage ?? 1) : rightCurrentPage,
totalPages: isTab ? (tabPag?.totalPages ?? 1) : rightTotalPages,
total: isTab ? (tabPag?.total ?? 0) : rightTotal,
pageSize: isTab ? (tabPag?.pageSize ?? rightPageSize) : rightPageSize,
};
}, [activeTabIndex, tabsPagination, rightCurrentPage, rightTotalPages, rightTotal, rightPageSize]);
// 탭 변경 핸들러
const handleTabChange = useCallback(
(newTabIndex: number) => {
@@ -1779,12 +1933,18 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
selectedLeftItem[leftPk] === item[leftPk];
if (isSameItem) {
// 선택 해제
setSelectedLeftItem(null);
setCustomLeftSelectedData({});
setExpandedRightItems(new Set());
setTabsData({});
// 우측/탭 페이지 리셋
if (rightPaginationEnabled) {
setRightCurrentPage(1);
setRightPageInput("1");
setTabsPagination({});
}
const mainRelationType = componentConfig.rightPanel?.relation?.type || "detail";
if (mainRelationType === "detail") {
// "선택 시 표시" 모드: 선택 해제 시 데이터 비움
@@ -1809,15 +1969,21 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
}
setSelectedLeftItem(item);
setCustomLeftSelectedData(item); // 커스텀 모드 우측 폼에 선택된 데이터 전달
setExpandedRightItems(new Set()); // 좌측 항목 변경 시 우측 확장 초기화
setTabsData({}); // 모든 탭 데이터 초기화
setCustomLeftSelectedData(item);
setExpandedRightItems(new Set());
setTabsData({});
// 우측/탭 페이지 리셋
if (rightPaginationEnabled) {
setRightCurrentPage(1);
setRightPageInput("1");
setTabsPagination({});
}
// 현재 활성 탭에 따라 데이터 로드
if (activeTabIndex === 0) {
loadRightData(item);
loadRightData(item, 1);
} else {
loadTabData(activeTabIndex, item);
loadTabData(activeTabIndex, item, 1);
}
// modalDataStore에 선택된 좌측 항목 저장 (단일 선택)
@@ -1829,7 +1995,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
});
}
},
[loadRightData, loadTabData, activeTabIndex, componentConfig.leftPanel?.tableName, componentConfig.rightPanel?.relation, componentConfig.rightPanel?.additionalTabs, isDesignMode, selectedLeftItem],
[loadRightData, loadTabData, activeTabIndex, componentConfig.leftPanel?.tableName, componentConfig.rightPanel?.relation, componentConfig.rightPanel?.additionalTabs, isDesignMode, selectedLeftItem, rightPaginationEnabled],
);
// 우측 항목 확장/축소 토글
@@ -3104,10 +3270,30 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDesignMode, componentConfig.autoLoad]);
// 🔄 필터 변경 시 데이터 다시 로드
// config에서 pageSize 변경 시 상태 동기화 + 페이지 리셋
useEffect(() => {
const configLeftPageSize = componentConfig.leftPanel?.pagination?.pageSize ?? 20;
setLeftPageSize(configLeftPageSize);
setLeftCurrentPage(1);
setLeftPageInput("1");
}, [componentConfig.leftPanel?.pagination?.pageSize]);
useEffect(() => {
const configRightPageSize = componentConfig.rightPanel?.pagination?.pageSize ?? 20;
setRightPageSize(configRightPageSize);
setRightCurrentPage(1);
setRightPageInput("1");
setTabsPagination({});
}, [componentConfig.rightPanel?.pagination?.pageSize]);
// 🔄 필터 변경 시 데이터 다시 로드 (페이지 1로 리셋)
useEffect(() => {
if (!isDesignMode && componentConfig.autoLoad !== false) {
loadLeftData();
if (leftPaginationEnabled) {
setLeftCurrentPage(1);
setLeftPageInput("1");
}
loadLeftData(1);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [leftFilters]);
@@ -3547,12 +3733,6 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
format: undefined, // 🆕 기본값
}));
// 컬럼 너비 합계 계산 (작업 컬럼 제외, 100% 초과 시 스크롤)
const leftTotalColWidth = columnsToShow.reduce((sum, col) => {
const w = col.width && col.width <= 100 ? col.width : 0;
return sum + w;
}, 0);
// 🔧 그룹화된 데이터 렌더링
const hasGroupedLeftActions = !isDesignMode && (
(componentConfig.leftPanel?.showEdit !== false) ||
@@ -3566,7 +3746,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
<div className="bg-muted px-3 py-2 text-sm font-semibold">
{group.groupKey} ({group.count})
</div>
<table className="divide-y divide-border table-fixed" style={{ width: leftTotalColWidth > 100 ? `${leftTotalColWidth}%` : '100%' }}>
<table className="min-w-full divide-y divide-border">
<thead className="bg-muted">
<tr>
{columnsToShow.map((col, idx) => (
@@ -3574,7 +3754,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
key={idx}
className="px-3 py-2 text-left text-xs font-medium tracking-wider text-muted-foreground uppercase whitespace-nowrap"
style={{
width: col.width && col.width <= 100 ? `${col.width}%` : "auto",
minWidth: col.width ? `${col.width}px` : "80px",
textAlign: col.align || "left",
}}
>
@@ -3663,7 +3843,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
);
return (
<div className="overflow-auto">
<table className="divide-y divide-border table-fixed" style={{ width: leftTotalColWidth > 100 ? `${leftTotalColWidth}%` : '100%' }}>
<table className="min-w-full divide-y divide-border">
<thead className="sticky top-0 z-10 bg-muted">
<tr>
{columnsToShow.map((col, idx) => (
@@ -3671,7 +3851,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
key={idx}
className="px-3 py-2 text-left text-xs font-medium tracking-wider text-muted-foreground uppercase whitespace-nowrap"
style={{
width: col.width && col.width <= 100 ? `${col.width}%` : "auto",
minWidth: col.width ? `${col.width}px` : "80px",
textAlign: col.align || "left",
}}
>
@@ -4008,6 +4188,22 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</div>
)}
</CardContent>
{/* 좌측 페이징 UI */}
{leftPaginationEnabled && !isDesignMode && (
renderPaginationBar({
currentPage: leftCurrentPage,
totalPages: leftTotalPages,
total: leftTotal,
pageSize: leftPageSize,
pageInput: leftPageInput,
setPageInput: setLeftPageInput,
onPageChange: handleLeftPageChange,
onPageSizeChange: handleLeftPageSizeChange,
commitPageInput: commitLeftPageInput,
loading: isLoadingLeft,
})
)}
</Card>
</div>
@@ -4666,16 +4862,10 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
}));
}
// 컬럼 너비 합계 계산 (작업 컬럼 제외, 100% 초과 시 스크롤)
const rightTotalColWidth = columnsToShow.reduce((sum, col) => {
const w = col.width && col.width <= 100 ? col.width : 0;
return sum + w;
}, 0);
return (
<div className="flex h-full w-full flex-col">
<div className="min-h-0 flex-1 overflow-auto">
<table className="table-fixed" style={{ width: rightTotalColWidth > 100 ? `${rightTotalColWidth}%` : '100%' }}>
<table className="min-w-full">
<thead className="sticky top-0 z-10">
<tr className="border-b-2 border-border/60">
{columnsToShow.map((col, idx) => (
@@ -4683,7 +4873,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
key={idx}
className="text-muted-foreground px-3 py-2 text-left text-xs font-semibold whitespace-nowrap"
style={{
width: col.width && col.width <= 100 ? `${col.width}%` : "auto",
minWidth: col.width ? `${col.width}px` : "80px",
textAlign: col.align || "left",
}}
>
@@ -4796,12 +4986,6 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
}));
}
// 컬럼 너비 합계 계산 (작업 컬럼 제외, 100% 초과 시 스크롤)
const displayTotalColWidth = columnsToDisplay.reduce((sum, col) => {
const w = col.width && col.width <= 100 ? col.width : 0;
return sum + w;
}, 0);
const hasEditButton = !isDesignMode && (componentConfig.rightPanel?.editButton?.enabled ?? true);
const hasDeleteButton = !isDesignMode && (componentConfig.rightPanel?.deleteButton?.enabled ?? true);
const hasActions = hasEditButton || hasDeleteButton;
@@ -4809,14 +4993,14 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
return filteredData.length > 0 ? (
<div className="flex h-full w-full flex-col">
<div className="min-h-0 flex-1 overflow-auto">
<table className="table-fixed text-sm" style={{ width: displayTotalColWidth > 100 ? `${displayTotalColWidth}%` : '100%' }}>
<table className="min-w-full text-sm">
<thead className="sticky top-0 z-10 bg-background">
<tr className="border-b-2 border-border/60">
{columnsToDisplay.map((col) => (
<th
key={col.name}
className="text-muted-foreground px-3 py-2 text-left text-xs font-semibold whitespace-nowrap"
style={{ width: col.width && col.width <= 100 ? `${col.width}%` : "auto" }}
style={{ minWidth: col.width ? `${col.width}px` : "80px" }}
>
{col.label}
</th>
@@ -5040,6 +5224,31 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</div>
)}
</CardContent>
{/* 우측/탭 페이징 UI */}
{rightPaginationEnabled && !isDesignMode && renderPaginationBar({
currentPage: rightPagState.currentPage,
totalPages: rightPagState.totalPages,
total: rightPagState.total,
pageSize: rightPagState.pageSize,
pageInput: rightPageInput,
setPageInput: setRightPageInput,
onPageChange: (p) => {
if (rightPagState.isTab) {
setTabsPagination((prev) => ({
...prev,
[activeTabIndex]: { ...(prev[activeTabIndex] || { currentPage: 1, totalPages: 1, total: 0, pageSize: rightPageSize }), currentPage: p },
}));
setRightPageInput(String(p));
loadTabData(activeTabIndex, selectedLeftItem, p);
} else {
handleRightPageChange(p);
}
},
onPageSizeChange: handleRightPageSizeChange,
commitPageInput: commitRightPageInput,
loading: isLoadingRight || (tabsLoading[activeTabIndex] ?? false),
})}
</Card>
</div>