ERP-node/frontend/components/pop/designer/renderers/PopLayoutRenderer.tsx

402 lines
12 KiB
TypeScript

"use client";
import React from "react";
import { cn } from "@/lib/utils";
import {
PopLayoutDataV3,
PopLayoutModeKey,
PopModeLayoutV3,
GridPosition,
MODE_RESOLUTIONS,
PopComponentDefinition,
} from "../types/pop-layout";
// ========================================
// Props 정의
// ========================================
interface PopLayoutRendererProps {
/** 레이아웃 데이터 (v3.0) */
layout: PopLayoutDataV3;
/** 현재 모드 키 (tablet_landscape, tablet_portrait 등) */
modeKey: PopLayoutModeKey;
/** 디자인 모드 여부 (true: 편집 가능, false: 뷰어 모드) */
isDesignMode?: boolean;
/** 선택된 컴포넌트 ID */
selectedComponentId?: string | null;
/** 컴포넌트 클릭 시 호출 */
onComponentClick?: (componentId: string) => void;
/** 배경 클릭 시 호출 (선택 해제용) */
onBackgroundClick?: () => void;
/** 커스텀 모드 레이아웃 (fallback 등에서 변환된 레이아웃 사용 시) */
customModeLayout?: PopModeLayoutV3;
/** 추가 className */
className?: string;
/** 추가 style */
style?: React.CSSProperties;
}
// ========================================
// 컴포넌트 타입별 라벨
// ========================================
const COMPONENT_TYPE_LABELS: Record<string, string> = {
"pop-field": "필드",
"pop-button": "버튼",
"pop-list": "리스트",
"pop-indicator": "인디케이터",
"pop-scanner": "스캐너",
"pop-numpad": "숫자패드",
};
// ========================================
// POP 레이아웃 렌더러 (v3)
//
// 핵심 역할:
// - 디자이너와 뷰어에서 **동일한** 렌더링 결과 보장
// - 컴포넌트가 캔버스에 직접 배치 (섹션 없음)
// - CSS Grid + 1fr 비율 기반
// ========================================
export function PopLayoutRenderer({
layout,
modeKey,
isDesignMode = false,
selectedComponentId,
onComponentClick,
onBackgroundClick,
customModeLayout,
className,
style,
}: PopLayoutRendererProps) {
const { components, layouts, settings } = layout;
const canvasGrid = settings.canvasGrid;
// 현재 모드의 레이아웃
const modeLayout = customModeLayout || layouts[modeKey];
// 컴포넌트가 없으면 빈 상태 표시
if (!modeLayout || Object.keys(modeLayout.componentPositions).length === 0) {
return (
<div
className={cn(
"flex h-full w-full items-center justify-center bg-gray-50",
className
)}
style={style}
onClick={onBackgroundClick}
>
<div className="text-center text-sm text-gray-400">
<p> </p>
{isDesignMode && <p className="mt-1"> </p>}
</div>
</div>
);
}
// 컴포넌트 ID 목록
const componentIds = Object.keys(modeLayout.componentPositions);
return (
<div
className={cn("relative w-full h-full bg-white", className)}
style={{
// CSS Grid: 디자이너와 동일
display: "grid",
gridTemplateColumns: `repeat(${canvasGrid.columns}, 1fr)`,
gridTemplateRows: `repeat(${canvasGrid.rows || 24}, 1fr)`,
gap: `${canvasGrid.gap}px`,
padding: `${canvasGrid.gap}px`,
...style,
}}
onClick={(e) => {
if (e.target === e.currentTarget) {
onBackgroundClick?.();
}
}}
>
{/* 컴포넌트들 직접 렌더링 */}
{componentIds.map((componentId) => {
const compDef = components[componentId];
const compPos = modeLayout.componentPositions[componentId];
if (!compDef || !compPos) return null;
return (
<ComponentRenderer
key={componentId}
componentId={componentId}
component={compDef}
position={compPos}
isDesignMode={isDesignMode}
isSelected={selectedComponentId === componentId}
onComponentClick={() => onComponentClick?.(componentId)}
/>
);
})}
</div>
);
}
// ========================================
// 컴포넌트 렌더러
// ========================================
interface ComponentRendererProps {
componentId: string;
component: PopComponentDefinition;
position: GridPosition;
isDesignMode?: boolean;
isSelected?: boolean;
onComponentClick?: () => void;
}
function ComponentRenderer({
componentId,
component,
position,
isDesignMode = false,
isSelected = false,
onComponentClick,
}: ComponentRendererProps) {
const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type;
return (
<div
className={cn(
"relative flex flex-col overflow-hidden rounded-lg border-2 bg-white transition-all",
isSelected
? "border-primary ring-2 ring-primary/30 z-10"
: "border-gray-200",
isDesignMode && "cursor-pointer hover:border-gray-300"
)}
style={{
gridColumn: `${position.col} / span ${position.colSpan}`,
gridRow: `${position.row} / span ${position.rowSpan}`,
}}
onClick={(e) => {
e.stopPropagation();
onComponentClick?.();
}}
>
{/* 컴포넌트 라벨 (디자인 모드에서만) */}
{isDesignMode && (
<div
className={cn(
"flex h-5 shrink-0 items-center border-b px-2",
isSelected ? "bg-primary/10" : "bg-gray-50"
)}
>
<span className="text-[10px] font-medium text-gray-600">
{component.label || typeLabel}
</span>
</div>
)}
{/* 컴포넌트 내용 */}
<div className="flex flex-1 items-center justify-center p-2">
{renderComponentContent(component, isDesignMode)}
</div>
</div>
);
}
// ========================================
// 컴포넌트별 렌더링
// ========================================
function renderComponentContent(
component: PopComponentDefinition,
isDesignMode: boolean
): React.ReactNode {
const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type;
// 디자인 모드에서는 플레이스홀더 표시
if (isDesignMode) {
return (
<div className="text-xs text-gray-400 text-center">
{typeLabel}
</div>
);
}
// 뷰어 모드: 실제 컴포넌트 렌더링
switch (component.type) {
case "pop-field":
return (
<input
type="text"
placeholder={component.label || "입력하세요"}
className="w-full h-full px-3 py-2 text-sm border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
/>
);
case "pop-button":
return (
<button className="w-full h-full px-4 py-2 text-sm font-medium text-white bg-primary rounded-md hover:bg-primary/90 transition-colors">
{component.label || "버튼"}
</button>
);
case "pop-list":
return (
<div className="w-full h-full overflow-auto p-2">
<div className="text-xs text-gray-500 text-center">
( )
</div>
</div>
);
case "pop-indicator":
return (
<div className="text-center">
<div className="text-2xl font-bold text-primary">0</div>
<div className="text-xs text-gray-500">{component.label || "지표"}</div>
</div>
);
case "pop-scanner":
return (
<div className="text-center text-gray-500">
<div className="text-xs"></div>
<div className="text-[10px]"> </div>
</div>
);
case "pop-numpad":
return (
<div className="grid grid-cols-3 gap-1 p-1 w-full">
{[1, 2, 3, 4, 5, 6, 7, 8, 9, "C", 0, "OK"].map((key) => (
<button
key={key}
className="aspect-square text-xs font-medium bg-gray-100 rounded hover:bg-gray-200"
>
{key}
</button>
))}
</div>
);
default:
return (
<div className="text-xs text-gray-400">
{typeLabel}
</div>
);
}
}
// ========================================
// 헬퍼 함수들 (export)
// ========================================
/**
* 특정 모드에 레이아웃이 설정되어 있는지 확인
*/
export function hasModeLayout(
layout: PopLayoutDataV3,
modeKey: PopLayoutModeKey
): boolean {
const modeLayout = layout.layouts[modeKey];
return modeLayout && Object.keys(modeLayout.componentPositions).length > 0;
}
/**
* 태블릿 가로 모드(기준 모드)가 설정되어 있는지 확인
*/
export function hasBaseLayout(layout: PopLayoutDataV3): boolean {
return hasModeLayout(layout, "tablet_landscape");
}
/**
* 태블릿 가로 모드를 기준으로 다른 모드에 맞게 자동 변환
*/
export function autoConvertLayout(
layout: PopLayoutDataV3,
targetModeKey: PopLayoutModeKey
): PopModeLayoutV3 {
const sourceKey: PopLayoutModeKey = "tablet_landscape";
const sourceLayout = layout.layouts[sourceKey];
const sourceRes = MODE_RESOLUTIONS[sourceKey];
const targetRes = MODE_RESOLUTIONS[targetModeKey];
// 비율 계산
const widthRatio = targetRes.width / sourceRes.width;
const heightRatio = targetRes.height / sourceRes.height;
// 가로 → 세로 변환인지 확인
const isOrientationChange =
sourceRes.width > sourceRes.height !== targetRes.width > targetRes.height;
// 컴포넌트 위치 변환
const convertedPositions: Record<string, GridPosition> = {};
let currentRow = 1;
// 컴포넌트를 row, col 순으로 정렬
const sortedComponentIds = Object.keys(sourceLayout.componentPositions).sort(
(a, b) => {
const posA = sourceLayout.componentPositions[a];
const posB = sourceLayout.componentPositions[b];
if (posA.row !== posB.row) return posA.row - posB.row;
return posA.col - posB.col;
}
);
for (const componentId of sortedComponentIds) {
const sourcePos = sourceLayout.componentPositions[componentId];
if (isOrientationChange) {
// 가로 → 세로: 세로 스택 방식
const canvasColumns = layout.settings.canvasGrid.columns;
convertedPositions[componentId] = {
col: 1,
row: currentRow,
colSpan: canvasColumns,
rowSpan: Math.max(3, Math.round(sourcePos.rowSpan * 1.5)),
};
currentRow += convertedPositions[componentId].rowSpan + 1;
} else {
// 같은 방향: 비율 변환
convertedPositions[componentId] = {
col: Math.max(1, Math.round(sourcePos.col * widthRatio)),
row: Math.max(1, Math.round(sourcePos.row * heightRatio)),
colSpan: Math.max(1, Math.round(sourcePos.colSpan * widthRatio)),
rowSpan: Math.max(1, Math.round(sourcePos.rowSpan * heightRatio)),
};
}
}
return {
componentPositions: convertedPositions,
};
}
/**
* 현재 모드에 맞는 레이아웃 반환 (없으면 자동 변환)
*/
export function getEffectiveModeLayout(
layout: PopLayoutDataV3,
targetModeKey: PopLayoutModeKey
): {
modeLayout: PopModeLayoutV3;
isConverted: boolean;
sourceModeKey: PopLayoutModeKey;
} {
// 해당 모드에 레이아웃이 있으면 그대로 사용
if (hasModeLayout(layout, targetModeKey)) {
return {
modeLayout: layout.layouts[targetModeKey],
isConverted: false,
sourceModeKey: targetModeKey,
};
}
// 없으면 태블릿 가로 모드를 기준으로 자동 변환
return {
modeLayout: autoConvertLayout(layout, targetModeKey),
isConverted: true,
sourceModeKey: "tablet_landscape",
};
}
export default PopLayoutRenderer;