ERP-node/frontend/components/v2/V2Layout.tsx

364 lines
9.4 KiB
TypeScript
Raw Normal View History

2025-12-19 15:44:38 +09:00
"use client";
/**
* V2Layout
*
2025-12-19 15:44:38 +09:00
*
* - grid: 그리드
* - split: 분할
* - flex: 플렉스
* - divider: 구분선
* - screen-embed: 화면
*/
import React, { forwardRef, useCallback, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { V2LayoutProps } from "@/types/v2-components";
2025-12-19 15:44:38 +09:00
import { GripVertical, GripHorizontal } from "lucide-react";
/**
* (12 )
*
2025-12-19 15:44:38 +09:00
* :
* - columns: 컬럼 ( 12, )
* - colSpan: 자식 span
* - Tailwind의 grid-cols-12
*/
const GridLayout = forwardRef<
HTMLDivElement,
{
columns?: number; // 12컬럼 시스템에서 몇 컬럼으로 나눌지 (1-12)
gap?: string;
children?: React.ReactNode;
className?: string;
use12Column?: boolean; // 12컬럼 시스템 사용 여부
}
>(({ columns = 12, gap = "16px", children, className, use12Column = true }, ref) => {
2025-12-19 15:44:38 +09:00
// 12컬럼 그리드 클래스 매핑
const gridColsClass: Record<number, string> = {
1: "grid-cols-1",
2: "grid-cols-2",
3: "grid-cols-3",
4: "grid-cols-4",
5: "grid-cols-5",
6: "grid-cols-6",
7: "grid-cols-7",
8: "grid-cols-8",
9: "grid-cols-9",
10: "grid-cols-10",
11: "grid-cols-11",
12: "grid-cols-12",
};
// 12컬럼 시스템 사용 시
if (use12Column) {
return (
<div ref={ref} className={cn("grid", gridColsClass[columns] || "grid-cols-12", className)} style={{ gap }}>
2025-12-19 15:44:38 +09:00
{children}
</div>
);
}
// 기존 방식 (동적 컬럼 수)
return (
<div
ref={ref}
className={cn("grid", className)}
style={{
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
gap,
}}
>
{children}
</div>
);
});
GridLayout.displayName = "GridLayout";
/**
* ( )
*/
const SplitLayout = forwardRef<
HTMLDivElement,
{
direction?: "horizontal" | "vertical";
splitRatio?: number[];
gap?: string;
children?: React.ReactNode;
className?: string;
}
>(({ direction = "horizontal", splitRatio = [50, 50], gap = "8px", children, className }, ref) => {
2025-12-19 15:44:38 +09:00
const containerRef = useRef<HTMLDivElement>(null);
const [ratio, setRatio] = useState(splitRatio);
const [isDragging, setIsDragging] = useState(false);
const childArray = React.Children.toArray(children);
const isHorizontal = direction === "horizontal";
// 리사이저 드래그 시작
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
setIsDragging(true);
const startPos = isHorizontal ? e.clientX : e.clientY;
const startRatio = [...ratio];
const container = containerRef.current;
const handleMouseMove = (moveEvent: MouseEvent) => {
if (!container) return;
const containerSize = isHorizontal ? container.offsetWidth : container.offsetHeight;
const currentPos = isHorizontal ? moveEvent.clientX : moveEvent.clientY;
const delta = currentPos - startPos;
const deltaPercent = (delta / containerSize) * 100;
const newFirst = Math.max(10, Math.min(90, startRatio[0] + deltaPercent));
const newSecond = 100 - newFirst;
setRatio([newFirst, newSecond]);
};
const handleMouseUp = () => {
setIsDragging(false);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
},
[isHorizontal, ratio],
);
2025-12-19 15:44:38 +09:00
return (
<div
ref={(node) => {
(containerRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
}}
className={cn("flex", isHorizontal ? "flex-row" : "flex-col", className)}
2025-12-19 15:44:38 +09:00
style={{ gap }}
>
{/* 첫 번째 패널 */}
<div
className="overflow-auto"
style={{
[isHorizontal ? "width" : "height"]: `${ratio[0]}%`,
flexShrink: 0,
}}
>
{childArray[0]}
</div>
{/* 리사이저 */}
<div
className={cn(
"bg-border hover:bg-primary/20 flex items-center justify-center transition-colors",
2025-12-19 15:44:38 +09:00
isHorizontal ? "w-2 cursor-col-resize" : "h-2 cursor-row-resize",
isDragging && "bg-primary/30",
2025-12-19 15:44:38 +09:00
)}
onMouseDown={handleMouseDown}
>
{isHorizontal ? (
<GripVertical className="text-muted-foreground h-4 w-4" />
2025-12-19 15:44:38 +09:00
) : (
<GripHorizontal className="text-muted-foreground h-4 w-4" />
2025-12-19 15:44:38 +09:00
)}
</div>
{/* 두 번째 패널 */}
<div
className="flex-1 overflow-auto"
2025-12-19 15:44:38 +09:00
style={{
[isHorizontal ? "width" : "height"]: `${ratio[1]}%`,
}}
>
{childArray[1]}
</div>
</div>
);
});
SplitLayout.displayName = "SplitLayout";
/**
*
*/
const FlexLayout = forwardRef<
HTMLDivElement,
{
direction?: "horizontal" | "vertical";
gap?: string;
wrap?: boolean;
justify?: "start" | "center" | "end" | "between" | "around";
align?: "start" | "center" | "end" | "stretch";
children?: React.ReactNode;
className?: string;
}
>(
(
{ direction = "horizontal", gap = "16px", wrap = false, justify = "start", align = "stretch", children, className },
ref,
) => {
const justifyMap = {
start: "flex-start",
center: "center",
end: "flex-end",
between: "space-between",
around: "space-around",
};
2025-12-19 15:44:38 +09:00
const alignMap = {
start: "flex-start",
center: "center",
end: "flex-end",
stretch: "stretch",
};
2025-12-19 15:44:38 +09:00
return (
<div
ref={ref}
className={cn("flex", className)}
style={{
flexDirection: direction === "horizontal" ? "row" : "column",
flexWrap: wrap ? "wrap" : "nowrap",
justifyContent: justifyMap[justify],
alignItems: alignMap[align],
gap,
}}
>
{children}
</div>
);
},
);
2025-12-19 15:44:38 +09:00
FlexLayout.displayName = "FlexLayout";
/**
*
*/
const DividerLayout = forwardRef<
HTMLDivElement,
{
direction?: "horizontal" | "vertical";
className?: string;
}
>(({ direction = "horizontal", className }, ref) => {
2025-12-19 15:44:38 +09:00
return (
<div
ref={ref}
className={cn("bg-border", direction === "horizontal" ? "my-4 h-px w-full" : "mx-4 h-full w-px", className)}
2025-12-19 15:44:38 +09:00
/>
);
});
DividerLayout.displayName = "DividerLayout";
/**
*
*/
const ScreenEmbedLayout = forwardRef<
HTMLDivElement,
{
screenId?: number;
className?: string;
}
>(({ screenId, className }, ref) => {
2025-12-19 15:44:38 +09:00
if (!screenId) {
return (
<div
ref={ref}
className={cn(
"text-muted-foreground flex h-32 items-center justify-center rounded-lg border-2 border-dashed",
className,
2025-12-19 15:44:38 +09:00
)}
>
</div>
);
}
// TODO: 실제 화면 임베딩 로직 구현
// InteractiveScreenViewer와 연동 필요
return (
<div ref={ref} className={cn("rounded-lg border p-4", className)}>
<div className="text-muted-foreground mb-2 text-sm"> (ID: {screenId})</div>
<div className="bg-muted/30 flex h-48 items-center justify-center rounded">
2025-12-19 15:44:38 +09:00
{/* 여기에 InteractiveScreenViewer 렌더링 */}
</div>
</div>
);
});
ScreenEmbedLayout.displayName = "ScreenEmbedLayout";
/**
* V2Layout
2025-12-19 15:44:38 +09:00
*/
export const V2Layout = forwardRef<HTMLDivElement, V2LayoutProps>((props, ref) => {
const { id, style, size, config: configProp, children } = props;
// config가 없으면 기본값 사용
const config = configProp || { type: "grid" as const, columns: 2 };
// 타입별 레이아웃 렌더링
const renderLayout = () => {
const layoutType = config.type || "grid";
switch (layoutType) {
case "grid":
return (
<GridLayout columns={config.columns} gap={config.gap}>
{children}
</GridLayout>
);
case "split":
return (
<SplitLayout direction={config.direction} splitRatio={config.splitRatio} gap={config.gap}>
{children}
</SplitLayout>
);
case "flex":
return (
<FlexLayout direction={config.direction} gap={config.gap}>
{children}
</FlexLayout>
);
case "divider":
return <DividerLayout direction={config.direction} />;
case "screen-embed":
return <ScreenEmbedLayout screenId={config.screenId} />;
default:
return (
<GridLayout columns={config.columns} gap={config.gap}>
{children}
</GridLayout>
);
}
};
2025-12-19 15:44:38 +09:00
const componentWidth = size?.width || style?.width;
const componentHeight = size?.height || style?.height;
2025-12-19 15:44:38 +09:00
return (
<div
ref={ref}
id={id}
style={{
width: componentWidth,
height: componentHeight,
}}
>
{renderLayout()}
</div>
);
});
2025-12-19 15:44:38 +09:00
V2Layout.displayName = "V2Layout";
2025-12-19 15:44:38 +09:00
export default V2Layout;