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

238 lines
7.2 KiB
TypeScript
Raw Normal View History

"use client";
import React, { forwardRef } from "react";
import { cn } from "@/lib/utils";
import {
PopComponentDefinition,
PopComponentType,
GridPosition,
} from "../types/pop-layout";
// ========================================
// Props 정의
// ========================================
interface ComponentRendererProps {
/** 컴포넌트 정의 (타입, 라벨, 설정 등) */
component: PopComponentDefinition;
/** 컴포넌트의 그리드 위치 (섹션 내부 그리드 기준) */
position: GridPosition;
/** 디자인 모드 여부 (true: 편집 가능, false: 뷰어 모드) */
isDesignMode?: boolean;
/** 선택된 상태인지 */
isSelected?: boolean;
/** 컴포넌트 클릭 시 호출 */
onClick?: () => void;
/** 추가 className */
className?: string;
}
// ========================================
// 컴포넌트 렌더러
//
// 역할:
// - 관리자가 설정한 GridPosition(col, row, colSpan, rowSpan)을
// 그대로 CSS Grid에 반영
// - 디자이너/뷰어 모두에서 동일한 렌더링 보장
// - 디자인 모드에서는 선택 상태 표시
// ========================================
export const ComponentRenderer = forwardRef<HTMLDivElement, ComponentRendererProps>(
function ComponentRenderer(
{
component,
position,
isDesignMode = false,
isSelected = false,
onClick,
className,
},
ref
) {
const { type, label, config } = component;
return (
<div
ref={ref}
className={cn(
// 기본 스타일
"relative flex flex-col overflow-hidden rounded border bg-white transition-all",
// 디자인 모드 스타일
isDesignMode && "cursor-pointer",
// 선택 상태 스타일
isSelected
? "border-primary ring-2 ring-primary/30 z-10"
: "border-gray-200 hover:border-gray-300",
className
)}
style={{
// 관리자가 설정한 GridPosition을 그대로 반영
gridColumn: `${position.col} / span ${position.colSpan}`,
gridRow: `${position.row} / span ${position.rowSpan}`,
}}
onClick={(e) => {
e.stopPropagation();
onClick?.();
}}
>
{/* 컴포넌트 타입별 미리보기 렌더링 */}
<ComponentPreview type={type} label={label} config={config} />
</div>
);
}
);
// ========================================
// 컴포넌트 타입별 미리보기
// ========================================
interface ComponentPreviewProps {
type: PopComponentType;
label?: string;
config?: any;
}
function ComponentPreview({ type, label, config }: ComponentPreviewProps) {
switch (type) {
case "pop-field":
return <FieldPreview label={label} config={config} />;
case "pop-button":
return <ButtonPreview label={label} config={config} />;
case "pop-list":
return <ListPreview label={label} config={config} />;
case "pop-indicator":
return <IndicatorPreview label={label} config={config} />;
case "pop-scanner":
return <ScannerPreview label={label} config={config} />;
case "pop-numpad":
return <NumpadPreview label={label} config={config} />;
default:
return (
<div className="flex h-full items-center justify-center p-2 text-xs text-gray-400">
{label || type}
</div>
);
}
}
// ========================================
// 개별 컴포넌트 미리보기
// ========================================
function FieldPreview({ label, config }: { label?: string; config?: any }) {
const fieldType = config?.fieldType || "text";
const placeholder = config?.placeholder || "입력하세요";
const required = config?.required || false;
return (
<div className="flex h-full w-full flex-col gap-1 p-2">
{/* 라벨 */}
<span className="text-xs font-medium text-gray-600">
{label || "필드"}
{required && <span className="ml-1 text-destructive">*</span>}
</span>
{/* 입력 필드 미리보기 */}
<div className="flex h-8 w-full items-center rounded border border-gray-200 bg-gray-50 px-2 text-xs text-gray-400">
{placeholder}
</div>
</div>
);
}
function ButtonPreview({ label, config }: { label?: string; config?: any }) {
const buttonType = config?.buttonType || "action";
const variant = buttonType === "submit" ? "bg-primary text-white" : "bg-gray-100 text-gray-700";
return (
<div className="flex h-full w-full items-center justify-center p-2">
<div
className={cn(
"flex h-10 w-full items-center justify-center rounded font-medium",
variant
)}
>
{label || "버튼"}
</div>
</div>
);
}
function ListPreview({ label, config }: { label?: string; config?: any }) {
const itemCount = config?.itemsPerPage || 5;
return (
<div className="flex h-full w-full flex-col gap-1 p-2">
{/* 라벨 */}
<span className="text-xs font-medium text-gray-600">{label || "리스트"}</span>
{/* 리스트 아이템 미리보기 */}
<div className="flex flex-1 flex-col gap-0.5 overflow-hidden">
{Array.from({ length: Math.min(3, itemCount) }).map((_, i) => (
<div key={i} className="h-4 rounded bg-gray-100" />
))}
{itemCount > 3 && (
<div className="text-center text-[10px] text-gray-400">
+{itemCount - 3} more
</div>
)}
</div>
</div>
);
}
function IndicatorPreview({ label, config }: { label?: string; config?: any }) {
const indicatorType = config?.indicatorType || "kpi";
const unit = config?.unit || "";
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-1 p-2">
{/* 라벨 */}
<span className="text-[10px] text-gray-500">{label || "KPI"}</span>
{/* 값 미리보기 */}
<span className="text-xl font-bold text-primary">
0{unit && <span className="text-sm font-normal text-gray-500">{unit}</span>}
</span>
</div>
);
}
function ScannerPreview({ label, config }: { label?: string; config?: any }) {
const scannerType = config?.scannerType || "camera";
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-1 p-2">
{/* QR 아이콘 */}
<div className="flex h-10 w-10 items-center justify-center rounded border-2 border-dashed border-gray-300">
<span className="text-xs text-gray-400">QR</span>
</div>
{/* 라벨 */}
<span className="text-[10px] text-gray-500">{label || "스캐너"}</span>
</div>
);
}
function NumpadPreview({ label, config }: { label?: string; config?: any }) {
const keys = [1, 2, 3, 4, 5, 6, 7, 8, 9, "C", 0, "OK"];
return (
<div className="flex h-full w-full flex-col gap-1 p-2">
{/* 라벨 */}
{label && (
<span className="text-[10px] text-gray-500">{label}</span>
)}
{/* 넘패드 미리보기 */}
<div className="grid flex-1 grid-cols-3 gap-0.5">
{keys.map((key) => (
<div
key={key}
className="flex items-center justify-center rounded bg-gray-100 text-[8px]"
>
{key}
</div>
))}
</div>
</div>
);
}
export default ComponentRenderer;