jskim-node #419
|
|
@ -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,
|
||||
},
|
||||
} : {}),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
{/* 우측 패널 컬럼 설정 (접이식) */}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
// 레이아웃 설정
|
||||
|
|
|
|||
Loading…
Reference in New Issue