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

913 lines
27 KiB
TypeScript

"use client";
import React, { useMemo, useState, useCallback, useRef } from "react";
import { useDrag, useDrop } from "react-dnd";
import { cn } from "@/lib/utils";
import {
PopLayoutDataV4,
PopContainerV4,
PopComponentDefinitionV4,
PopResponsiveRuleV4,
PopSizeConstraintV4,
PopComponentType,
} from "../types/pop-layout";
// 드래그 아이템 타입
const DND_COMPONENT_REORDER = "POP_COMPONENT_REORDER";
interface DragItem {
type: string;
componentId: string;
containerId: string;
index: number;
}
// ========================================
// Props 정의
// ========================================
interface PopFlexRendererProps {
/** v4 레이아웃 데이터 */
layout: PopLayoutDataV4;
/** 현재 뷰포트 너비 (반응형 규칙 적용용) */
viewportWidth: number;
/** 현재 뷰포트 모드 (오버라이드 병합용) */
currentMode?: "mobile_portrait" | "mobile_landscape" | "tablet_portrait" | "tablet_landscape";
/** 임시 레이아웃 (고정 전 미리보기) */
tempLayout?: PopContainerV4 | null;
/** 디자인 모드 여부 */
isDesignMode?: boolean;
/** 선택된 컴포넌트 ID */
selectedComponentId?: string | null;
/** 컴포넌트 클릭 */
onComponentClick?: (componentId: string) => void;
/** 컨테이너 클릭 */
onContainerClick?: (containerId: string) => void;
/** 배경 클릭 */
onBackgroundClick?: () => void;
/** 컴포넌트 크기 변경 */
onComponentResize?: (componentId: string, size: Partial<PopSizeConstraintV4>) => void;
/** 컴포넌트 순서 변경 */
onReorderComponent?: (containerId: string, fromIndex: number, toIndex: number) => void;
/** 추가 className */
className?: string;
}
// ========================================
// 컴포넌트 타입별 라벨
// ========================================
const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
"pop-field": "필드",
"pop-button": "버튼",
"pop-list": "리스트",
"pop-indicator": "인디케이터",
"pop-scanner": "스캐너",
"pop-numpad": "숫자패드",
"pop-spacer": "스페이서",
"pop-break": "줄바꿈",
};
// ========================================
// v4 Flexbox 렌더러
//
// 핵심 역할:
// - v4 레이아웃을 Flexbox CSS로 렌더링
// - 제약조건(fill/fixed/hug) 기반 크기 계산
// - 반응형 규칙(breakpoint) 자동 적용
// ========================================
export function PopFlexRenderer({
layout,
viewportWidth,
currentMode = "tablet_landscape",
tempLayout,
isDesignMode = false,
selectedComponentId,
onComponentClick,
onContainerClick,
onBackgroundClick,
onComponentResize,
onReorderComponent,
className,
}: PopFlexRendererProps) {
const { root, components, settings, overrides } = layout;
// 오버라이드 병합 로직 (컨테이너) 🔥
const getMergedRoot = (): PopContainerV4 => {
// 1. 임시 레이아웃이 있으면 최우선 (고정 전 미리보기)
if (tempLayout) {
return tempLayout;
}
// 2. 기본 모드면 root 그대로 반환
if (currentMode === "tablet_landscape") {
return root;
}
// 3. 다른 모드면 오버라이드 병합
const override = overrides?.[currentMode]?.containers?.root;
if (override) {
return {
...root,
...override, // 오버라이드 속성으로 덮어쓰기
};
}
// 4. 오버라이드 없으면 기본값
return root;
};
// visibility 체크 함수 🆕
const isComponentVisible = (component: PopComponentDefinitionV4): boolean => {
if (!component.visibility) return true; // 기본값: 표시
const modeVisibility = component.visibility[currentMode as keyof typeof component.visibility];
return modeVisibility !== false; // undefined도 true로 취급
};
// 컴포넌트 오버라이드 병합 🆕
const getMergedComponent = (baseComponent: PopComponentDefinitionV4): PopComponentDefinitionV4 => {
if (currentMode === "tablet_landscape") return baseComponent;
const componentOverride = overrides?.[currentMode]?.components?.[baseComponent.id];
if (!componentOverride) return baseComponent;
// 깊은 병합 (config, size)
return {
...baseComponent,
...componentOverride,
size: { ...baseComponent.size, ...componentOverride.size },
config: { ...baseComponent.config, ...componentOverride.config },
};
};
const effectiveRoot = getMergedRoot();
// 빈 상태는 PopCanvasV4에서 표시하므로 여기서는 투명 배경만 렌더링
if (effectiveRoot.children.length === 0) {
return (
<div
className={cn("h-full w-full", className)}
onClick={onBackgroundClick}
/>
);
}
return (
<div
className={cn("relative min-h-full w-full bg-white", className)}
onClick={(e) => {
if (e.target === e.currentTarget) {
onBackgroundClick?.();
}
}}
>
{/* 루트 컨테이너 렌더링 (병합된 레이아웃 사용) */}
<ContainerRenderer
container={effectiveRoot}
components={components}
viewportWidth={viewportWidth}
settings={settings}
currentMode={currentMode}
overrides={overrides}
isDesignMode={isDesignMode}
selectedComponentId={selectedComponentId}
onComponentClick={onComponentClick}
onContainerClick={onContainerClick}
onComponentResize={onComponentResize}
onReorderComponent={onReorderComponent}
/>
</div>
);
}
// ========================================
// 컨테이너 렌더러 (재귀)
// ========================================
interface ContainerRendererProps {
container: PopContainerV4;
components: Record<string, PopComponentDefinitionV4>;
viewportWidth: number;
settings: PopLayoutDataV4["settings"];
currentMode: string;
overrides: PopLayoutDataV4["overrides"];
isDesignMode?: boolean;
selectedComponentId?: string | null;
onComponentClick?: (componentId: string) => void;
onContainerClick?: (containerId: string) => void;
onComponentResize?: (componentId: string, size: Partial<PopSizeConstraintV4>) => void;
onReorderComponent?: (containerId: string, fromIndex: number, toIndex: number) => void;
depth?: number;
}
function ContainerRenderer({
container,
components,
viewportWidth,
settings,
currentMode,
overrides,
isDesignMode = false,
selectedComponentId,
onComponentClick,
onContainerClick,
onComponentResize,
onReorderComponent,
depth = 0,
}: ContainerRendererProps) {
// visibility 체크 함수
const isComponentVisible = (component: PopComponentDefinitionV4): boolean => {
if (!component.visibility) return true; // 기본값: 표시
const modeVisibility = component.visibility[currentMode as keyof typeof component.visibility];
return modeVisibility !== false; // undefined도 true로 취급
};
// 컴포넌트 오버라이드 병합
const getMergedComponent = (baseComponent: PopComponentDefinitionV4): PopComponentDefinitionV4 => {
if (currentMode === "tablet_landscape") return baseComponent;
const componentOverride = overrides?.[currentMode as keyof typeof overrides]?.components?.[baseComponent.id];
if (!componentOverride) return baseComponent;
// 깊은 병합 (config, size)
return {
...baseComponent,
...componentOverride,
size: { ...baseComponent.size, ...componentOverride.size },
config: { ...baseComponent.config, ...componentOverride.config },
};
};
// 반응형 규칙 적용
const effectiveContainer = useMemo(() => {
return applyResponsiveRules(container, viewportWidth);
}, [container, viewportWidth]);
// 비율 스케일 계산 (디자인 모드에서는 1, 뷰어에서는 실제 비율 적용)
const scale = isDesignMode ? 1 : viewportWidth / BASE_VIEWPORT_WIDTH;
// Flexbox 스타일 계산 (useMemo는 조건문 전에 호출해야 함)
const containerStyle = useMemo((): React.CSSProperties => {
const { direction, wrap, gap, alignItems, justifyContent, padding } = effectiveContainer;
// gap과 padding도 스케일 적용
const scaledGap = gap * scale;
const scaledPadding = padding ? padding * scale : undefined;
return {
display: "flex",
flexDirection: direction === "horizontal" ? "row" : "column",
flexWrap: wrap ? "wrap" : "nowrap",
gap: `${scaledGap}px`,
alignItems: mapAlignment(alignItems),
justifyContent: mapJustify(justifyContent),
padding: scaledPadding ? `${scaledPadding}px` : undefined,
width: "100%",
minHeight: depth === 0 ? "100%" : undefined,
};
}, [effectiveContainer, depth, scale]);
// 숨김 처리
if (effectiveContainer.hidden) {
return null;
}
return (
<div
className={cn(
"relative",
isDesignMode && depth > 0 && "border border-dashed border-gray-300 rounded"
)}
style={containerStyle}
onClick={(e) => {
if (e.target === e.currentTarget) {
onContainerClick?.(container.id);
}
}}
>
{effectiveContainer.children.map((child, index) => {
// 중첩 컨테이너인 경우
if (typeof child === "object") {
return (
<ContainerRenderer
key={child.id}
container={child}
components={components}
viewportWidth={viewportWidth}
settings={settings}
currentMode={currentMode}
overrides={overrides}
isDesignMode={isDesignMode}
selectedComponentId={selectedComponentId}
onComponentClick={onComponentClick}
onContainerClick={onContainerClick}
onComponentResize={onComponentResize}
onReorderComponent={onReorderComponent}
depth={depth + 1}
/>
);
}
// 컴포넌트 ID인 경우
const componentId = child;
const baseComponent = components[componentId];
if (!baseComponent) return null;
// visibility 체크 (모드별 숨김)
if (!isComponentVisible(baseComponent)) {
return null;
}
// 반응형 숨김 처리 (픽셀 기반)
if (baseComponent.hideBelow && viewportWidth < baseComponent.hideBelow) {
return null;
}
// 오버라이드 병합
const mergedComponent = getMergedComponent(baseComponent);
// pop-break 특수 처리
if (mergedComponent.type === "pop-break") {
return (
<div
key={componentId}
className={cn(
"w-full",
isDesignMode
? "h-4 border-2 border-dashed border-gray-300 rounded flex items-center justify-center cursor-pointer hover:border-gray-400"
: "h-0"
)}
style={{ flexBasis: "100%" }}
onClick={() => onComponentClick?.(componentId)}
>
{isDesignMode && (
<span className="text-xs text-gray-400"></span>
)}
</div>
);
}
return (
<DraggableComponentWrapper
key={componentId}
componentId={componentId}
containerId={container.id}
index={index}
isDesignMode={isDesignMode}
onReorder={onReorderComponent}
>
<ComponentRendererV4
componentId={componentId}
component={mergedComponent}
settings={settings}
viewportWidth={viewportWidth}
isDesignMode={isDesignMode}
isSelected={selectedComponentId === componentId}
onClick={() => onComponentClick?.(componentId)}
onResize={onComponentResize}
/>
</DraggableComponentWrapper>
);
})}
</div>
);
}
// ========================================
// 드래그 가능한 컴포넌트 래퍼
// ========================================
interface DraggableComponentWrapperProps {
componentId: string;
containerId: string;
index: number;
isDesignMode: boolean;
onReorder?: (containerId: string, fromIndex: number, toIndex: number) => void;
children: React.ReactNode;
}
function DraggableComponentWrapper({
componentId,
containerId,
index,
isDesignMode,
onReorder,
children,
}: DraggableComponentWrapperProps) {
// 디자인 모드가 아니면 그냥 children 반환 (훅 호출 전에 체크)
// DndProvider가 없는 환경에서 useDrag/useDrop 훅 호출 방지
if (!isDesignMode) {
return <>{children}</>;
}
// 디자인 모드일 때만 드래그 기능 활성화
return (
<DraggableComponentWrapperInner
componentId={componentId}
containerId={containerId}
index={index}
onReorder={onReorder}
>
{children}
</DraggableComponentWrapperInner>
);
}
// 디자인 모드 전용 내부 컴포넌트 (DndProvider 필요)
function DraggableComponentWrapperInner({
componentId,
containerId,
index,
onReorder,
children,
}: Omit<DraggableComponentWrapperProps, "isDesignMode">) {
const ref = useRef<HTMLDivElement>(null);
const [{ isDragging }, drag] = useDrag({
type: DND_COMPONENT_REORDER,
item: (): DragItem => ({
type: DND_COMPONENT_REORDER,
componentId,
containerId,
index,
}),
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
});
const [{ isOver, canDrop }, drop] = useDrop({
accept: DND_COMPONENT_REORDER,
canDrop: (item: DragItem) => {
// 같은 컨테이너 내에서만 이동 가능 (일단은)
return item.containerId === containerId && item.index !== index;
},
drop: (item: DragItem) => {
if (item.index !== index && onReorder) {
onReorder(containerId, item.index, index);
}
},
collect: (monitor) => ({
isOver: monitor.isOver(),
canDrop: monitor.canDrop(),
}),
});
// drag와 drop 합치기
drag(drop(ref));
return (
<div
ref={ref}
className={cn(
"relative",
isDragging && "opacity-50",
isOver && canDrop && "ring-2 ring-blue-500 ring-offset-2"
)}
style={{ cursor: isDragging ? "grabbing" : "grab" }}
>
{children}
{/* 드롭 인디케이터 */}
{isOver && canDrop && (
<div className="absolute inset-0 bg-blue-500/10 pointer-events-none rounded" />
)}
</div>
);
}
// ========================================
// v4 컴포넌트 렌더러 (리사이즈 핸들 포함)
// ========================================
interface ComponentRendererV4Props {
componentId: string;
component: PopComponentDefinitionV4;
settings: PopLayoutDataV4["settings"];
viewportWidth: number;
isDesignMode?: boolean;
isSelected?: boolean;
onClick?: () => void;
onResize?: (componentId: string, size: Partial<PopSizeConstraintV4>) => void;
}
function ComponentRendererV4({
componentId,
component,
settings,
viewportWidth,
isDesignMode = false,
isSelected = false,
onClick,
onResize,
}: ComponentRendererV4Props) {
const { size, alignSelf, type, label } = component;
const containerRef = useRef<HTMLDivElement>(null);
// 비율 스케일 계산 (디자인 모드에서는 1, 뷰어에서는 실제 비율 적용)
const scale = isDesignMode ? 1 : viewportWidth / BASE_VIEWPORT_WIDTH;
// 리사이즈 상태
const [isResizing, setIsResizing] = useState(false);
const [resizeDirection, setResizeDirection] = useState<"width" | "height" | "both" | null>(null);
const resizeStartRef = useRef<{ x: number; y: number; width: number; height: number } | null>(null);
// 크기 스타일 계산 (스케일 적용)
const sizeStyle = useMemo((): React.CSSProperties => {
return calculateSizeStyle(size, settings, scale);
}, [size, settings, scale]);
// alignSelf 스타일
const alignStyle: React.CSSProperties = alignSelf
? { alignSelf: mapAlignment(alignSelf) }
: {};
const typeLabel = COMPONENT_TYPE_LABELS[type] || type;
// 리사이즈 시작
const handleResizeStart = useCallback((
e: React.MouseEvent,
direction: "width" | "height" | "both"
) => {
e.stopPropagation();
e.preventDefault();
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
resizeStartRef.current = {
x: e.clientX,
y: e.clientY,
width: rect.width,
height: rect.height,
};
setIsResizing(true);
setResizeDirection(direction);
}, []);
// 리사이즈 중
useCallback((e: MouseEvent) => {
if (!isResizing || !resizeStartRef.current || !onResize) return;
const deltaX = e.clientX - resizeStartRef.current.x;
const deltaY = e.clientY - resizeStartRef.current.y;
const updates: Partial<PopSizeConstraintV4> = {};
if (resizeDirection === "width" || resizeDirection === "both") {
const newWidth = Math.max(48, Math.round(resizeStartRef.current.width + deltaX));
updates.width = "fixed";
updates.fixedWidth = newWidth;
}
if (resizeDirection === "height" || resizeDirection === "both") {
const newHeight = Math.max(settings.touchTargetMin, Math.round(resizeStartRef.current.height + deltaY));
updates.height = "fixed";
updates.fixedHeight = newHeight;
}
onResize(componentId, updates);
}, [isResizing, resizeDirection, componentId, onResize, settings.touchTargetMin]);
// 리사이즈 종료 및 이벤트 등록
React.useEffect(() => {
if (!isResizing) return;
const handleMouseMove = (e: MouseEvent) => {
if (!resizeStartRef.current || !onResize) return;
const deltaX = e.clientX - resizeStartRef.current.x;
const deltaY = e.clientY - resizeStartRef.current.y;
const updates: Partial<PopSizeConstraintV4> = {};
if (resizeDirection === "width" || resizeDirection === "both") {
const newWidth = Math.max(48, Math.round(resizeStartRef.current.width + deltaX));
updates.width = "fixed";
updates.fixedWidth = newWidth;
}
if (resizeDirection === "height" || resizeDirection === "both") {
const newHeight = Math.max(settings.touchTargetMin, Math.round(resizeStartRef.current.height + deltaY));
updates.height = "fixed";
updates.fixedHeight = newHeight;
}
onResize(componentId, updates);
};
const handleMouseUp = () => {
setIsResizing(false);
setResizeDirection(null);
resizeStartRef.current = null;
};
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
return () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
};
}, [isResizing, resizeDirection, componentId, onResize, settings.touchTargetMin]);
return (
<div
ref={containerRef}
className={cn(
"relative flex flex-col overflow-visible rounded-lg border-2 bg-white transition-all",
isSelected
? "border-primary ring-2 ring-primary/30 z-10"
: "border-gray-200",
isDesignMode && !isResizing && "cursor-pointer hover:border-gray-300",
isResizing && "select-none"
)}
style={{
...sizeStyle,
...alignStyle,
}}
onClick={(e) => {
e.stopPropagation();
if (!isResizing) {
onClick?.();
}
}}
>
{/* 컴포넌트 라벨 (디자인 모드에서만) */}
{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">
{label || typeLabel}
</span>
</div>
)}
{/* 컴포넌트 내용 */}
<div className="flex flex-1 items-center justify-center p-2 overflow-hidden">
{renderComponentContent(component, isDesignMode, settings)}
</div>
{/* 리사이즈 핸들 (디자인 모드 + 선택 시에만) */}
{isDesignMode && isSelected && onResize && (
<>
{/* 오른쪽 핸들 (너비 조정) */}
<div
className="absolute right-0 top-1/2 -translate-y-1/2 translate-x-1/2 w-3 h-8 bg-primary rounded cursor-ew-resize hover:bg-primary/80 flex items-center justify-center z-20"
onMouseDown={(e) => handleResizeStart(e, "width")}
title="너비 조정"
>
<div className="w-0.5 h-4 bg-white rounded" />
</div>
{/* 아래쪽 핸들 (높이 조정) */}
<div
className="absolute bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2 h-3 w-8 bg-primary rounded cursor-ns-resize hover:bg-primary/80 flex items-center justify-center z-20"
onMouseDown={(e) => handleResizeStart(e, "height")}
title="높이 조정"
>
<div className="h-0.5 w-4 bg-white rounded" />
</div>
{/* 오른쪽 아래 핸들 (너비 + 높이 동시 조정) */}
<div
className="absolute right-0 bottom-0 translate-x-1/2 translate-y-1/2 w-4 h-4 bg-primary rounded cursor-nwse-resize hover:bg-primary/80 flex items-center justify-center z-20"
onMouseDown={(e) => handleResizeStart(e, "both")}
title="크기 조정"
>
<div className="w-2 h-2 border-r-2 border-b-2 border-white" />
</div>
</>
)}
</div>
);
}
// ========================================
// 헬퍼 함수들
// ========================================
/**
* 반응형 규칙 적용
*/
function applyResponsiveRules(
container: PopContainerV4,
viewportWidth: number
): PopContainerV4 & { hidden?: boolean } {
if (!container.responsive || container.responsive.length === 0) {
return container;
}
// 현재 뷰포트에 적용되는 규칙 찾기 (가장 큰 breakpoint부터)
const sortedRules = [...container.responsive].sort((a, b) => b.breakpoint - a.breakpoint);
const applicableRule = sortedRules.find((rule) => viewportWidth <= rule.breakpoint);
if (!applicableRule) {
return container;
}
return {
...container,
direction: applicableRule.direction ?? container.direction,
gap: applicableRule.gap ?? container.gap,
hidden: applicableRule.hidden ?? false,
};
}
/**
* 기준 뷰포트 너비 (10인치 태블릿 가로)
* 이 크기를 기준으로 컴포넌트 크기가 비율 조정됨
*/
const BASE_VIEWPORT_WIDTH = 1024;
/**
* 크기 제약 → CSS 스타일 변환
* @param scale - 뷰포트 비율 (viewportWidth / BASE_VIEWPORT_WIDTH)
*/
function calculateSizeStyle(
size: PopSizeConstraintV4,
settings: PopLayoutDataV4["settings"],
scale: number = 1
): React.CSSProperties {
const style: React.CSSProperties = {};
// 스케일된 터치 최소 크기
const scaledTouchMin = settings.touchTargetMin * scale;
// 너비
switch (size.width) {
case "fixed":
// fixed 크기도 비율에 맞게 스케일
style.width = size.fixedWidth ? `${size.fixedWidth * scale}px` : "auto";
style.flexShrink = 0;
break;
case "fill":
style.flex = 1;
style.minWidth = size.minWidth ? `${size.minWidth * scale}px` : 0;
style.maxWidth = size.maxWidth ? `${size.maxWidth * scale}px` : undefined;
break;
case "hug":
style.width = "auto";
style.flexShrink = 0;
break;
}
// 높이
switch (size.height) {
case "fixed":
const scaledFixedHeight = (size.fixedHeight || settings.touchTargetMin) * scale;
const minHeight = Math.max(scaledFixedHeight, scaledTouchMin);
style.height = `${minHeight}px`;
break;
case "fill":
style.flexGrow = 1;
style.minHeight = size.minHeight
? `${Math.max(size.minHeight * scale, scaledTouchMin)}px`
: `${scaledTouchMin}px`;
break;
case "hug":
style.height = "auto";
style.minHeight = `${scaledTouchMin}px`;
break;
}
return style;
}
/**
* alignItems 값 변환
*/
function mapAlignment(value: string): React.CSSProperties["alignItems"] {
switch (value) {
case "start":
return "flex-start";
case "end":
return "flex-end";
case "center":
return "center";
case "stretch":
return "stretch";
default:
return "stretch";
}
}
/**
* justifyContent 값 변환
*/
function mapJustify(value: string): React.CSSProperties["justifyContent"] {
switch (value) {
case "start":
return "flex-start";
case "end":
return "flex-end";
case "center":
return "center";
case "space-between":
return "space-between";
default:
return "flex-start";
}
}
/**
* 컴포넌트별 내용 렌더링
*/
function renderComponentContent(
component: PopComponentDefinitionV4,
isDesignMode: boolean,
settings: PopLayoutDataV4["settings"]
): React.ReactNode {
const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type;
// 디자인 모드에서는 플레이스홀더 표시
if (isDesignMode) {
// Spacer는 디자인 모드에서 점선 배경으로 표시
if (component.type === "pop-spacer") {
return (
<div className="h-full w-full flex items-center justify-center border-2 border-dashed border-gray-300 bg-gray-50/50 rounded">
<span className="text-xs text-gray-400"> </span>
</div>
);
}
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>
);
case "pop-spacer":
// 실제 모드에서 Spacer는 완전히 투명 (공간만 차지)
return null;
default:
return (
<div className="text-xs text-gray-400">
{typeLabel}
</div>
);
}
}
export default PopFlexRenderer;