Files
vexplor/frontend/components/screen/LayerManagerPanel.tsx

479 lines
21 KiB
TypeScript

import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Badge } from "@/components/ui/badge";
import {
Plus,
Trash2,
GripVertical,
Layers,
SplitSquareVertical,
ChevronDown,
ChevronRight,
Zap,
Loader2,
Box,
Settings2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { screenApi } from "@/lib/api/screen";
import { toast } from "sonner";
import { ComponentData, ConditionalZone } from "@/types/screen-management";
// DB 레이어 타입
interface DBLayer {
layer_id: number;
layer_name: string;
condition_config: any;
component_count: number;
updated_at: string;
}
interface LayerManagerPanelProps {
screenId: number | null;
activeLayerId: number; // 현재 활성 레이어 ID (DB layer_id)
onLayerChange: (layerId: number) => void; // 레이어 전환
components?: ComponentData[]; // 현재 활성 레이어의 컴포넌트 (폴백용)
zones?: ConditionalZone[]; // Zone 목록 (ScreenDesigner에서 전달)
onZonesChange?: (zones: ConditionalZone[]) => void; // Zone 목록 변경 콜백
}
export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({
screenId,
activeLayerId,
onLayerChange,
components = [],
zones: externalZones,
onZonesChange,
}) => {
const [layers, setLayers] = useState<DBLayer[]>([]);
const [isLoading, setIsLoading] = useState(false);
// 펼침/접힘 상태: zone_id별
const [expandedZones, setExpandedZones] = useState<Set<number>>(new Set());
// Zone에 레이어 추가 시 조건값 입력 상태
const [addingToZoneId, setAddingToZoneId] = useState<number | null>(null);
const [newConditionValue, setNewConditionValue] = useState("");
// Zone 트리거 설정 열기 상태
const [triggerEditZoneId, setTriggerEditZoneId] = useState<number | null>(null);
// 기본 레이어 컴포넌트 (트리거 선택용)
const [baseLayerComponents, setBaseLayerComponents] = useState<ComponentData[]>([]);
const zones = externalZones || [];
// 레이어 목록 로드
const loadLayers = useCallback(async () => {
if (!screenId) return;
setIsLoading(true);
try {
const data = await screenApi.getScreenLayers(screenId);
setLayers(data);
} catch (error) {
console.error("레이어 목록 로드 실패:", error);
} finally {
setIsLoading(false);
}
}, [screenId]);
// 기본 레이어 컴포넌트 로드
const loadBaseLayerComponents = useCallback(async () => {
if (!screenId) return;
try {
const data = await screenApi.getLayerLayout(screenId, 1);
if (data?.components) {
setBaseLayerComponents(data.components as ComponentData[]);
}
} catch {
setBaseLayerComponents(components);
}
}, [screenId, components]);
useEffect(() => {
loadLayers();
loadBaseLayerComponents();
}, [loadLayers, loadBaseLayerComponents]);
// Zone별 레이어 그룹핑
const getLayersForZone = useCallback((zoneId: number): DBLayer[] => {
return layers.filter(l => {
const cc = l.condition_config;
return cc && cc.zone_id === zoneId;
});
}, [layers]);
// Zone에 속하지 않는 조건부 레이어 (레거시)
const orphanLayers = layers.filter(l => {
if (l.layer_id === 1) return false;
const cc = l.condition_config;
return !cc || !cc.zone_id;
});
// 기본 레이어
const baseLayer = layers.find(l => l.layer_id === 1);
// Zone에 레이어 추가
const handleAddLayerToZone = useCallback(async (zoneId: number) => {
if (!screenId || !newConditionValue.trim()) return;
try {
const result = await screenApi.addLayerToZone(
screenId, zoneId, newConditionValue.trim(),
`레이어 (${newConditionValue.trim()})`,
);
toast.success(`레이어가 Zone에 추가되었습니다. (ID: ${result.layerId})`);
setAddingToZoneId(null);
setNewConditionValue("");
await loadLayers();
onLayerChange(result.layerId);
} catch (error) {
console.error("Zone 레이어 추가 실패:", error);
toast.error("레이어 추가에 실패했습니다.");
}
}, [screenId, newConditionValue, loadLayers, onLayerChange]);
// 레이어 삭제
const handleDeleteLayer = useCallback(async (layerId: number) => {
if (!screenId || layerId === 1) return;
try {
await screenApi.deleteLayer(screenId, layerId);
toast.success("레이어가 삭제되었습니다.");
await loadLayers();
if (activeLayerId === layerId) onLayerChange(1);
} catch (error) {
console.error("레이어 삭제 실패:", error);
toast.error("레이어 삭제에 실패했습니다.");
}
}, [screenId, activeLayerId, loadLayers, onLayerChange]);
// Zone 삭제
const handleDeleteZone = useCallback(async (zoneId: number) => {
if (!screenId) return;
try {
await screenApi.deleteZone(zoneId);
toast.success("조건부 영역이 삭제되었습니다.");
// Zone 목록 새로고침
const loadedZones = await screenApi.getScreenZones(screenId);
onZonesChange?.(loadedZones);
await loadLayers();
onLayerChange(1);
} catch (error) {
console.error("Zone 삭제 실패:", error);
toast.error("Zone 삭제에 실패했습니다.");
}
}, [screenId, loadLayers, onLayerChange, onZonesChange]);
// Zone 트리거 컴포넌트 업데이트
const handleUpdateZoneTrigger = useCallback(async (zoneId: number, triggerComponentId: string, operator: string = "eq") => {
try {
await screenApi.updateZone(zoneId, {
trigger_component_id: triggerComponentId,
trigger_operator: operator,
});
const loadedZones = await screenApi.getScreenZones(screenId!);
onZonesChange?.(loadedZones);
toast.success("트리거가 설정되었습니다.");
} catch (error) {
console.error("Zone 트리거 업데이트 실패:", error);
toast.error("트리거 설정에 실패했습니다.");
}
}, [screenId, onZonesChange]);
// Zone 접힘/펼침 토글
const toggleZone = (zoneId: number) => {
setExpandedZones(prev => {
const next = new Set(prev);
next.has(zoneId) ? next.delete(zoneId) : next.add(zoneId);
return next;
});
};
// 트리거로 사용 가능한 컴포넌트 (select, combobox 등)
const triggerableComponents = baseLayerComponents.filter(c =>
["select", "combobox", "radio-group"].some(t => c.componentType?.includes(t))
);
return (
<div className="flex h-full flex-col bg-background">
{/* 헤더 */}
<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="px-1.5 py-0 text-[10px]">
{layers.length}
</Badge>
</div>
</div>
{/* 레이어 + Zone 목록 */}
<ScrollArea className="flex-1">
<div className="space-y-1 p-2">
{isLoading ? (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<span className="text-sm"> ...</span>
</div>
) : (
<>
{/* 기본 레이어 */}
{baseLayer && (
<div
className={cn(
"flex cursor-pointer items-center gap-2 rounded-md border p-2 text-sm transition-all",
activeLayerId === 1
? "border-primary bg-primary/5 shadow-sm"
: "border-transparent hover:bg-muted",
)}
onClick={() => onLayerChange(1)}
>
<span className="shrink-0 rounded bg-blue-100 p-1 text-blue-700 dark:bg-blue-900 dark:text-blue-300">
<Layers className="h-3 w-3" />
</span>
<div className="min-w-0 flex-1">
<span className="truncate font-medium">{baseLayer.layer_name}</span>
<div className="flex items-center gap-2">
<Badge variant="outline" className="h-4 px-1 py-0 text-[10px]"></Badge>
<span className="text-[10px] text-muted-foreground">{baseLayer.component_count} </span>
</div>
</div>
</div>
)}
{/* 조건부 영역(Zone) 목록 */}
{zones.map((zone) => {
const zoneLayers = getLayersForZone(zone.zone_id);
const isExpanded = expandedZones.has(zone.zone_id);
const isTriggerEdit = triggerEditZoneId === zone.zone_id;
return (
<div key={`zone-${zone.zone_id}`} className="space-y-0">
{/* Zone 헤더 */}
<div
className={cn(
"flex cursor-pointer items-center gap-2 rounded-md border p-2 text-sm transition-all",
"border-amber-200 bg-amber-50/50 hover:bg-amber-100/50 dark:border-amber-800 dark:bg-amber-950/20",
isExpanded && "rounded-b-none border-b-0",
)}
onClick={() => toggleZone(zone.zone_id)}
>
{isExpanded ? (
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-amber-600" />
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-amber-600" />
)}
<Box className="h-3.5 w-3.5 shrink-0 text-amber-600" />
<div className="min-w-0 flex-1">
<span className="truncate font-medium text-amber-800 dark:text-amber-300">{zone.zone_name}</span>
<div className="flex items-center gap-2">
<Badge variant="outline" className="h-4 px-1 py-0 text-[10px] border-amber-300 text-amber-700">
Zone
</Badge>
<span className="text-[10px] text-muted-foreground">
{zoneLayers.length} | {zone.width}x{zone.height}
</span>
{zone.trigger_component_id && (
<Badge variant="secondary" className="h-4 gap-0.5 px-1 py-0 text-[10px]">
<Zap className="h-2.5 w-2.5" />
</Badge>
)}
</div>
</div>
{/* Zone 액션 버튼 */}
<div className="flex shrink-0 items-center gap-0.5">
<Button
variant="ghost" size="icon"
className="h-6 w-6 text-amber-600 hover:text-amber-800"
title="트리거 설정"
onClick={(e) => { e.stopPropagation(); setTriggerEditZoneId(isTriggerEdit ? null : zone.zone_id); }}
>
<Settings2 className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost" size="icon"
className="h-6 w-6 hover:text-destructive"
title="Zone 삭제"
onClick={(e) => { e.stopPropagation(); handleDeleteZone(zone.zone_id); }}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{/* 펼쳐진 Zone 내용 */}
{isExpanded && (
<div className={cn(
"rounded-b-md border border-t-0 border-amber-200 bg-amber-50/20 p-2 space-y-1",
"dark:border-amber-800 dark:bg-amber-950/10",
)}>
{/* 트리거 설정 패널 */}
{isTriggerEdit && (
<div className="mb-2 rounded border bg-background p-2 space-y-2">
<p className="text-[10px] font-medium text-muted-foreground"> </p>
{triggerableComponents.length === 0 ? (
<p className="text-[10px] text-muted-foreground"> Select/Combobox/Radio .</p>
) : (
<div className="space-y-1">
{triggerableComponents.map(c => (
<button
key={c.id}
className={cn(
"w-full text-left rounded px-2 py-1 text-[11px] transition-colors",
zone.trigger_component_id === c.id
? "bg-primary/10 text-primary font-medium"
: "hover:bg-muted",
)}
onClick={() => handleUpdateZoneTrigger(zone.zone_id, c.id!)}
>
{c.componentConfig?.label || c.id} ({c.componentType})
</button>
))}
</div>
)}
</div>
)}
{/* Zone 소속 레이어 목록 */}
{zoneLayers.map((layer) => {
const isActive = activeLayerId === layer.layer_id;
return (
<div
key={layer.layer_id}
className={cn(
"flex cursor-pointer items-center gap-2 rounded-md border p-1.5 text-sm transition-all",
isActive
? "border-primary bg-primary/5 shadow-sm"
: "border-transparent hover:bg-background",
)}
onClick={() => onLayerChange(layer.layer_id)}
>
<SplitSquareVertical className="h-3 w-3 shrink-0 text-amber-600" />
<div className="min-w-0 flex-1">
<span className="text-xs font-medium truncate">{layer.layer_name}</span>
<div className="flex items-center gap-2">
<span className="text-[10px] text-muted-foreground">
: {layer.condition_config?.condition_value || "미설정"}
</span>
<span className="text-[10px] text-muted-foreground">
| {layer.component_count}
</span>
</div>
</div>
<Button
variant="ghost" size="icon"
className="h-5 w-5 hover:text-destructive"
title="레이어 삭제"
onClick={(e) => { e.stopPropagation(); handleDeleteLayer(layer.layer_id); }}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
);
})}
{/* 레이어 추가 */}
{addingToZoneId === zone.zone_id ? (
<div className="flex items-center gap-1 rounded border bg-background p-1.5">
<Input
value={newConditionValue}
onChange={(e) => setNewConditionValue(e.target.value)}
placeholder="조건값 입력 (예: 옵션1)"
className="h-6 text-[11px] flex-1"
autoFocus
onKeyDown={(e) => { if (e.key === "Enter") handleAddLayerToZone(zone.zone_id); }}
/>
<Button
variant="default" size="sm"
className="h-6 px-2 text-[10px]"
onClick={() => handleAddLayerToZone(zone.zone_id)}
disabled={!newConditionValue.trim()}
>
</Button>
<Button
variant="ghost" size="sm"
className="h-6 px-1 text-[10px]"
onClick={() => { setAddingToZoneId(null); setNewConditionValue(""); }}
>
</Button>
</div>
) : (
<Button
variant="outline" size="sm"
className="h-6 w-full gap-1 text-[10px] border-dashed"
onClick={() => setAddingToZoneId(zone.zone_id)}
>
<Plus className="h-3 w-3" />
</Button>
)}
</div>
)}
</div>
);
})}
{/* 고아 레이어 (Zone에 소속되지 않은 조건부 레이어) */}
{orphanLayers.length > 0 && (
<div className="mt-2 space-y-1">
<p className="text-[10px] font-medium text-muted-foreground px-1">Zone </p>
{orphanLayers.map((layer) => {
const isActive = activeLayerId === layer.layer_id;
return (
<div
key={layer.layer_id}
className={cn(
"flex cursor-pointer items-center gap-2 rounded-md border p-2 text-sm transition-all",
isActive
? "border-primary bg-primary/5 shadow-sm"
: "border-transparent hover:bg-muted",
)}
onClick={() => onLayerChange(layer.layer_id)}
>
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-muted-foreground" />
<div className="min-w-0 flex-1">
<span className="truncate font-medium">{layer.layer_name}</span>
<div className="flex items-center gap-2">
<Badge variant="outline" className="h-4 px-1 py-0 text-[10px]"></Badge>
<span className="text-[10px] text-muted-foreground">{layer.component_count}</span>
</div>
</div>
<Button
variant="ghost" size="icon"
className="h-6 w-6 hover:text-destructive"
onClick={(e) => { e.stopPropagation(); handleDeleteLayer(layer.layer_id); }}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
);
})}
</div>
)}
</>
)}
</div>
</ScrollArea>
{/* Zone 생성 드래그 영역 */}
<div className="border-t px-3 py-2 space-y-1">
<div
className="flex cursor-grab items-center gap-2 rounded border border-dashed border-amber-300 bg-amber-50/50 px-2 py-1.5 text-xs text-amber-700 dark:border-amber-700 dark:bg-amber-950/20 dark:text-amber-400"
draggable
onDragStart={(e) => {
e.dataTransfer.setData("application/json", JSON.stringify({ type: "create-zone" }));
e.dataTransfer.effectAllowed = "copy";
}}
>
<GripVertical className="h-3.5 w-3.5" />
<Box className="h-3.5 w-3.5" />
<span> ( )</span>
</div>
<p className="text-[10px] text-muted-foreground">
Zone을 , Zone
</p>
</div>
</div>
);
};