jskim-node #419

Merged
kjs merged 15 commits from jskim-node into main 2026-03-17 09:56:34 +09:00
9 changed files with 594 additions and 277 deletions
Showing only changes of commit e8dc1a287a - Show all commits

View File

@ -1338,7 +1338,6 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
size: {
...splitAdjustedComponent.size,
width: undefined as unknown as number,
height: undefined as unknown as number,
},
} : {}),
}

View File

@ -577,7 +577,7 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
// - 버튼 컴포넌트: 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;

View File

@ -34,7 +34,8 @@ const FORMAT_PATTERNS: Record<V2InputFormat, { pattern: RegExp; placeholder: str
errorMessage: "올바른 이메일 형식이 아닙니다",
},
tel: {
pattern: /^\d{2,3}-\d{3,4}-\d{4}$/,
pattern:
/^(01[016789]|02|0[3-7]1|0[3-6][2-5]|050[2-8]|070|080)-\d{3,4}-\d{4}$|^(15|16|18)\d{2}-\d{4}$/,
placeholder: "010-1234-5678",
errorMessage: "올바른 전화번호 형식이 아닙니다",
},
@ -80,8 +81,34 @@ function formatBizNo(value: string): string {
// 전화번호 형식 변환
function formatTel(value: string): string {
const digits = value.replace(/\D/g, "");
if (digits.length === 0) return "";
// 대표번호: 15xx, 16xx, 18xx → 4-4
if (/^(15|16|18)/.test(digits)) {
if (digits.length <= 4) return digits;
return `${digits.slice(0, 4)}-${digits.slice(4, 8)}`;
}
// 서울: 02 → 2-4-4
if (digits.startsWith("02")) {
if (digits.length <= 2) return digits;
if (digits.length <= 6) return `${digits.slice(0, 2)}-${digits.slice(2)}`;
if (digits.length <= 10) return `${digits.slice(0, 2)}-${digits.slice(2, 6)}-${digits.slice(6)}`;
return `${digits.slice(0, 2)}-${digits.slice(2, 6)}-${digits.slice(6, 10)}`;
}
// 안심번호: 050x → 4-4-4
if (/^050[2-8]/.test(digits)) {
if (digits.length <= 4) return digits;
if (digits.length <= 8) return `${digits.slice(0, 4)}-${digits.slice(4)}`;
if (digits.length <= 12) return `${digits.slice(0, 4)}-${digits.slice(4, 8)}-${digits.slice(8)}`;
return `${digits.slice(0, 4)}-${digits.slice(4, 8)}-${digits.slice(8, 12)}`;
}
// 나머지 (010, 031, 070, 080 등)
if (digits.length <= 3) return digits;
if (digits.length <= 7) return `${digits.slice(0, 3)}-${digits.slice(3)}`;
if (digits.length === 10) return `${digits.slice(0, 3)}-${digits.slice(3, 6)}-${digits.slice(6)}`;
if (digits.length <= 11) return `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7)}`;
return `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7, 11)}`;
}
@ -1175,8 +1202,8 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((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<HTMLDivElement, V2InputProps>((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 && <span className="ml-0.5 text-amber-500">*</span>}

View File

@ -1291,8 +1291,8 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>((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<HTMLDivElement, V2SelectProps>((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 && <span className="ml-0.5 text-amber-500">*</span>}

View File

@ -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 })
}
/>
<SwitchRow
label="페이징 처리"
description="서버 페이지 단위 조회 (필터/정렬/계층 비활성화)"
checked={config.leftPanel?.pagination?.enabled ?? false}
onCheckedChange={(checked) =>
updateLeftPanel({
pagination: {
...config.leftPanel?.pagination,
enabled: checked,
pageSize: config.leftPanel?.pagination?.pageSize ?? 20,
},
})
}
/>
{config.leftPanel?.pagination?.enabled && (
<div className="ml-4 space-y-1">
<Label className="text-[10px]"> </Label>
<Input
type="number"
min={1}
max={MAX_LOAD_ALL_SIZE}
value={config.leftPanel?.pagination?.pageSize ?? 20}
onChange={(e) =>
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"
/>
</div>
)}
</div>
{/* 좌측 패널 컬럼 설정 (접이식) */}
@ -1564,6 +1600,41 @@ export const V2SplitPanelLayoutConfigPanel: React.FC<
updateRightPanel({ showDelete: checked })
}
/>
<SwitchRow
label="페이징 처리"
description="서버 페이지 단위 조회 (탭 포함 적용)"
checked={config.rightPanel?.pagination?.enabled ?? false}
onCheckedChange={(checked) =>
updateRightPanel({
pagination: {
...config.rightPanel?.pagination,
enabled: checked,
pageSize: config.rightPanel?.pagination?.pageSize ?? 20,
},
})
}
/>
{config.rightPanel?.pagination?.enabled && (
<div className="ml-4 space-y-1">
<Label className="text-[10px]"> </Label>
<Input
type="number"
min={1}
max={MAX_LOAD_ALL_SIZE}
value={config.rightPanel?.pagination?.pageSize ?? 20}
onChange={(e) =>
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"
/>
</div>
)}
</div>
{/* 우측 패널 컬럼 설정 (접이식) */}

View File

@ -531,7 +531,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
}
: (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<DynamicComponentRendererProps> =
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,

View File

@ -1751,7 +1751,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
<TableHeader>
<TableRow>
{displayColumns.map((col, idx) => (
<TableHead key={idx} style={{ width: col.width && col.width <= 100 ? `${col.width}%` : "auto" }}>
<TableHead key={idx} style={{ minWidth: col.width ? `${col.width}px` : "80px" }}>
{col.label || col.name}
</TableHead>
))}
@ -1952,7 +1952,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
</TableHead>
)}
{displayColumns.map((col, idx) => (
<TableHead key={idx} style={{ width: col.width && col.width <= 100 ? `${col.width}%` : "auto" }}>
<TableHead key={idx} style={{ minWidth: col.width ? `${col.width}px` : "80px" }}>
{col.label || col.name}
</TableHead>
))}

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>

View File

@ -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;
};
// 레이아웃 설정