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

485 lines
15 KiB
TypeScript
Raw Normal View History

"use client";
import { useState } from "react";
import { useDrag } from "react-dnd";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Plus,
Settings,
LayoutGrid,
Type,
MousePointer,
List,
Activity,
ScanLine,
Calculator,
Trash2,
ChevronDown,
GripVertical,
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
PopLayoutDataV2,
PopLayoutModeKey,
PopSectionDefinition,
PopComponentType,
MODE_RESOLUTIONS,
} from "../types/pop-layout";
// ========================================
// 드래그 아이템 타입
// ========================================
export const DND_ITEM_TYPES = {
SECTION: "section",
COMPONENT: "component",
} as const;
export interface DragItemSection {
type: typeof DND_ITEM_TYPES.SECTION;
}
export interface DragItemComponent {
type: typeof DND_ITEM_TYPES.COMPONENT;
componentType: PopComponentType;
}
// ========================================
// 컴포넌트 팔레트 정의
// ========================================
const COMPONENT_PALETTE: {
type: PopComponentType;
label: string;
icon: React.ElementType;
description: string;
}[] = [
{
type: "pop-field",
label: "필드",
icon: Type,
description: "텍스트, 숫자 등 데이터 입력",
},
{
type: "pop-button",
label: "버튼",
icon: MousePointer,
description: "저장, 삭제 등 액션 실행",
},
{
type: "pop-list",
label: "리스트",
icon: List,
description: "데이터 목록 표시",
},
{
type: "pop-indicator",
label: "인디케이터",
icon: Activity,
description: "KPI, 상태 표시",
},
{
type: "pop-scanner",
label: "스캐너",
icon: ScanLine,
description: "바코드/QR 스캔",
},
{
type: "pop-numpad",
label: "숫자패드",
icon: Calculator,
description: "숫자 입력 전용",
},
];
// ========================================
// Props
// ========================================
interface PopPanelProps {
layout: PopLayoutDataV2;
activeModeKey: PopLayoutModeKey;
selectedSectionId: string | null;
selectedSection: PopSectionDefinition | null;
onUpdateSectionDefinition: (id: string, updates: Partial<PopSectionDefinition>) => void;
onDeleteSection: (id: string) => void;
activeDevice: "mobile" | "tablet";
}
// ========================================
// 메인 컴포넌트
// ========================================
export function PopPanel({
layout,
activeModeKey,
selectedSectionId,
selectedSection,
onUpdateSectionDefinition,
onDeleteSection,
activeDevice,
}: PopPanelProps) {
const [activeTab, setActiveTab] = useState<string>("components");
// 현재 모드의 섹션 위치
const currentModeLayout = layout.layouts[activeModeKey];
const selectedSectionPosition = selectedSectionId
? currentModeLayout.sectionPositions[selectedSectionId]
: null;
return (
<div className="flex h-full flex-col">
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex h-full flex-col"
>
<TabsList className="mx-2 mt-2 grid w-auto grid-cols-2">
<TabsTrigger value="components" className="text-xs">
<Plus className="mr-1 h-3.5 w-3.5" />
</TabsTrigger>
<TabsTrigger value="edit" className="text-xs">
<Settings className="mr-1 h-3.5 w-3.5" />
</TabsTrigger>
</TabsList>
{/* 컴포넌트 탭 */}
<TabsContent value="components" className="flex-1 overflow-auto p-2">
<div className="space-y-4">
{/* 현재 모드 표시 */}
<div className="rounded-lg bg-muted p-2">
<p className="text-xs font-medium text-muted-foreground">
: {getModeLabel(activeModeKey)}
</p>
<p className="text-[10px] text-muted-foreground">
{MODE_RESOLUTIONS[activeModeKey].width} x {MODE_RESOLUTIONS[activeModeKey].height}
</p>
</div>
{/* 섹션 드래그 아이템 */}
<div>
<h4 className="mb-2 text-xs font-medium text-muted-foreground">
</h4>
<DraggableSectionItem />
<p className="mt-1 text-xs text-muted-foreground">
</p>
</div>
{/* 컴포넌트 팔레트 */}
<div>
<h4 className="mb-2 text-xs font-medium text-muted-foreground">
</h4>
<div className="space-y-1">
{COMPONENT_PALETTE.map((item) => (
<DraggableComponentItem
key={item.type}
type={item.type}
label={item.label}
icon={item.icon}
description={item.description}
/>
))}
</div>
<p className="mt-2 text-xs text-muted-foreground">
</p>
</div>
</div>
</TabsContent>
{/* 편집 탭 */}
<TabsContent value="edit" className="flex-1 overflow-auto p-2">
{selectedSection && selectedSectionPosition ? (
<SectionEditorV2
section={selectedSection}
position={selectedSectionPosition}
activeModeKey={activeModeKey}
onUpdateDefinition={(updates) =>
onUpdateSectionDefinition(selectedSection.id, updates)
}
onDelete={() => onDeleteSection(selectedSection.id)}
/>
) : (
<div className="flex h-40 items-center justify-center text-sm text-muted-foreground">
</div>
)}
</TabsContent>
</Tabs>
</div>
);
}
// ========================================
// 모드 라벨 헬퍼
// ========================================
function getModeLabel(modeKey: PopLayoutModeKey): string {
const labels: Record<PopLayoutModeKey, string> = {
tablet_landscape: "태블릿 가로",
tablet_portrait: "태블릿 세로",
mobile_landscape: "모바일 가로",
mobile_portrait: "모바일 세로",
};
return labels[modeKey];
}
// ========================================
// 드래그 가능한 섹션 아이템
// ========================================
function DraggableSectionItem() {
const [{ isDragging }, drag] = useDrag(() => ({
type: DND_ITEM_TYPES.SECTION,
item: { type: DND_ITEM_TYPES.SECTION } as DragItemSection,
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
}));
return (
<div
ref={drag}
className={cn(
"flex cursor-grab items-center gap-3 rounded-lg border p-3 transition-all",
"hover:bg-accent hover:text-accent-foreground",
isDragging && "opacity-50 ring-2 ring-primary"
)}
>
<GripVertical className="h-4 w-4 text-gray-400" />
<LayoutGrid className="h-4 w-4" />
<div className="flex-1">
<p className="text-sm font-medium"></p>
<p className="text-xs text-muted-foreground"> </p>
</div>
</div>
);
}
// ========================================
// 드래그 가능한 컴포넌트 아이템
// ========================================
interface DraggableComponentItemProps {
type: PopComponentType;
label: string;
icon: React.ElementType;
description: string;
}
function DraggableComponentItem({
type,
label,
icon: Icon,
description,
}: DraggableComponentItemProps) {
const [{ isDragging }, drag] = useDrag(() => ({
type: DND_ITEM_TYPES.COMPONENT,
item: { type: DND_ITEM_TYPES.COMPONENT, componentType: type } as DragItemComponent,
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
}));
return (
<div
ref={drag}
className={cn(
"flex cursor-grab items-start gap-3 rounded-lg border p-3 transition-all",
"hover:bg-accent hover:text-accent-foreground",
isDragging && "opacity-50 ring-2 ring-primary"
)}
>
<GripVertical className="mt-0.5 h-4 w-4 text-gray-400" />
<Icon className="mt-0.5 h-4 w-4 shrink-0" />
<div className="flex-1 space-y-1">
<p className="text-sm font-medium leading-none">{label}</p>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
</div>
);
}
// ========================================
// v2 섹션 편집기
// ========================================
interface SectionEditorV2Props {
section: PopSectionDefinition;
position: { col: number; row: number; colSpan: number; rowSpan: number };
activeModeKey: PopLayoutModeKey;
onUpdateDefinition: (updates: Partial<PopSectionDefinition>) => void;
onDelete: () => void;
}
function SectionEditorV2({
section,
position,
activeModeKey,
onUpdateDefinition,
onDelete,
}: SectionEditorV2Props) {
const [isGridOpen, setIsGridOpen] = useState(true);
return (
<div className="space-y-4">
{/* 섹션 기본 정보 */}
<div className="flex items-center justify-between">
<span className="text-sm font-medium"> </span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:bg-destructive/10"
onClick={onDelete}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
{/* 라벨 */}
<div className="space-y-2">
<Label className="text-xs"> ()</Label>
<Input
value={section.label || ""}
onChange={(e) => onUpdateDefinition({ label: e.target.value })}
placeholder="섹션 이름"
className="h-8 text-xs"
/>
<p className="text-[10px] text-muted-foreground">
4
</p>
</div>
{/* 현재 모드 위치 (읽기 전용 - 드래그로 조정) */}
<Collapsible open={isGridOpen} onOpenChange={setIsGridOpen}>
<CollapsibleTrigger className="flex w-full items-center justify-between py-2 text-sm font-medium">
<ChevronDown
className={cn(
"h-4 w-4 transition-transform",
isGridOpen && "rotate-180"
)}
/>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-3 pt-2">
<div className="rounded-lg bg-muted p-3">
<p className="mb-2 text-xs font-medium">{getModeLabel(activeModeKey)}</p>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="flex justify-between">
<span className="text-muted-foreground"> :</span>
<span className="font-medium">{position.col}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"> :</span>
<span className="font-medium">{position.row}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"> :</span>
<span className="font-medium">{position.colSpan}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"> :</span>
<span className="font-medium">{position.rowSpan}</span>
</div>
</div>
</div>
<p className="text-[10px] text-muted-foreground">
/ .
(/) .
</p>
</CollapsibleContent>
</Collapsible>
{/* 내부 그리드 설정 */}
<div className="space-y-3">
<h4 className="text-sm font-medium"> ()</h4>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
value={String(section.innerGrid.columns)}
onValueChange={(v) =>
onUpdateDefinition({
innerGrid: { ...section.innerGrid, columns: parseInt(v) },
})
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1</SelectItem>
<SelectItem value="2">2</SelectItem>
<SelectItem value="3">3</SelectItem>
<SelectItem value="4">4</SelectItem>
<SelectItem value="6">6</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
value={String(section.innerGrid.rows)}
onValueChange={(v) =>
onUpdateDefinition({
innerGrid: { ...section.innerGrid, rows: parseInt(v) },
})
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1</SelectItem>
<SelectItem value="2">2</SelectItem>
<SelectItem value="3">3</SelectItem>
<SelectItem value="4">4</SelectItem>
<SelectItem value="5">5</SelectItem>
<SelectItem value="6">6</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<p className="text-[10px] text-muted-foreground">
4
</p>
</div>
{/* 컴포넌트 목록 */}
<div className="space-y-2">
<h4 className="text-sm font-medium">
({section.componentIds.length})
</h4>
{section.componentIds.length > 0 ? (
<div className="space-y-1">
{section.componentIds.map((compId) => (
<div
key={compId}
className="rounded border bg-muted/50 px-2 py-1 text-xs"
>
{compId}
</div>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">
</p>
)}
</div>
</div>
);
}