Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node
This commit is contained in:
@@ -284,59 +284,38 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||
});
|
||||
}, [finalFormData, layers, allComponents, handleLayerAction]);
|
||||
|
||||
// 🆕 모든 조건부 레이어의 displayRegion 정보 (활성/비활성 모두)
|
||||
const conditionalRegionInfos = useMemo(() => {
|
||||
return layers
|
||||
.filter((layer) => layer.type === "conditional" && layer.displayRegion)
|
||||
.map((layer) => ({
|
||||
layerId: layer.id,
|
||||
region: layer.displayRegion!,
|
||||
isActive: activeLayerIds.includes(layer.id),
|
||||
}))
|
||||
.sort((a, b) => a.region.y - b.region.y); // Y 좌표 기준 정렬
|
||||
}, [layers, activeLayerIds]);
|
||||
|
||||
// 🆕 접힌 조건부 영역 (비활성 상태인 것만)
|
||||
const collapsedRegions = useMemo(() => {
|
||||
return conditionalRegionInfos
|
||||
.filter((info) => !info.isActive)
|
||||
.map((info) => info.region);
|
||||
}, [conditionalRegionInfos]);
|
||||
|
||||
// 🆕 Y 오프셋 계산 함수 (다중 조건부 영역 지원)
|
||||
// 컴포넌트의 원래 Y 좌표보다 위에 있는 접힌 영역들의 높이를 누적하여 빼줌
|
||||
// 겹치는 영역은 중복 계산하지 않도록 병합(merge) 처리
|
||||
// 🆕 Zone 기반 Y 오프셋 계산 (단순화)
|
||||
// Zone 단위로 활성 여부만 판단 → merge 로직 불필요
|
||||
const calculateYOffset = useCallback((componentY: number): number => {
|
||||
if (collapsedRegions.length === 0) return 0;
|
||||
|
||||
// 컴포넌트보다 위에 있는 접힌 영역만 필터링
|
||||
const relevantRegions = collapsedRegions.filter(
|
||||
(region) => region.y + region.height <= componentY
|
||||
);
|
||||
|
||||
if (relevantRegions.length === 0) return 0;
|
||||
|
||||
// 겹치는 영역 병합 (다중 조건부 영역이 겹치는 경우 중복 높이 제거)
|
||||
const mergedRegions: { y: number; bottom: number }[] = [];
|
||||
for (const region of relevantRegions) {
|
||||
const bottom = region.y + region.height;
|
||||
if (mergedRegions.length === 0) {
|
||||
mergedRegions.push({ y: region.y, bottom });
|
||||
} else {
|
||||
const last = mergedRegions[mergedRegions.length - 1];
|
||||
if (region.y <= last.bottom) {
|
||||
// 겹치는 영역 - 병합 (더 큰 하단으로 확장)
|
||||
last.bottom = Math.max(last.bottom, bottom);
|
||||
} else {
|
||||
// 겹치지 않는 영역 - 새로 추가
|
||||
mergedRegions.push({ y: region.y, bottom });
|
||||
}
|
||||
// layers에서 Zone 정보 추출 (displayRegion이 있는 레이어들을 zone 단위로 그룹핑)
|
||||
const zoneMap = new Map<number, { y: number; height: number; hasActive: boolean }>();
|
||||
|
||||
for (const layer of layers) {
|
||||
if (layer.type !== "conditional" || !layer.zoneId || !layer.displayRegion) continue;
|
||||
const zid = layer.zoneId;
|
||||
if (!zoneMap.has(zid)) {
|
||||
zoneMap.set(zid, {
|
||||
y: layer.displayRegion.y,
|
||||
height: layer.displayRegion.height,
|
||||
hasActive: false,
|
||||
});
|
||||
}
|
||||
if (activeLayerIds.includes(layer.id)) {
|
||||
zoneMap.get(zid)!.hasActive = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 병합된 영역들의 높이 합산
|
||||
return mergedRegions.reduce((offset, merged) => offset + (merged.bottom - merged.y), 0);
|
||||
}, [collapsedRegions]);
|
||||
let totalOffset = 0;
|
||||
for (const [, zone] of zoneMap) {
|
||||
const zoneBottom = zone.y + zone.height;
|
||||
// 컴포넌트가 Zone 하단보다 아래에 있고, Zone에 활성 레이어가 없으면 접힘
|
||||
if (componentY >= zoneBottom && !zone.hasActive) {
|
||||
totalOffset += zone.height;
|
||||
}
|
||||
}
|
||||
|
||||
return totalOffset;
|
||||
}, [layers, activeLayerIds]);
|
||||
|
||||
// 개선된 검증 시스템 (선택적 활성화)
|
||||
const enhancedValidation = enableEnhancedValidation && screenInfo && tableColumns.length > 0
|
||||
@@ -2378,7 +2357,48 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||
);
|
||||
}
|
||||
|
||||
// 일반/조건부 레이어 (base, conditional)
|
||||
// 조건부 레이어: Zone 기반 영역 내에 컴포넌트 렌더링
|
||||
if (layer.type === "conditional" && layer.displayRegion) {
|
||||
const region = layer.displayRegion;
|
||||
return (
|
||||
<div
|
||||
key={layer.id}
|
||||
className="pointer-events-none absolute"
|
||||
style={{
|
||||
left: `${region.x}px`,
|
||||
top: `${region.y}px`,
|
||||
width: `${region.width}px`,
|
||||
height: `${region.height}px`,
|
||||
zIndex: layer.zIndex,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{layer.components.map((comp) => (
|
||||
<div
|
||||
key={comp.id}
|
||||
className="pointer-events-auto absolute"
|
||||
style={{
|
||||
left: `${comp.position.x}px`,
|
||||
top: `${comp.position.y}px`,
|
||||
width: comp.style?.width || `${comp.size.width}px`,
|
||||
height: comp.style?.height || `${comp.size.height}px`,
|
||||
zIndex: comp.position.z || 1,
|
||||
}}
|
||||
>
|
||||
<InteractiveScreenViewer
|
||||
component={comp}
|
||||
allComponents={allLayerComponents}
|
||||
formData={externalFormData}
|
||||
onFormDataChange={onFormDataChange}
|
||||
screenInfo={screenInfo}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 기본/기타 레이어 (base)
|
||||
return (
|
||||
<div
|
||||
key={layer.id}
|
||||
@@ -2386,7 +2406,6 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||
style={{ zIndex: layer.zIndex }}
|
||||
>
|
||||
{layer.components.map((comp) => {
|
||||
// 기본 레이어 컴포넌트만 Y 오프셋 적용 (조건부 레이어 컴포넌트는 자체 영역 내 표시)
|
||||
const yOffset = layer.type === "base" ? calculateYOffset(comp.position.y) : 0;
|
||||
const adjustedY = comp.position.y - yOffset;
|
||||
|
||||
@@ -2414,7 +2433,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}, [activeLayerIds, handleLayerAction, externalFormData, onFormDataChange, screenInfo, calculateYOffset, allLayerComponents]);
|
||||
}, [activeLayerIds, handleLayerAction, externalFormData, onFormDataChange, screenInfo, calculateYOffset, allLayerComponents, layers]);
|
||||
|
||||
return (
|
||||
<SplitPanelProvider>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 {
|
||||
@@ -12,13 +13,13 @@ import {
|
||||
ChevronRight,
|
||||
Zap,
|
||||
Loader2,
|
||||
Box,
|
||||
Settings2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
import { convertV2ToLegacy } from "@/lib/utils/layoutV2Converter";
|
||||
import { toast } from "sonner";
|
||||
import { LayerConditionPanel } from "./LayerConditionPanel";
|
||||
import { ComponentData, LayerCondition, DisplayRegion } from "@/types/screen-management";
|
||||
import { ComponentData, ConditionalZone } from "@/types/screen-management";
|
||||
|
||||
// DB 레이어 타입
|
||||
interface DBLayer {
|
||||
@@ -34,6 +35,8 @@ interface LayerManagerPanelProps {
|
||||
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> = ({
|
||||
@@ -41,13 +44,23 @@ export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({
|
||||
activeLayerId,
|
||||
onLayerChange,
|
||||
components = [],
|
||||
zones: externalZones,
|
||||
onZonesChange,
|
||||
}) => {
|
||||
const [layers, setLayers] = useState<DBLayer[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [conditionOpenLayerId, setConditionOpenLayerId] = useState<number | null>(null);
|
||||
// 기본 레이어(layer_id=1)의 컴포넌트 (조건 설정 시 트리거 대상)
|
||||
// 펼침/접힘 상태: 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;
|
||||
@@ -62,60 +75,60 @@ export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({
|
||||
}
|
||||
}, [screenId]);
|
||||
|
||||
// 기본 레이어 컴포넌트 로드 (조건 설정 패널에서 트리거 컴포넌트 선택용)
|
||||
// 기본 레이어 컴포넌트 로드
|
||||
const loadBaseLayerComponents = useCallback(async () => {
|
||||
if (!screenId) return;
|
||||
try {
|
||||
const data = await screenApi.getLayerLayout(screenId, 1);
|
||||
if (data && data.components) {
|
||||
const legacy = convertV2ToLegacy(data);
|
||||
if (legacy) {
|
||||
setBaseLayerComponents(legacy.components as ComponentData[]);
|
||||
return;
|
||||
}
|
||||
if (data?.components) {
|
||||
setBaseLayerComponents(data.components as ComponentData[]);
|
||||
}
|
||||
setBaseLayerComponents([]);
|
||||
} catch {
|
||||
// 기본 레이어가 없거나 로드 실패 시 현재 컴포넌트 사용
|
||||
setBaseLayerComponents(components);
|
||||
}
|
||||
}, [screenId, components]);
|
||||
|
||||
useEffect(() => {
|
||||
loadLayers();
|
||||
}, [loadLayers]);
|
||||
loadBaseLayerComponents();
|
||||
}, [loadLayers, loadBaseLayerComponents]);
|
||||
|
||||
// 조건 설정 패널이 열릴 때 기본 레이어 컴포넌트 로드
|
||||
useEffect(() => {
|
||||
if (conditionOpenLayerId !== null) {
|
||||
loadBaseLayerComponents();
|
||||
}
|
||||
}, [conditionOpenLayerId, loadBaseLayerComponents]);
|
||||
// Zone별 레이어 그룹핑
|
||||
const getLayersForZone = useCallback((zoneId: number): DBLayer[] => {
|
||||
return layers.filter(l => {
|
||||
const cc = l.condition_config;
|
||||
return cc && cc.zone_id === zoneId;
|
||||
});
|
||||
}, [layers]);
|
||||
|
||||
// 새 레이어 추가
|
||||
const handleAddLayer = useCallback(async () => {
|
||||
if (!screenId) return;
|
||||
// 다음 layer_id 계산
|
||||
const maxLayerId = layers.length > 0 ? Math.max(...layers.map((l) => l.layer_id)) : 0;
|
||||
const newLayerId = maxLayerId + 1;
|
||||
// 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 {
|
||||
// 빈 레이아웃으로 새 레이어 저장
|
||||
await screenApi.saveLayoutV2(screenId, {
|
||||
version: "2.0",
|
||||
components: [],
|
||||
layerId: newLayerId,
|
||||
layerName: `조건부 레이어 ${newLayerId}`,
|
||||
});
|
||||
toast.success(`조건부 레이어 ${newLayerId}가 생성되었습니다.`);
|
||||
const result = await screenApi.addLayerToZone(
|
||||
screenId, zoneId, newConditionValue.trim(),
|
||||
`레이어 (${newConditionValue.trim()})`,
|
||||
);
|
||||
toast.success(`레이어가 Zone에 추가되었습니다. (ID: ${result.layerId})`);
|
||||
setAddingToZoneId(null);
|
||||
setNewConditionValue("");
|
||||
await loadLayers();
|
||||
// 새 레이어로 전환
|
||||
onLayerChange(newLayerId);
|
||||
onLayerChange(result.layerId);
|
||||
} catch (error) {
|
||||
console.error("레이어 추가 실패:", error);
|
||||
console.error("Zone 레이어 추가 실패:", error);
|
||||
toast.error("레이어 추가에 실패했습니다.");
|
||||
}
|
||||
}, [screenId, layers, loadLayers, onLayerChange]);
|
||||
}, [screenId, newConditionValue, loadLayers, onLayerChange]);
|
||||
|
||||
// 레이어 삭제
|
||||
const handleDeleteLayer = useCallback(async (layerId: number) => {
|
||||
@@ -124,42 +137,59 @@ export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({
|
||||
await screenApi.deleteLayer(screenId, layerId);
|
||||
toast.success("레이어가 삭제되었습니다.");
|
||||
await loadLayers();
|
||||
// 기본 레이어로 전환
|
||||
if (activeLayerId === layerId) {
|
||||
onLayerChange(1);
|
||||
}
|
||||
if (activeLayerId === layerId) onLayerChange(1);
|
||||
} catch (error) {
|
||||
console.error("레이어 삭제 실패:", error);
|
||||
toast.error("레이어 삭제에 실패했습니다.");
|
||||
}
|
||||
}, [screenId, activeLayerId, loadLayers, onLayerChange]);
|
||||
|
||||
// 조건 업데이트 (기존 condition_config의 displayRegion 보존)
|
||||
const handleUpdateCondition = useCallback(async (layerId: number, condition: LayerCondition | undefined) => {
|
||||
// Zone 삭제
|
||||
const handleDeleteZone = useCallback(async (zoneId: number) => {
|
||||
if (!screenId) return;
|
||||
try {
|
||||
// 기존 condition_config를 가져와서 displayRegion 보존
|
||||
const layerData = await screenApi.getLayerLayout(screenId, layerId);
|
||||
const existingCondition = layerData?.conditionConfig || {};
|
||||
const displayRegion = existingCondition.displayRegion;
|
||||
|
||||
let mergedCondition: any;
|
||||
if (condition) {
|
||||
// 조건 설정: 새 조건 + 기존 displayRegion 보존
|
||||
mergedCondition = { ...condition, ...(displayRegion ? { displayRegion } : {}) };
|
||||
} else {
|
||||
// 조건 삭제: displayRegion만 남기거나, 없으면 null
|
||||
mergedCondition = displayRegion ? { displayRegion } : null;
|
||||
}
|
||||
|
||||
await screenApi.updateLayerCondition(screenId, layerId, mergedCondition);
|
||||
toast.success("조건이 저장되었습니다.");
|
||||
await screenApi.deleteZone(zoneId);
|
||||
toast.success("조건부 영역이 삭제되었습니다.");
|
||||
// Zone 목록 새로고침
|
||||
const loadedZones = await screenApi.getScreenZones(screenId);
|
||||
onZonesChange?.(loadedZones);
|
||||
await loadLayers();
|
||||
onLayerChange(1);
|
||||
} catch (error) {
|
||||
console.error("조건 업데이트 실패:", error);
|
||||
toast.error("조건 저장에 실패했습니다.");
|
||||
console.error("Zone 삭제 실패:", error);
|
||||
toast.error("Zone 삭제에 실패했습니다.");
|
||||
}
|
||||
}, [screenId, loadLayers]);
|
||||
}, [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">
|
||||
@@ -172,19 +202,9 @@ export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({
|
||||
{layers.length}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1 px-2"
|
||||
onClick={handleAddLayer}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 레이어 목록 */}
|
||||
{/* 레이어 + Zone 목록 */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="space-y-1 p-2">
|
||||
{isLoading ? (
|
||||
@@ -192,146 +212,266 @@ export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<span className="text-sm">로딩 중...</span>
|
||||
</div>
|
||||
) : layers.length === 0 ? (
|
||||
<div className="space-y-2 py-4 text-center">
|
||||
<p className="text-sm text-muted-foreground">레이어를 로드하는 중...</p>
|
||||
<p className="text-[10px] text-muted-foreground">먼저 화면을 저장하면 기본 레이어가 생성됩니다.</p>
|
||||
</div>
|
||||
) : (
|
||||
layers
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((layer) => {
|
||||
const isActive = activeLayerId === layer.layer_id;
|
||||
const isBase = layer.layer_id === 1;
|
||||
const hasCondition = !!layer.condition_config;
|
||||
const isConditionOpen = conditionOpenLayerId === layer.layer_id;
|
||||
<>
|
||||
{/* 기본 레이어 */}
|
||||
{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={layer.layer_id} className="space-y-0">
|
||||
<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",
|
||||
isActive
|
||||
? "border-primary bg-primary/5 shadow-sm"
|
||||
: "border-transparent hover:bg-muted",
|
||||
isConditionOpen && "rounded-b-none border-b-0",
|
||||
"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={() => onLayerChange(layer.layer_id)}
|
||||
// 조건부 레이어를 캔버스로 드래그 (영역 배치용)
|
||||
draggable={!isBase}
|
||||
onDragStart={(e) => {
|
||||
if (isBase) return;
|
||||
e.dataTransfer.setData("application/json", JSON.stringify({
|
||||
type: "layer-region",
|
||||
layerId: layer.layer_id,
|
||||
layerName: layer.layer_name,
|
||||
}));
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
}}
|
||||
onClick={() => toggleZone(zone.zone_id)}
|
||||
>
|
||||
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-muted-foreground" />
|
||||
|
||||
{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">
|
||||
<span className={cn(
|
||||
"shrink-0 rounded p-1",
|
||||
isBase
|
||||
? "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300"
|
||||
: "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300",
|
||||
)}>
|
||||
{isBase ? <Layers className="h-3 w-3" /> : <SplitSquareVertical className="h-3 w-3" />}
|
||||
</span>
|
||||
<span className="flex-1 truncate font-medium">{layer.layer_name}</span>
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-2">
|
||||
<Badge variant="outline" className="h-4 px-1 py-0 text-[10px]">
|
||||
{isBase ? "기본" : "조건부"}
|
||||
<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">
|
||||
{layer.component_count}개 컴포넌트
|
||||
{zoneLayers.length}개 레이어 | {zone.width}x{zone.height}
|
||||
</span>
|
||||
{hasCondition && (
|
||||
{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" />
|
||||
조건
|
||||
<Zap className="h-2.5 w-2.5" /> 트리거
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
{/* Zone 액션 버튼 */}
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
{!isBase && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("h-6 w-6", hasCondition && "text-amber-600")}
|
||||
title="조건 설정"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConditionOpenLayerId(isConditionOpen ? null : layer.layer_id);
|
||||
}}
|
||||
>
|
||||
{isConditionOpen ? (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{!isBase && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 hover:text-destructive"
|
||||
title="레이어 삭제"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteLayer(layer.layer_id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<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>
|
||||
|
||||
{/* 조건 설정 패널 */}
|
||||
{!isBase && isConditionOpen && (
|
||||
{/* 펼쳐진 Zone 내용 */}
|
||||
{isExpanded && (
|
||||
<div className={cn(
|
||||
"rounded-b-md border border-t-0 bg-muted/30",
|
||||
isActive ? "border-primary" : "border-border",
|
||||
"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",
|
||||
)}>
|
||||
<LayerConditionPanel
|
||||
layer={{
|
||||
id: String(layer.layer_id),
|
||||
name: layer.layer_name,
|
||||
type: "conditional",
|
||||
zIndex: layer.layer_id,
|
||||
isVisible: true,
|
||||
isLocked: false,
|
||||
condition: layer.condition_config || undefined,
|
||||
components: [],
|
||||
}}
|
||||
components={baseLayerComponents}
|
||||
baseLayerComponents={baseLayerComponents}
|
||||
onUpdateCondition={(condition) => handleUpdateCondition(layer.layer_id, condition)}
|
||||
onUpdateDisplayRegion={() => {}}
|
||||
onClose={() => setConditionOpenLayerId(null)}
|
||||
/>
|
||||
{/* 트리거 설정 패널 */}
|
||||
{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>
|
||||
|
||||
{/* 도움말 */}
|
||||
<div className="border-t px-3 py-2 text-[10px] text-muted-foreground">
|
||||
<p>레이어를 클릭하여 편집 | 조건부 레이어를 캔버스에 드래그하여 영역 설정</p>
|
||||
{/* 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>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user