ERP-node/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx

488 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import React from "react";
import { cn } from "@/lib/utils";
import {
PopComponentDefinitionV5,
PopGridPosition,
GridMode,
GRID_BREAKPOINTS,
} from "../types/pop-layout";
import {
Settings,
Database,
Eye,
Grid3x3,
MoveHorizontal,
MoveVertical,
Layers,
} from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry";
// ========================================
// Props
// ========================================
interface ComponentEditorPanelProps {
/** 선택된 컴포넌트 */
component: PopComponentDefinitionV5 | null;
/** 현재 모드 */
currentMode: GridMode;
/** 컴포넌트 업데이트 */
onUpdateComponent?: (updates: Partial<PopComponentDefinitionV5>) => void;
/** 추가 className */
className?: string;
/** 그리드에 배치된 모든 컴포넌트 */
allComponents?: PopComponentDefinitionV5[];
/** 컴포넌트 선택 콜백 */
onSelectComponent?: (componentId: string) => void;
/** 현재 선택된 컴포넌트 ID */
selectedComponentId?: string | null;
}
// ========================================
// 컴포넌트 타입별 라벨
// ========================================
const COMPONENT_TYPE_LABELS: Record<string, string> = {
"pop-sample": "샘플",
"pop-text": "텍스트",
"pop-dashboard": "대시보드",
"pop-field": "필드",
"pop-button": "버튼",
"pop-list": "리스트",
"pop-indicator": "인디케이터",
"pop-scanner": "스캐너",
"pop-numpad": "숫자패드",
"pop-spacer": "스페이서",
"pop-break": "줄바꿈",
};
// ========================================
// 컴포넌트 편집 패널 (v5 그리드 시스템)
// ========================================
export default function ComponentEditorPanel({
component,
currentMode,
onUpdateComponent,
className,
allComponents,
onSelectComponent,
selectedComponentId,
}: ComponentEditorPanelProps) {
const breakpoint = GRID_BREAKPOINTS[currentMode];
// 선택된 컴포넌트 없음
if (!component) {
return (
<div className={cn("flex h-full flex-col bg-white", className)}>
<div className="border-b px-4 py-3">
<h3 className="text-sm font-medium"></h3>
</div>
<div className="flex flex-1 items-center justify-center p-4 text-sm text-muted-foreground">
</div>
</div>
);
}
// 기본 모드 여부
const isDefaultMode = currentMode === "tablet_landscape";
return (
<div className={cn("flex h-full flex-col bg-white", className)}>
{/* 헤더 */}
<div className="border-b px-4 py-3">
<h3 className="text-sm font-medium">
{component.label || COMPONENT_TYPE_LABELS[component.type]}
</h3>
<p className="text-xs text-muted-foreground">{component.type}</p>
{!isDefaultMode && (
<p className="text-xs text-amber-600 mt-1">
(릿 )
</p>
)}
</div>
{/* 탭 */}
<Tabs defaultValue="position" className="flex min-h-0 flex-1 flex-col">
<TabsList className="w-full shrink-0 justify-start rounded-none border-b bg-transparent px-2">
<TabsTrigger value="position" className="gap-1 text-xs">
<Grid3x3 className="h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="settings" className="gap-1 text-xs">
<Settings className="h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="visibility" className="gap-1 text-xs">
<Eye className="h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="data" className="gap-1 text-xs">
<Database className="h-3 w-3" />
</TabsTrigger>
</TabsList>
{/* 위치 탭 */}
<TabsContent value="position" className="min-h-0 flex-1 overflow-auto p-4">
{/* 배치된 컴포넌트 목록 */}
{allComponents && allComponents.length > 0 && (
<div className="mb-4">
<div className="flex items-center gap-1 mb-2">
<Layers className="h-3 w-3 text-muted-foreground" />
<span className="text-xs font-medium text-muted-foreground">
({allComponents.length})
</span>
</div>
<div className="space-y-1">
{allComponents.map((comp) => {
const label = comp.label
|| COMPONENT_TYPE_LABELS[comp.type]
|| comp.type;
const isActive = comp.id === selectedComponentId;
return (
<button
key={comp.id}
onClick={() => onSelectComponent?.(comp.id)}
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs transition-colors",
isActive
? "bg-primary/10 text-primary font-medium"
: "hover:bg-gray-100 text-gray-600"
)}
>
<span className="truncate flex-1">{label}</span>
<span className="shrink-0 text-[10px] text-gray-400">
({comp.position.col},{comp.position.row})
</span>
</button>
);
})}
</div>
<div className="h-px bg-gray-200 mt-3" />
</div>
)}
<PositionForm
component={component}
currentMode={currentMode}
isDefaultMode={isDefaultMode}
columns={breakpoint.columns}
onUpdate={onUpdateComponent}
/>
</TabsContent>
{/* 설정 탭 */}
<TabsContent value="settings" className="min-h-0 flex-1 overflow-auto p-4">
<ComponentSettingsForm
component={component}
onUpdate={onUpdateComponent}
/>
</TabsContent>
{/* 표시 탭 */}
<TabsContent value="visibility" className="min-h-0 flex-1 overflow-auto p-4">
<VisibilityForm
component={component}
onUpdate={onUpdateComponent}
/>
</TabsContent>
{/* 데이터 탭 */}
<TabsContent value="data" className="min-h-0 flex-1 overflow-auto p-4">
<DataBindingPlaceholder />
</TabsContent>
</Tabs>
</div>
);
}
// ========================================
// 위치 편집 폼
// ========================================
interface PositionFormProps {
component: PopComponentDefinitionV5;
currentMode: GridMode;
isDefaultMode: boolean;
columns: number;
onUpdate?: (updates: Partial<PopComponentDefinitionV5>) => void;
}
function PositionForm({ component, currentMode, isDefaultMode, columns, onUpdate }: PositionFormProps) {
const { position } = component;
const handlePositionChange = (field: keyof PopGridPosition, value: number) => {
// 범위 체크
let clampedValue = Math.max(1, value);
if (field === "col" || field === "colSpan") {
clampedValue = Math.min(columns, clampedValue);
}
if (field === "colSpan" && position.col + clampedValue - 1 > columns) {
clampedValue = columns - position.col + 1;
}
onUpdate?.({
position: {
...position,
[field]: clampedValue,
},
});
};
return (
<div className="space-y-6">
{/* 그리드 정보 */}
<div className="rounded-lg bg-gray-50 p-3">
<p className="text-xs font-medium text-gray-700 mb-1">
: {GRID_BREAKPOINTS[currentMode].label}
</p>
<p className="text-xs text-muted-foreground">
{columns} ×
</p>
</div>
{/* 열 위치 */}
<div className="space-y-2">
<Label className="text-xs font-medium flex items-center gap-1">
<MoveHorizontal className="h-3 w-3" />
(Col)
</Label>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
max={columns}
value={position.col}
onChange={(e) => handlePositionChange("col", parseInt(e.target.value) || 1)}
disabled={!isDefaultMode}
className="h-8 w-20 text-xs"
/>
<span className="text-xs text-muted-foreground">
(1~{columns})
</span>
</div>
</div>
{/* 행 위치 */}
<div className="space-y-2">
<Label className="text-xs font-medium flex items-center gap-1">
<MoveVertical className="h-3 w-3" />
(Row)
</Label>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
value={position.row}
onChange={(e) => handlePositionChange("row", parseInt(e.target.value) || 1)}
disabled={!isDefaultMode}
className="h-8 w-20 text-xs"
/>
<span className="text-xs text-muted-foreground">
(1~)
</span>
</div>
</div>
<div className="h-px bg-gray-200" />
{/* 열 크기 */}
<div className="space-y-2">
<Label className="text-xs font-medium flex items-center gap-1">
<MoveHorizontal className="h-3 w-3" />
(ColSpan)
</Label>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
max={columns}
value={position.colSpan}
onChange={(e) => handlePositionChange("colSpan", parseInt(e.target.value) || 1)}
disabled={!isDefaultMode}
className="h-8 w-20 text-xs"
/>
<span className="text-xs text-muted-foreground">
(1~{columns})
</span>
</div>
<p className="text-xs text-muted-foreground">
{Math.round((position.colSpan / columns) * 100)}%
</p>
</div>
{/* 행 크기 */}
<div className="space-y-2">
<Label className="text-xs font-medium flex items-center gap-1">
<MoveVertical className="h-3 w-3" />
(RowSpan)
</Label>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
value={position.rowSpan}
onChange={(e) => handlePositionChange("rowSpan", parseInt(e.target.value) || 1)}
disabled={!isDefaultMode}
className="h-8 w-20 text-xs"
/>
<span className="text-xs text-muted-foreground">
</span>
</div>
<p className="text-xs text-muted-foreground">
: {position.rowSpan * GRID_BREAKPOINTS[currentMode].rowHeight}px
</p>
</div>
{/* 비활성화 안내 */}
{!isDefaultMode && (
<div className="rounded-lg bg-amber-50 border border-amber-200 p-3">
<p className="text-xs text-amber-800">
(릿 ) .
.
</p>
</div>
)}
</div>
);
}
// ========================================
// 설정 폼
// ========================================
interface ComponentSettingsFormProps {
component: PopComponentDefinitionV5;
onUpdate?: (updates: Partial<PopComponentDefinitionV5>) => void;
}
function ComponentSettingsForm({ component, onUpdate }: ComponentSettingsFormProps) {
// PopComponentRegistry에서 configPanel 가져오기
const registeredComp = PopComponentRegistry.getComponent(component.type);
const ConfigPanel = registeredComp?.configPanel;
// config 업데이트 핸들러
const handleConfigUpdate = (newConfig: any) => {
onUpdate?.({ config: newConfig });
};
return (
<div className="space-y-4">
{/* 라벨 */}
<div className="space-y-2">
<Label className="text-xs font-medium"></Label>
<Input
type="text"
value={component.label || ""}
onChange={(e) => onUpdate?.({ label: e.target.value })}
placeholder="컴포넌트 이름"
className="h-8 text-xs"
/>
</div>
{/* 컴포넌트 타입별 설정 패널 */}
{ConfigPanel ? (
<ConfigPanel
config={component.config || {}}
onUpdate={handleConfigUpdate}
/>
) : (
<div className="rounded-lg bg-gray-50 p-3">
<p className="text-xs text-muted-foreground">
{component.type}
</p>
</div>
)}
</div>
);
}
// ========================================
// 표시/숨김 폼
// ========================================
interface VisibilityFormProps {
component: PopComponentDefinitionV5;
onUpdate?: (updates: Partial<PopComponentDefinitionV5>) => void;
}
function VisibilityForm({ component, onUpdate }: VisibilityFormProps) {
const modes: Array<{ key: GridMode; label: string }> = [
{ key: "tablet_landscape", label: "태블릿 가로 (12칸)" },
{ key: "tablet_portrait", label: "태블릿 세로 (8칸)" },
{ key: "mobile_landscape", label: "모바일 가로 (6칸)" },
{ key: "mobile_portrait", label: "모바일 세로 (4칸)" },
];
const handleVisibilityChange = (mode: GridMode, visible: boolean) => {
onUpdate?.({
visibility: {
...component.visibility,
[mode]: visible,
},
});
};
return (
<div className="space-y-4">
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
{modes.map((mode) => {
const isVisible = component.visibility?.[mode.key] !== false;
return (
<div key={mode.key} className="flex items-center gap-2">
<Checkbox
id={`visibility-${mode.key}`}
checked={isVisible}
onCheckedChange={(checked) =>
handleVisibilityChange(mode.key, checked === true)
}
/>
<label
htmlFor={`visibility-${mode.key}`}
className="text-xs cursor-pointer"
>
{mode.label}
</label>
</div>
);
})}
</div>
<div className="rounded-lg bg-blue-50 border border-blue-200 p-3">
<p className="text-xs text-blue-800">
</p>
</div>
</div>
);
}
// ========================================
// 데이터 바인딩 플레이스홀더
// ========================================
function DataBindingPlaceholder() {
return (
<div className="space-y-4">
<div className="rounded-lg bg-gray-50 p-4 text-center">
<Database className="mx-auto mb-2 h-8 w-8 text-muted-foreground" />
<p className="text-sm font-medium text-gray-700"> </p>
<p className="text-xs text-muted-foreground mt-1">
Phase 4
</p>
</div>
</div>
);
}