feat: Enhance LayerManagerPanel with dynamic trigger options and improved condition handling

- Added a new Select component to allow users to choose condition values dynamically based on the selected zone's trigger component.
- Implemented logic to fetch trigger options from the base layer components, ensuring only relevant options are displayed.
- Updated the LayerManagerPanel to handle condition values more effectively, including the ability to set new condition values and manage used values.
- Refactored the ComponentsPanel to include the new V2 select component with appropriate configuration options.
- Improved the V2SelectConfigPanel to streamline option management and enhance user experience.
This commit is contained in:
kjs 2026-02-09 17:13:26 +09:00
parent f8c0fe9499
commit c65f436009
5 changed files with 137 additions and 7528 deletions

View File

@ -1,6 +1,7 @@
import React, { useState, useEffect, useCallback } from "react"; import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { import {
@ -78,6 +79,11 @@ export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({
// 기본 레이어 컴포넌트 로드 // 기본 레이어 컴포넌트 로드
const loadBaseLayerComponents = useCallback(async () => { const loadBaseLayerComponents = useCallback(async () => {
if (!screenId) return; if (!screenId) return;
// 현재 활성 레이어가 기본 레이어(1)이면 props의 실시간 컴포넌트 사용
if (activeLayerId === 1 && components.length > 0) {
setBaseLayerComponents(components);
return;
}
try { try {
const data = await screenApi.getLayerLayout(screenId, 1); const data = await screenApi.getLayerLayout(screenId, 1);
if (data?.components) { if (data?.components) {
@ -86,7 +92,7 @@ export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({
} catch { } catch {
setBaseLayerComponents(components); setBaseLayerComponents(components);
} }
}, [screenId, components]); }, [screenId, components, activeLayerId]);
useEffect(() => { useEffect(() => {
loadLayers(); loadLayers();
@ -191,6 +197,22 @@ export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({
["select", "combobox", "radio-group"].some(t => c.componentType?.includes(t)) ["select", "combobox", "radio-group"].some(t => c.componentType?.includes(t))
); );
// Zone의 트리거 컴포넌트에서 옵션 목록 가져오기
const getTriggerOptions = useCallback((zone: ConditionalZone): { value: string; label: string }[] => {
if (!zone.trigger_component_id) return [];
const triggerComp = baseLayerComponents.find(c => c.id === zone.trigger_component_id);
if (!triggerComp) return [];
const config = triggerComp.componentConfig || {};
// 정적 옵션 (v2-select static source)
if (config.options && Array.isArray(config.options)) {
return config.options
.filter((opt: any) => opt.value)
.map((opt: any) => ({ value: opt.value, label: opt.label || opt.value }));
}
return [];
}, [baseLayerComponents]);
return ( return (
<div className="flex h-full flex-col bg-background"> <div className="flex h-full flex-col bg-background">
{/* 헤더 */} {/* 헤더 */}
@ -335,6 +357,8 @@ export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({
{/* Zone 소속 레이어 목록 */} {/* Zone 소속 레이어 목록 */}
{zoneLayers.map((layer) => { {zoneLayers.map((layer) => {
const isActive = activeLayerId === layer.layer_id; const isActive = activeLayerId === layer.layer_id;
const triggerOpts = getTriggerOptions(zone);
const currentCondValue = layer.condition_config?.condition_value || "";
return ( return (
<div <div
key={layer.layer_id} key={layer.layer_id}
@ -349,10 +373,47 @@ export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({
<SplitSquareVertical className="h-3 w-3 shrink-0 text-amber-600" /> <SplitSquareVertical className="h-3 w-3 shrink-0 text-amber-600" />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<span className="text-xs font-medium truncate">{layer.layer_name}</span> <span className="text-xs font-medium truncate">{layer.layer_name}</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-1 mt-0.5">
<span className="text-[10px] text-muted-foreground"> {triggerOpts.length > 0 ? (
: {layer.condition_config?.condition_value || "미설정"} <Select
</span> value={currentCondValue || "_none_"}
onValueChange={async (val) => {
if (!screenId) return;
const newVal = val === "_none_" ? "" : val;
try {
await screenApi.updateLayerCondition(
screenId,
layer.layer_id,
{ ...layer.condition_config, condition_value: newVal },
layer.layer_name,
);
await loadLayers();
toast.success("조건값이 변경되었습니다.");
} catch {
toast.error("조건값 변경에 실패했습니다.");
}
}}
>
<SelectTrigger
className="h-5 text-[10px] w-auto min-w-[80px] max-w-[140px] px-1.5"
onClick={(e) => e.stopPropagation()}
>
<SelectValue placeholder="조건값 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none_" className="text-xs text-muted-foreground"></SelectItem>
{triggerOpts.map(opt => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<span className="text-[10px] text-muted-foreground">
: {currentCondValue || "미설정"}
</span>
)}
<span className="text-[10px] text-muted-foreground"> <span className="text-[10px] text-muted-foreground">
| {layer.component_count} | {layer.component_count}
</span> </span>
@ -373,14 +434,41 @@ export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({
{/* 레이어 추가 */} {/* 레이어 추가 */}
{addingToZoneId === zone.zone_id ? ( {addingToZoneId === zone.zone_id ? (
<div className="flex items-center gap-1 rounded border bg-background p-1.5"> <div className="flex items-center gap-1 rounded border bg-background p-1.5">
<Input {(() => {
value={newConditionValue} const triggerOpts = getTriggerOptions(zone);
onChange={(e) => setNewConditionValue(e.target.value)} // 이미 사용된 조건값 제외
placeholder="조건값 입력 (예: 옵션1)" const usedValues = new Set(
className="h-6 text-[11px] flex-1" zoneLayers.map(l => l.condition_config?.condition_value).filter(Boolean)
autoFocus );
onKeyDown={(e) => { if (e.key === "Enter") handleAddLayerToZone(zone.zone_id); }} const availableOpts = triggerOpts.filter(o => !usedValues.has(o.value));
/>
if (availableOpts.length > 0) {
return (
<Select value={newConditionValue} onValueChange={setNewConditionValue}>
<SelectTrigger className="h-6 text-[11px] flex-1">
<SelectValue placeholder="조건값 선택" />
</SelectTrigger>
<SelectContent>
{availableOpts.map(opt => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
return (
<Input
value={newConditionValue}
onChange={(e) => setNewConditionValue(e.target.value)}
placeholder="조건값 입력"
className="h-6 text-[11px] flex-1"
autoFocus
onKeyDown={(e) => { if (e.key === "Enter") handleAddLayerToZone(zone.zone_id); }}
/>
);
})()}
<Button <Button
variant="default" size="sm" variant="default" size="sm"
className="h-6 px-2 text-[10px]" className="h-6 px-2 text-[10px]"

File diff suppressed because it is too large Load Diff

View File

@ -49,7 +49,6 @@ export function ComponentsPanel({
() => () =>
[ [
// v2-input: 테이블 컬럼 드래그 시 자동 생성되므로 숨김 처리 // v2-input: 테이블 컬럼 드래그 시 자동 생성되므로 숨김 처리
// v2-select: 테이블 컬럼 드래그 시 자동 생성되므로 숨김 처리
// v2-date: 테이블 컬럼 드래그 시 자동 생성되므로 숨김 처리 // v2-date: 테이블 컬럼 드래그 시 자동 생성되므로 숨김 처리
// v2-layout: 중첩 드래그앤드롭 기능 미구현으로 숨김 처리 // v2-layout: 중첩 드래그앤드롭 기능 미구현으로 숨김 처리
// v2-group: 중첩 드래그앤드롭 기능 미구현으로 숨김 처리 // v2-group: 중첩 드래그앤드롭 기능 미구현으로 숨김 처리
@ -57,6 +56,23 @@ export function ComponentsPanel({
// v2-media 제거 - 테이블 컬럼의 image/file 입력 타입으로 사용 // v2-media 제거 - 테이블 컬럼의 image/file 입력 타입으로 사용
// v2-biz 제거 - 개별 컴포넌트(flow-widget, rack-structure, numbering-rule)로 직접 표시 // v2-biz 제거 - 개별 컴포넌트(flow-widget, rack-structure, numbering-rule)로 직접 표시
// v2-hierarchy 제거 - 현재 미사용 // v2-hierarchy 제거 - 현재 미사용
{
id: "v2-select",
name: "V2 선택",
description: "드롭다운, 콤보박스, 라디오, 체크박스 등 다양한 선택 모드 지원",
category: "input" as ComponentCategory,
tags: ["select", "dropdown", "combobox", "v2"],
defaultSize: { width: 300, height: 40 },
defaultConfig: {
mode: "dropdown",
source: "static",
multiple: false,
searchable: false,
placeholder: "선택하세요",
options: [],
allowClear: true,
},
},
{ {
id: "v2-repeater", id: "v2-repeater",
name: "리피터 그리드", name: "리피터 그리드",
@ -65,7 +81,7 @@ export function ComponentsPanel({
tags: ["repeater", "table", "modal", "button", "v2", "v2"], tags: ["repeater", "table", "modal", "button", "v2", "v2"],
defaultSize: { width: 600, height: 300 }, defaultSize: { width: 600, height: 300 },
}, },
] as ComponentDefinition[], ] as unknown as ComponentDefinition[],
[], [],
); );
@ -126,6 +142,7 @@ export function ComponentsPanel({
"section-card", // → v2-section-card "section-card", // → v2-section-card
"location-swap-selector", // → v2-location-swap-selector "location-swap-selector", // → v2-location-swap-selector
"rack-structure", // → v2-rack-structure "rack-structure", // → v2-rack-structure
"v2-select", // → v2-select (아래 v2Components에서 별도 처리)
"v2-repeater", // → v2-repeater (아래 v2Components에서 별도 처리) "v2-repeater", // → v2-repeater (아래 v2Components에서 별도 처리)
"repeat-container", // → v2-repeat-container "repeat-container", // → v2-repeat-container
"repeat-screen-modal", // → v2-repeat-screen-modal "repeat-screen-modal", // → v2-repeat-screen-modal

View File

@ -71,11 +71,13 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
<SelectValue placeholder={placeholder} /> <SelectValue placeholder={placeholder} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{options.map((option) => ( {options
<SelectItem key={option.value} value={option.value}> .filter((option) => option.value !== "")
{option.label} .map((option) => (
</SelectItem> <SelectItem key={option.value} value={option.value}>
))} {option.label}
</SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
); );

View File

@ -87,9 +87,9 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({ config
updateConfig("options", newOptions); updateConfig("options", newOptions);
}; };
const updateOption = (index: number, field: "value" | "label", value: string) => { const updateOptionValue = (index: number, value: string) => {
const newOptions = [...options]; const newOptions = [...options];
newOptions[index] = { ...newOptions[index], [field]: value }; newOptions[index] = { ...newOptions[index], value, label: value };
updateConfig("options", newOptions); updateConfig("options", newOptions);
}; };
@ -139,7 +139,7 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({ config
</div> </div>
{/* 정적 옵션 관리 */} {/* 정적 옵션 관리 */}
{config.source === "static" && ( {(config.source || "static") === "static" && (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label className="text-xs font-medium"> </Label> <Label className="text-xs font-medium"> </Label>
@ -148,19 +148,13 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({ config
</Button> </Button>
</div> </div>
<div className="max-h-40 space-y-2 overflow-y-auto"> <div className="max-h-40 space-y-1.5 overflow-y-auto">
{options.map((option: any, index: number) => ( {options.map((option: any, index: number) => (
<div key={index} className="flex items-center gap-2"> <div key={index} className="flex items-center gap-1.5">
<Input <Input
value={option.value || ""} value={option.value || ""}
onChange={(e) => updateOption(index, "value", e.target.value)} onChange={(e) => updateOptionValue(index, e.target.value)}
placeholder="값" placeholder={`옵션 ${index + 1}`}
className="h-7 flex-1 text-xs"
/>
<Input
value={option.label || ""}
onChange={(e) => updateOption(index, "label", e.target.value)}
placeholder="표시 텍스트"
className="h-7 flex-1 text-xs" className="h-7 flex-1 text-xs"
/> />
<Button <Button
@ -168,7 +162,7 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({ config
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => removeOption(index)} onClick={() => removeOption(index)}
className="text-destructive h-7 w-7 p-0" className="text-destructive h-7 w-7 shrink-0 p-0"
> >
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3 w-3" />
</Button> </Button>