ERP-node/frontend/components/screen/LayerManagerPanel.tsx

406 lines
13 KiB
TypeScript
Raw Normal View History

import React, { useState, useMemo, useCallback } from "react";
import { useLayer } from "@/contexts/LayerContext";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { Badge } from "@/components/ui/badge";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Eye,
EyeOff,
Lock,
Unlock,
Plus,
Trash2,
GripVertical,
Layers,
SplitSquareVertical,
PanelRight,
ChevronDown,
ChevronRight,
Settings2,
Zap,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { LayerType, LayerDefinition, ComponentData, LayerCondition } from "@/types/screen-management";
import { LayerConditionPanel } from "./LayerConditionPanel";
// 레이어 타입별 아이콘
const getLayerTypeIcon = (type: LayerType) => {
switch (type) {
case "base":
return <Layers className="h-3 w-3" />;
case "conditional":
return <SplitSquareVertical className="h-3 w-3" />;
case "modal":
return <Settings2 className="h-3 w-3" />;
case "drawer":
return <PanelRight className="h-3 w-3" />;
default:
return <Layers className="h-3 w-3" />;
}
};
// 레이어 타입별 라벨
function getLayerTypeLabel(type: LayerType): string {
switch (type) {
case "base":
return "기본";
case "conditional":
return "조건부";
case "modal":
return "모달";
case "drawer":
return "드로어";
default:
return type;
}
}
// 레이어 타입별 색상
function getLayerTypeColor(type: LayerType): string {
switch (type) {
case "base":
return "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300";
case "conditional":
return "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300";
case "modal":
return "bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300";
case "drawer":
return "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300";
default:
return "bg-gray-100 text-gray-700 dark:bg-gray-900 dark:text-gray-300";
}
}
interface LayerItemProps {
layer: LayerDefinition;
isActive: boolean;
componentCount: number; // 실제 컴포넌트 수 (layout.components 기반)
allComponents: ComponentData[]; // 조건 설정에 필요한 전체 컴포넌트
onSelect: () => void;
onToggleVisibility: () => void;
onToggleLock: () => void;
onRemove: () => void;
onUpdateName: (name: string) => void;
onUpdateCondition: (condition: LayerCondition | undefined) => void;
}
const LayerItem: React.FC<LayerItemProps> = ({
layer,
isActive,
componentCount,
allComponents,
onSelect,
onToggleVisibility,
onToggleLock,
onRemove,
onUpdateName,
onUpdateCondition,
}) => {
const [isEditing, setIsEditing] = useState(false);
const [isConditionOpen, setIsConditionOpen] = useState(false);
// 조건부 레이어인지 확인
const isConditionalLayer = layer.type === "conditional";
// 조건 설정 여부
const hasCondition = !!layer.condition;
return (
<div className="space-y-0">
{/* 레이어 메인 영역 */}
<div
className={cn(
"flex items-center gap-2 rounded-md border p-2 text-sm transition-all cursor-pointer",
isActive
? "border-primary bg-primary/5 shadow-sm"
: "hover:bg-muted border-transparent",
!layer.isVisible && "opacity-50",
isConditionOpen && "rounded-b-none border-b-0",
)}
onClick={onSelect}
>
{/* 드래그 핸들 */}
<GripVertical className="text-muted-foreground h-4 w-4 cursor-grab flex-shrink-0" />
{/* 레이어 정보 */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
{/* 레이어 타입 아이콘 */}
<span className={cn("flex-shrink-0", getLayerTypeColor(layer.type), "p-1 rounded")}>
{getLayerTypeIcon(layer.type)}
</span>
{/* 레이어 이름 */}
{isEditing ? (
<input
type="text"
value={layer.name}
onChange={(e) => onUpdateName(e.target.value)}
onBlur={() => setIsEditing(false)}
onKeyDown={(e) => {
if (e.key === "Enter") setIsEditing(false);
}}
className="flex-1 bg-transparent outline-none border-b border-primary text-sm"
autoFocus
onClick={(e) => e.stopPropagation()}
/>
) : (
<span
className="flex-1 truncate font-medium"
onDoubleClick={(e) => {
e.stopPropagation();
setIsEditing(true);
}}
>
{layer.name}
</span>
)}
</div>
{/* 레이어 메타 정보 */}
<div className="flex items-center gap-2 mt-0.5">
<Badge variant="outline" className="text-[10px] px-1 py-0 h-4">
{getLayerTypeLabel(layer.type)}
</Badge>
<span className="text-muted-foreground text-[10px]">
{componentCount}
</span>
{/* 조건 설정됨 표시 */}
{hasCondition && (
<Badge variant="secondary" className="text-[10px] px-1 py-0 h-4 gap-0.5">
<Zap className="h-2.5 w-2.5" />
</Badge>
)}
</div>
</div>
{/* 액션 버튼들 */}
<div className="flex items-center gap-0.5 flex-shrink-0">
{/* 조건부 레이어일 때 조건 설정 버튼 */}
{isConditionalLayer && (
<Button
variant="ghost"
size="icon"
className={cn(
"h-6 w-6",
hasCondition && "text-amber-600"
)}
title="조건 설정"
onClick={(e) => {
e.stopPropagation();
setIsConditionOpen(!isConditionOpen);
}}
>
{isConditionOpen ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
title={layer.isVisible ? "레이어 숨기기" : "레이어 표시"}
onClick={(e) => {
e.stopPropagation();
onToggleVisibility();
}}
>
{layer.isVisible ? (
<Eye className="h-3.5 w-3.5" />
) : (
<EyeOff className="text-muted-foreground h-3.5 w-3.5" />
)}
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
title={layer.isLocked ? "편집 잠금 해제" : "편집 잠금"}
onClick={(e) => {
e.stopPropagation();
onToggleLock();
}}
>
{layer.isLocked ? (
<Lock className="text-destructive h-3.5 w-3.5" />
) : (
<Unlock className="text-muted-foreground h-3.5 w-3.5" />
)}
</Button>
{layer.type !== "base" && (
<Button
variant="ghost"
size="icon"
className="hover:text-destructive h-6 w-6"
title="레이어 삭제"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
{/* 조건 설정 패널 (조건부 레이어만) */}
{isConditionalLayer && isConditionOpen && (
<div className={cn(
"border border-t-0 rounded-b-md bg-muted/30",
isActive ? "border-primary" : "border-border"
)}>
<LayerConditionPanel
layer={layer}
components={allComponents}
onUpdateCondition={onUpdateCondition}
onClose={() => setIsConditionOpen(false)}
/>
</div>
)}
</div>
);
};
interface LayerManagerPanelProps {
components?: ComponentData[]; // layout.components를 전달받음
}
export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({ components = [] }) => {
const {
layers,
activeLayerId,
setActiveLayerId,
addLayer,
removeLayer,
toggleLayerVisibility,
toggleLayerLock,
updateLayer,
} = useLayer();
// 레이어 조건 업데이트 핸들러
const handleUpdateCondition = useCallback((layerId: string, condition: LayerCondition | undefined) => {
updateLayer(layerId, { condition });
}, [updateLayer]);
// 🆕 각 레이어별 컴포넌트 수 계산 (layout.components 기반)
const componentCountByLayer = useMemo(() => {
const counts: Record<string, number> = {};
// 모든 레이어를 0으로 초기화
layers.forEach(layer => {
counts[layer.id] = 0;
});
// layout.components에서 layerId별로 카운트
components.forEach(comp => {
const layerId = comp.layerId || "default-layer";
if (counts[layerId] !== undefined) {
counts[layerId]++;
} else {
// layerId가 존재하지 않는 레이어인 경우 default-layer로 카운트
if (counts["default-layer"] !== undefined) {
counts["default-layer"]++;
}
}
});
return counts;
}, [components, layers]);
return (
<div className="bg-background flex h-full flex-col">
{/* 헤더 */}
<div className="flex items-center justify-between border-b px-3 py-2">
<div className="flex items-center gap-2">
<Layers className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-semibold"></h3>
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
{layers.length}
</Badge>
</div>
{/* 레이어 추가 드롭다운 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-7 px-2 gap-1">
<Plus className="h-3.5 w-3.5" />
<ChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => addLayer("conditional", "조건부 레이어")}>
<SplitSquareVertical className="h-4 w-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => addLayer("modal", "모달 레이어")}>
<Settings2 className="h-4 w-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => addLayer("drawer", "드로어 레이어")}>
<PanelRight className="h-4 w-4 mr-2" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* 레이어 목록 */}
<ScrollArea className="flex-1">
<div className="space-y-1 p-2">
{layers.length === 0 ? (
<div className="text-center text-muted-foreground text-sm py-8">
.
<br />
<span className="text-xs"> + .</span>
</div>
) : (
layers
.slice()
.reverse() // 상위 레이어가 위에 표시
.map((layer) => (
<LayerItem
key={layer.id}
layer={layer}
isActive={activeLayerId === layer.id}
componentCount={componentCountByLayer[layer.id] || 0}
allComponents={components}
onSelect={() => setActiveLayerId(layer.id)}
onToggleVisibility={() => toggleLayerVisibility(layer.id)}
onToggleLock={() => toggleLayerLock(layer.id)}
onRemove={() => removeLayer(layer.id)}
onUpdateName={(name) => updateLayer(layer.id, { name })}
onUpdateCondition={(condition) => handleUpdateCondition(layer.id, condition)}
/>
))
)}
</div>
</ScrollArea>
{/* 도움말 */}
<div className="border-t px-3 py-2 text-[10px] text-muted-foreground">
<p>더블클릭: 이름 | 드래그: 순서 </p>
</div>
</div>
);
};