diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index 75e7248e..d6111b64 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -1338,7 +1338,6 @@ export const InteractiveScreenViewerDynamic: React.FC = ({ // - 버튼 컴포넌트: buttonElementStyle에서 자체 border 적용 const isV2HorizLabel = !!( componentStyle && - (componentStyle.labelDisplay === true || componentStyle.labelDisplay === "true") && + componentStyle.labelDisplay !== false && componentStyle.labelDisplay !== "false" && (componentStyle.labelPosition === "left" || componentStyle.labelPosition === "right") ); const needsStripBorder = isV2HorizLabel || isButtonComponent; diff --git a/frontend/components/v2/V2Input.tsx b/frontend/components/v2/V2Input.tsx index 38a9f338..387a361c 100644 --- a/frontend/components/v2/V2Input.tsx +++ b/frontend/components/v2/V2Input.tsx @@ -34,7 +34,8 @@ const FORMAT_PATTERNS: Record((props, ref) => ref={ref} id={id} className={cn( - "flex flex-col gap-1", - labelPos === "left" ? "sm:flex-row sm:items-center" : "sm:flex-row-reverse sm:items-center", + "flex gap-1", + labelPos === "left" ? "flex-row items-center" : "flex-row-reverse items-center", )} style={{ width: componentWidth, @@ -1191,7 +1218,7 @@ export const V2Input = forwardRef((props, ref) => color: getAdaptiveLabelColor(style?.labelColor), fontWeight: style?.labelFontWeight || "500", }} - className="w-full text-sm font-medium whitespace-nowrap sm:w-[120px] sm:shrink-0" + className="text-sm font-medium whitespace-nowrap w-[120px] shrink-0" > {actualLabel} {required && *} diff --git a/frontend/components/v2/V2Select.tsx b/frontend/components/v2/V2Select.tsx index 9062e7bc..9ced9670 100644 --- a/frontend/components/v2/V2Select.tsx +++ b/frontend/components/v2/V2Select.tsx @@ -1291,8 +1291,8 @@ export const V2Select = forwardRef((props, ref) = ref={ref} id={id} className={cn( - "flex flex-col gap-1", - labelPos === "left" ? "sm:flex-row sm:items-center" : "sm:flex-row-reverse sm:items-center", + "flex gap-1", + labelPos === "left" ? "flex-row items-center" : "flex-row-reverse items-center", isDesignMode && "pointer-events-none", )} style={{ @@ -1308,7 +1308,7 @@ export const V2Select = forwardRef((props, ref) = color: getAdaptiveLabelColor(style?.labelColor), fontWeight: style?.labelFontWeight || "500", }} - className="w-full text-sm font-medium whitespace-nowrap sm:w-[120px] sm:shrink-0" + className="text-sm font-medium whitespace-nowrap w-[120px] shrink-0" > {label} {required && *} diff --git a/frontend/components/v2/config-panels/V2SplitPanelLayoutConfigPanel.tsx b/frontend/components/v2/config-panels/V2SplitPanelLayoutConfigPanel.tsx index ae5679b6..7895a3d5 100644 --- a/frontend/components/v2/config-panels/V2SplitPanelLayoutConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2SplitPanelLayoutConfigPanel.tsx @@ -82,9 +82,10 @@ import { arrayMove, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import type { - SplitPanelLayoutConfig, - AdditionalTabConfig, +import { + MAX_LOAD_ALL_SIZE, + type SplitPanelLayoutConfig, + type AdditionalTabConfig, } from "@/lib/registry/components/v2-split-panel-layout/types"; import type { TableInfo, ColumnInfo } from "@/types/screen"; @@ -1158,6 +1159,41 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< updateLeftPanel({ showItemAddButton: checked }) } /> + + updateLeftPanel({ + pagination: { + ...config.leftPanel?.pagination, + enabled: checked, + pageSize: config.leftPanel?.pagination?.pageSize ?? 20, + }, + }) + } + /> + {config.leftPanel?.pagination?.enabled && ( +
+ + + updateLeftPanel({ + pagination: { + ...config.leftPanel?.pagination, + enabled: true, + pageSize: Math.min(MAX_LOAD_ALL_SIZE, Math.max(1, Number(e.target.value) || 20)), + }, + }) + } + className="h-7 w-24 text-xs" + /> +
+ )} {/* 좌측 패널 컬럼 설정 (접이식) */} @@ -1564,6 +1600,41 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< updateRightPanel({ showDelete: checked }) } /> + + updateRightPanel({ + pagination: { + ...config.rightPanel?.pagination, + enabled: checked, + pageSize: config.rightPanel?.pagination?.pageSize ?? 20, + }, + }) + } + /> + {config.rightPanel?.pagination?.enabled && ( +
+ + + updateRightPanel({ + pagination: { + ...config.rightPanel?.pagination, + enabled: true, + pageSize: Math.min(MAX_LOAD_ALL_SIZE, Math.max(1, Number(e.target.value) || 20)), + }, + }) + } + className="h-7 w-24 text-xs" + /> +
+ )} {/* 우측 패널 컬럼 설정 (접이식) */} diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 859d136f..9bf0b7d3 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -531,7 +531,7 @@ export const DynamicComponentRenderer: React.FC = } : (component as any).style; const catSize = catNeedsExternalHorizLabel - ? { ...(component as any).size, width: undefined, height: undefined } + ? { ...(component as any).size, width: undefined } : (component as any).size; const rendererProps = { @@ -797,35 +797,33 @@ export const DynamicComponentRenderer: React.FC = componentType === "modal-repeater-table" || componentType === "v2-input"; - // 🆕 v2-input 등의 라벨 표시 로직 (labelDisplay가 true/"true"일 때만 라벨 표시) + // 🆕 v2-input 등의 라벨 표시 로직 (InteractiveScreenViewerDynamic과 동일한 부정형 체크) const labelDisplay = component.style?.labelDisplay ?? (component as any).labelDisplay; const effectiveLabel = - labelDisplay === true || labelDisplay === "true" + labelDisplay !== false && labelDisplay !== "false" ? component.style?.labelText || (component as any).label || component.componentConfig?.label : undefined; - // 🔧 수평 라벨(left/right) 감지 → 외부 flex 컨테이너에서 라벨 처리 + // 🔧 수평 라벨(left/right) 감지 → 런타임에서만 외부 flex 컨테이너로 라벨 처리 + // 디자인 모드에서는 V2 컴포넌트가 자체적으로 라벨을 렌더링 (height 체인 문제 방지) const labelPosition = component.style?.labelPosition; const isV2Component = componentType?.startsWith("v2-"); const needsExternalHorizLabel = !!( + !props.isDesignMode && isV2Component && effectiveLabel && (labelPosition === "left" || labelPosition === "right") ); - // 🔧 순서 중요! component.style 먼저, CSS 크기 속성은 size 기반으로 덮어씀 const mergedStyle = { - ...component.style, // 원본 style (labelDisplay, labelText 등) - 먼저! - // CSS 크기 속성은 size에서 계산한 값으로 명시적 덮어쓰기 (우선순위 최고) + ...component.style, width: finalStyle.width, height: finalStyle.height, - // 수평 라벨 → V2 컴포넌트에는 라벨 비활성화 (외부에서 처리) ...(needsExternalHorizLabel ? { labelDisplay: false, labelPosition: "top" as const, width: "100%", - height: "100%", borderWidth: undefined, borderColor: undefined, borderStyle: undefined, diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx index a36836a6..d0fd3a5c 100644 --- a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx @@ -1751,7 +1751,7 @@ export const SplitPanelLayout2Component: React.FC {displayColumns.map((col, idx) => ( - + {col.label || col.name} ))} @@ -1952,7 +1952,7 @@ export const SplitPanelLayout2Component: React.FC )} {displayColumns.map((col, idx) => ( - + {col.label || col.name} ))} diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx index c89fb1d3..bcd670c5 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx @@ -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 const [columnInputTypes, setColumnInputTypes] = useState>({}); const [expandedItems, setExpandedItems] = useState>(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>({}); + 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>({}); // 탭별 데이터 @@ -919,13 +998,24 @@ export const SplitPanelLayoutComponent: React.FC 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 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 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 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 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 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 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 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 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 = {}; - 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 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 ( +
+
+ 표시: + { + 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" + /> + / {total}건 +
+ +
+ + +
+ 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" + /> + / + {totalPages || 1} +
+ + +
+
+ ); + }, []); + + // 우측/탭 페이징 상태 (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 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 } 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 }); } }, - [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 // 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 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
{group.groupKey} ({group.count}개)
- 100 ? `${leftTotalColWidth}%` : '100%' }}> +
{columnsToShow.map((col, idx) => ( @@ -3574,7 +3754,7 @@ export const SplitPanelLayoutComponent: React.FC 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 ); return (
-
100 ? `${leftTotalColWidth}%` : '100%' }}> +
{columnsToShow.map((col, idx) => ( @@ -3671,7 +3851,7 @@ export const SplitPanelLayoutComponent: React.FC 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 )} + + {/* 좌측 페이징 UI */} + {leftPaginationEnabled && !isDesignMode && ( + renderPaginationBar({ + currentPage: leftCurrentPage, + totalPages: leftTotalPages, + total: leftTotal, + pageSize: leftPageSize, + pageInput: leftPageInput, + setPageInput: setLeftPageInput, + onPageChange: handleLeftPageChange, + onPageSizeChange: handleLeftPageSizeChange, + commitPageInput: commitLeftPageInput, + loading: isLoadingLeft, + }) + )} @@ -4666,16 +4862,10 @@ export const SplitPanelLayoutComponent: React.FC })); } - // 컬럼 너비 합계 계산 (작업 컬럼 제외, 100% 초과 시 스크롤) - const rightTotalColWidth = columnsToShow.reduce((sum, col) => { - const w = col.width && col.width <= 100 ? col.width : 0; - return sum + w; - }, 0); - return (
-
100 ? `${rightTotalColWidth}%` : '100%' }}> +
{columnsToShow.map((col, idx) => ( @@ -4683,7 +4873,7 @@ export const SplitPanelLayoutComponent: React.FC 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 })); } - // 컬럼 너비 합계 계산 (작업 컬럼 제외, 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 return filteredData.length > 0 ? (
-
100 ? `${displayTotalColWidth}%` : '100%' }}> +
{columnsToDisplay.map((col) => ( @@ -5040,6 +5224,31 @@ export const SplitPanelLayoutComponent: React.FC )} + + {/* 우측/탭 페이징 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), + })} diff --git a/frontend/lib/registry/components/v2-split-panel-layout/types.ts b/frontend/lib/registry/components/v2-split-panel-layout/types.ts index 5b87a82e..225a8a29 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/types.ts +++ b/frontend/lib/registry/components/v2-split-panel-layout/types.ts @@ -10,6 +10,15 @@ import { DataFilterConfig, TabInlineComponent } from "@/types/screen-management" */ export type PanelInlineComponent = TabInlineComponent; +/** 페이징 처리 설정 (좌측/우측 패널 공통) */ +export interface PaginationConfig { + enabled: boolean; + pageSize?: number; +} + +/** 페이징 OFF 시 전체 데이터 로드에 사용하는 최대 건수 */ +export const MAX_LOAD_ALL_SIZE = 10000; + /** * 추가 탭 설정 (우측 패널과 동일한 구조 + tabId, label) */ @@ -224,6 +233,8 @@ export interface SplitPanelLayoutConfig { // 🆕 컬럼 값 기반 데이터 필터링 dataFilter?: DataFilterConfig; + + pagination?: PaginationConfig; }; // 우측 패널 설정 @@ -351,6 +362,8 @@ export interface SplitPanelLayoutConfig { // 🆕 추가 탭 설정 (멀티 테이블 탭) additionalTabs?: AdditionalTabConfig[]; + + pagination?: PaginationConfig; }; // 레이아웃 설정
{col.label}