238 lines
7.2 KiB
TypeScript
238 lines
7.2 KiB
TypeScript
|
|
"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;
|