ERP-node/frontend/components/screen/ResponsiveGridRenderer.tsx

241 lines
6.7 KiB
TypeScript

"use client";
import React, { useMemo } from "react";
import { ComponentData } from "@/types/screen";
import { useResponsive } from "@/lib/hooks/useResponsive";
interface ResponsiveGridRendererProps {
components: ComponentData[];
canvasWidth: number;
canvasHeight: number;
renderComponent: (component: ComponentData) => React.ReactNode;
}
// 전체 행을 차지해야 하는 컴포넌트 타입
const FULL_WIDTH_TYPES = new Set([
"table-list",
"v2-table-list",
"table-search-widget",
"v2-table-search-widget",
"conditional-container",
"split-panel-layout",
"split-panel-layout2",
"v2-split-line",
"flow-widget",
"v2-tab-container",
"tab-container",
]);
// 높이를 auto로 처리해야 하는 컴포넌트 타입
const AUTO_HEIGHT_TYPES = new Set([
"table-list",
"v2-table-list",
"table-search-widget",
"v2-table-search-widget",
"conditional-container",
"flow-widget",
"v2-tab-container",
"tab-container",
"split-panel-layout",
"split-panel-layout2",
]);
/**
* Y좌표 기준으로 컴포넌트를 행(row) 단위로 그룹핑
* - Y값 차이가 threshold 이내면 같은 행으로 판정
* - 같은 행 안에서는 X좌표 순으로 정렬
*/
function groupComponentsIntoRows(
components: ComponentData[],
threshold: number = 30
): ComponentData[][] {
if (components.length === 0) return [];
const sorted = [...components].sort((a, b) => a.position.y - b.position.y);
const rows: ComponentData[][] = [];
let currentRow: ComponentData[] = [];
let currentRowY = -Infinity;
for (const comp of sorted) {
if (comp.position.y - currentRowY > threshold) {
if (currentRow.length > 0) rows.push(currentRow);
currentRow = [comp];
currentRowY = comp.position.y;
} else {
currentRow.push(comp);
}
}
if (currentRow.length > 0) rows.push(currentRow);
return rows.map((row) =>
row.sort((a, b) => a.position.x - b.position.x)
);
}
/**
* 컴포넌트의 유형 식별 (componentType, componentId, widgetType 등)
*/
function getComponentTypeId(component: ComponentData): string {
return (
(component as any).componentType ||
(component as any).componentId ||
(component as any).widgetType ||
component.type ||
""
);
}
/**
* 전체 행을 차지해야 하는 컴포넌트인지 판정
*/
function isFullWidthComponent(component: ComponentData): boolean {
const typeId = getComponentTypeId(component);
return FULL_WIDTH_TYPES.has(typeId);
}
/**
* 높이를 auto로 처리해야 하는 컴포넌트인지 판정
*/
function shouldAutoHeight(component: ComponentData): boolean {
const typeId = getComponentTypeId(component);
return AUTO_HEIGHT_TYPES.has(typeId);
}
/**
* 컴포넌트 너비를 캔버스 대비 비율(%)로 변환
*/
function getPercentageWidth(
componentWidth: number,
canvasWidth: number
): number {
const percentage = (componentWidth / canvasWidth) * 100;
if (percentage >= 95) return 100;
return percentage;
}
/**
* 행 내 컴포넌트 사이의 수평 갭(px)을 비율 기반으로 추정
*/
function getRowGap(row: ComponentData[], canvasWidth: number): number {
if (row.length < 2) return 0;
const totalComponentWidth = row.reduce(
(sum, c) => sum + (c.size?.width || 100),
0
);
const totalGap = canvasWidth - totalComponentWidth;
const gapCount = row.length - 1;
if (totalGap <= 0 || gapCount <= 0) return 8;
const gapPx = totalGap / gapCount;
return Math.min(Math.max(Math.round(gapPx), 4), 24);
}
export function ResponsiveGridRenderer({
components,
canvasWidth,
canvasHeight,
renderComponent,
}: ResponsiveGridRendererProps) {
const { isMobile } = useResponsive();
// 전체 행을 차지하는 컴포넌트는 별도 행으로 분리
const processedRows = useMemo(() => {
const topLevel = components.filter((c) => !c.parentId);
const rows = groupComponentsIntoRows(topLevel);
const result: ComponentData[][] = [];
for (const row of rows) {
// 전체 너비 컴포넌트는 독립 행으로 분리
const fullWidthComps: ComponentData[] = [];
const normalComps: ComponentData[] = [];
for (const comp of row) {
if (isFullWidthComponent(comp)) {
fullWidthComps.push(comp);
} else {
normalComps.push(comp);
}
}
// 일반 컴포넌트 행 먼저
if (normalComps.length > 0) {
result.push(normalComps);
}
// 전체 너비 컴포넌트는 각각 독립 행
for (const comp of fullWidthComps) {
result.push([comp]);
}
}
return result;
}, [components]);
return (
<div
data-screen-runtime="true"
className="bg-background flex w-full flex-col"
style={{ minHeight: "200px" }}
>
{processedRows.map((row, rowIndex) => {
const isSingleFullWidth =
row.length === 1 && isFullWidthComponent(row[0]);
const gap = isMobile ? 8 : getRowGap(row, canvasWidth);
return (
<div
key={`row-${rowIndex}`}
className="flex w-full flex-wrap"
style={{ gap: isSingleFullWidth ? 0 : `${gap}px` }}
>
{row.map((component) => {
const typeId = getComponentTypeId(component);
const isFullWidth =
isMobile || isFullWidthComponent(component);
const percentWidth = isFullWidth
? 100
: getPercentageWidth(
component.size?.width || 100,
canvasWidth
);
const autoHeight = shouldAutoHeight(component);
const height = autoHeight
? "auto"
: component.size?.height
? `${component.size.height}px`
: "auto";
// 모바일에서는 100%, 데스크톱에서는 비율 기반
// gap을 고려한 flex-basis 계산
const flexBasis = isFullWidth
? "100%"
: `calc(${percentWidth}% - ${gap}px)`;
return (
<div
key={component.id}
data-component-id={component.id}
data-component-type={typeId}
className="flex-shrink-0"
style={{
width: isFullWidth ? "100%" : undefined,
flexBasis: isFullWidth ? "100%" : flexBasis,
flexGrow: isFullWidth ? 1 : 0,
minWidth: isMobile ? "100%" : undefined,
height,
minHeight: autoHeight ? undefined : height,
}}
>
{renderComponent(component)}
</div>
);
})}
</div>
);
})}
</div>
);
}
export default ResponsiveGridRenderer;