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

470 lines
16 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, Link2, 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";
import { PopDataConnection, PopModalDefinition } from "../types/pop-layout";
import ConnectionEditor from "./ConnectionEditor";
// ========================================
// 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;
/** 대시보드 페이지 미리보기 인덱스 */
previewPageIndex?: number;
/** 페이지 미리보기 요청 콜백 */
onPreviewPage?: (pageIndex: number) => void;
/** 데이터 흐름 연결 목록 */
connections?: PopDataConnection[];
/** 연결 추가 콜백 */
onAddConnection?: (conn: Omit<PopDataConnection, "id">) => void;
/** 연결 수정 콜백 */
onUpdateConnection?: (connectionId: string, conn: Omit<PopDataConnection, "id">) => void;
/** 연결 삭제 콜백 */
onRemoveConnection?: (connectionId: string) => void;
/** 모달 정의 목록 (설정 패널에 전달) */
modals?: PopModalDefinition[];
}
// ========================================
// 컴포넌트 타입별 라벨
// ========================================
const COMPONENT_TYPE_LABELS: Record<string, string> = {
"pop-sample": "샘플",
"pop-text": "텍스트",
"pop-icon": "아이콘",
"pop-dashboard": "대시보드",
"pop-card-list": "카드 목록",
"pop-field": "필드",
"pop-button": "버튼",
"pop-string-list": "리스트 목록",
"pop-search": "검색",
"pop-list": "리스트",
"pop-indicator": "인디케이터",
"pop-scanner": "스캐너",
"pop-numpad": "숫자패드",
"pop-spacer": "스페이서",
"pop-break": "줄바꿈",
};
// ========================================
// 컴포넌트 편집 패널 (v5 그리드 시스템)
// ========================================
export default function ComponentEditorPanel({
component,
currentMode,
onUpdateComponent,
className,
allComponents,
onSelectComponent,
selectedComponentId,
previewPageIndex,
onPreviewPage,
connections,
onAddConnection,
onUpdateConnection,
onRemoveConnection,
modals,
}: 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="text-muted-foreground flex flex-1 items-center justify-center p-4 text-sm">
</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-muted-foreground text-xs">{component.type}</p>
{!isDefaultMode && <p className="mt-1 text-xs text-amber-600"> (릿 ) </p>}
</div>
{/* 탭 */}
<Tabs defaultValue="position" className="flex min-h-0 flex-1 flex-col overflow-hidden">
<TabsList className="w-full flex-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="connection" className="gap-1 text-xs">
<Link2 className="h-3 w-3" />
</TabsTrigger>
</TabsList>
{/* 위치 탭 */}
<TabsContent value="position" className="m-0 min-h-0 flex-1 overflow-y-auto p-4">
{/* 배치된 컴포넌트 목록 */}
{allComponents && allComponents.length > 0 && (
<div className="mb-4">
<div className="mb-2 flex items-center gap-1">
<Layers className="text-muted-foreground h-3 w-3" />
<span className="text-muted-foreground text-xs font-medium">
({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" : "text-gray-600 hover:bg-gray-100",
)}
>
<span className="flex-1 truncate">{label}</span>
<span className="shrink-0 text-[10px] text-gray-400">
({comp.position.col},{comp.position.row})
</span>
</button>
);
})}
</div>
<div className="mt-3 h-px bg-gray-200" />
</div>
)}
<PositionForm
component={component}
currentMode={currentMode}
isDefaultMode={isDefaultMode}
columns={breakpoint.columns}
onUpdate={onUpdateComponent}
/>
</TabsContent>
{/* 설정 탭 */}
<TabsContent value="settings" className="m-0 min-h-0 flex-1 overflow-y-auto p-4">
<ComponentSettingsForm
component={component}
onUpdate={onUpdateComponent}
currentMode={currentMode}
previewPageIndex={previewPageIndex}
onPreviewPage={onPreviewPage}
modals={modals}
/>
</TabsContent>
{/* 표시 탭 */}
<TabsContent value="visibility" className="m-0 min-h-0 flex-1 overflow-y-auto p-4">
<VisibilityForm component={component} onUpdate={onUpdateComponent} />
</TabsContent>
{/* 연결 탭 */}
<TabsContent value="connection" className="m-0 min-h-0 flex-1 overflow-y-auto p-4">
<ConnectionEditor
component={component}
allComponents={allComponents || []}
connections={connections || []}
onAddConnection={onAddConnection}
onUpdateConnection={onUpdateConnection}
onRemoveConnection={onRemoveConnection}
/>
</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="mb-1 text-xs font-medium text-gray-700"> : {GRID_BREAKPOINTS[currentMode].label}</p>
<p className="text-muted-foreground text-xs"> {columns} × </p>
</div>
{/* 열 위치 */}
<div className="space-y-2">
<Label className="flex items-center gap-1 text-xs font-medium">
<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-muted-foreground text-xs">(1~{columns})</span>
</div>
</div>
{/* 행 위치 */}
<div className="space-y-2">
<Label className="flex items-center gap-1 text-xs font-medium">
<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-muted-foreground text-xs">(1~)</span>
</div>
</div>
<div className="h-px bg-gray-200" />
{/* 열 크기 */}
<div className="space-y-2">
<Label className="flex items-center gap-1 text-xs font-medium">
<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-muted-foreground text-xs"> (1~{columns})</span>
</div>
<p className="text-muted-foreground text-xs">{Math.round((position.colSpan / columns) * 100)}% </p>
</div>
{/* 행 크기 */}
<div className="space-y-2">
<Label className="flex items-center gap-1 text-xs font-medium">
<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-muted-foreground text-xs"></span>
</div>
<p className="text-muted-foreground text-xs">
: {position.rowSpan * GRID_BREAKPOINTS[currentMode].rowHeight}px
</p>
</div>
{/* 비활성화 안내 */}
{!isDefaultMode && (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3">
<p className="text-xs text-amber-800">
(릿 ) . .
</p>
</div>
)}
</div>
);
}
// ========================================
// 설정 폼
// ========================================
interface ComponentSettingsFormProps {
component: PopComponentDefinitionV5;
onUpdate?: (updates: Partial<PopComponentDefinitionV5>) => void;
currentMode?: GridMode;
previewPageIndex?: number;
onPreviewPage?: (pageIndex: number) => void;
modals?: PopModalDefinition[];
}
function ComponentSettingsForm({
component,
onUpdate,
currentMode,
previewPageIndex,
onPreviewPage,
modals,
}: 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}
currentMode={currentMode}
currentColSpan={component.position.colSpan}
onPreviewPage={onPreviewPage}
previewPageIndex={previewPageIndex}
modals={modals}
/>
) : (
<div className="rounded-lg bg-gray-50 p-3">
<p className="text-muted-foreground text-xs">{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="cursor-pointer text-xs">
{mode.label}
</label>
</div>
);
})}
</div>
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3">
<p className="text-xs text-blue-800"> </p>
</div>
</div>
);
}