658 lines
25 KiB
TypeScript
658 lines
25 KiB
TypeScript
import React, { useState, useEffect, useMemo, useCallback } from "react";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Loader2, AlertCircle, Check, X, Database, Code2 } from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
import { ComponentData, LayerCondition, LayerDefinition, DisplayRegion } from "@/types/screen-management";
|
|
import { getCodesByCategory, CodeItem } from "@/lib/api/codeManagement";
|
|
import { EntityReferenceAPI } from "@/lib/api/entityReference";
|
|
import { apiClient } from "@/lib/api/client";
|
|
|
|
// 통합 옵션 타입 (코드/엔티티/카테고리 모두 사용)
|
|
interface ConditionOption {
|
|
value: string;
|
|
label: string;
|
|
}
|
|
|
|
// 컴포넌트의 데이터 소스 타입
|
|
type DataSourceType = "code" | "entity" | "category" | "static" | "none";
|
|
|
|
interface LayerConditionPanelProps {
|
|
layer: LayerDefinition;
|
|
components: ComponentData[]; // 화면의 모든 컴포넌트
|
|
baseLayerComponents?: ComponentData[]; // 기본 레이어 컴포넌트 (트리거 우선 대상)
|
|
onUpdateCondition: (condition: LayerCondition | undefined) => void;
|
|
onUpdateDisplayRegion: (region: DisplayRegion | undefined) => void;
|
|
onClose?: () => void;
|
|
}
|
|
|
|
// 조건 연산자 옵션
|
|
const OPERATORS = [
|
|
{ value: "eq", label: "같음 (=)" },
|
|
{ value: "neq", label: "같지 않음 (≠)" },
|
|
{ value: "in", label: "포함 (in)" },
|
|
] as const;
|
|
|
|
type OperatorType = "eq" | "neq" | "in";
|
|
|
|
export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
|
|
layer,
|
|
components,
|
|
baseLayerComponents,
|
|
onUpdateCondition,
|
|
onUpdateDisplayRegion,
|
|
onClose,
|
|
}) => {
|
|
// 조건 설정 상태
|
|
const [targetComponentId, setTargetComponentId] = useState<string>(
|
|
layer.condition?.targetComponentId || ""
|
|
);
|
|
const [operator, setOperator] = useState<OperatorType>(
|
|
(layer.condition?.operator as OperatorType) || "eq"
|
|
);
|
|
const [value, setValue] = useState<string>(
|
|
layer.condition?.value?.toString() || ""
|
|
);
|
|
const [multiValues, setMultiValues] = useState<string[]>(
|
|
Array.isArray(layer.condition?.value) ? layer.condition.value : []
|
|
);
|
|
|
|
// 옵션 목록 로딩 상태 (코드/엔티티 통합)
|
|
const [options, setOptions] = useState<ConditionOption[]>([]);
|
|
const [isLoadingOptions, setIsLoadingOptions] = useState(false);
|
|
const [loadError, setLoadError] = useState<string | null>(null);
|
|
|
|
// 트리거 가능한 컴포넌트 필터링 (기본 레이어 우선, 셀렉트/라디오/코드 타입 등)
|
|
const triggerableComponents = useMemo(() => {
|
|
// 기본 레이어 컴포넌트가 전달된 경우 우선 사용, 없으면 전체 컴포넌트 사용
|
|
const sourceComponents = baseLayerComponents && baseLayerComponents.length > 0
|
|
? baseLayerComponents
|
|
: components;
|
|
|
|
const isTriggerComponent = (comp: ComponentData): boolean => {
|
|
const componentType = (comp.componentType || "").toLowerCase();
|
|
const widgetType = ((comp as any).widgetType || "").toLowerCase();
|
|
const webType = ((comp as any).webType || "").toLowerCase();
|
|
const inputType = ((comp as any).componentConfig?.inputType || "").toLowerCase();
|
|
|
|
// 셀렉트, 라디오, 코드 타입 컴포넌트만 허용
|
|
const triggerTypes = ["select", "radio", "code", "checkbox", "toggle", "entity"];
|
|
return triggerTypes.some((type) =>
|
|
componentType.includes(type) ||
|
|
widgetType.includes(type) ||
|
|
webType.includes(type) ||
|
|
inputType.includes(type)
|
|
);
|
|
};
|
|
|
|
// 기본 레이어 컴포넌트 ID Set (그룹 구분용)
|
|
const baseLayerIds = new Set(
|
|
(baseLayerComponents || []).map((c) => c.id)
|
|
);
|
|
|
|
// 기본 레이어 트리거 컴포넌트
|
|
const baseLayerTriggers = sourceComponents.filter(isTriggerComponent);
|
|
|
|
// 기본 레이어가 아닌 다른 레이어의 트리거 컴포넌트도 포함 (하단에 표시)
|
|
// 단, baseLayerComponents가 별도로 전달된 경우에만 나머지 컴포넌트 추가
|
|
const otherLayerTriggers = baseLayerComponents && baseLayerComponents.length > 0
|
|
? components.filter((comp) => !baseLayerIds.has(comp.id) && isTriggerComponent(comp))
|
|
: [];
|
|
|
|
return { baseLayerTriggers, otherLayerTriggers };
|
|
}, [components, baseLayerComponents]);
|
|
|
|
// 선택된 컴포넌트 정보
|
|
const selectedComponent = useMemo(() => {
|
|
return components.find((c) => c.id === targetComponentId);
|
|
}, [components, targetComponentId]);
|
|
|
|
// 선택된 컴포넌트의 데이터 소스 정보 추출
|
|
const dataSourceInfo = useMemo<{
|
|
type: DataSourceType;
|
|
codeCategory?: string;
|
|
// 엔티티: 원본 테이블.컬럼 (entity-reference API용)
|
|
originTable?: string;
|
|
originColumn?: string;
|
|
// 엔티티: 참조 대상 정보 (직접 조회용 폴백)
|
|
referenceTable?: string;
|
|
referenceColumn?: string;
|
|
categoryTable?: string;
|
|
categoryColumn?: string;
|
|
staticOptions?: any[];
|
|
}>(() => {
|
|
if (!selectedComponent) return { type: "none" };
|
|
|
|
const comp = selectedComponent as any;
|
|
const config = comp.componentConfig || comp.webTypeConfig || {};
|
|
const detailSettings = comp.detailSettings || {};
|
|
|
|
// V2 컴포넌트: config.source 확인
|
|
const source = config.source;
|
|
|
|
// 1. 카테고리 소스 (V2: source === "category", category_values 테이블)
|
|
if (source === "category") {
|
|
const categoryTable = config.categoryTable || comp.tableName;
|
|
const categoryColumn = config.categoryColumn || comp.columnName;
|
|
return { type: "category", categoryTable, categoryColumn };
|
|
}
|
|
|
|
// 2. 코드 카테고리 확인 (V2: source === "code" + codeGroup, 기존: codeCategory)
|
|
const codeCategory =
|
|
config.codeGroup || // V2 컴포넌트
|
|
config.codeCategory ||
|
|
comp.codeCategory ||
|
|
detailSettings.codeCategory;
|
|
|
|
if (source === "code" || codeCategory) {
|
|
return { type: "code", codeCategory };
|
|
}
|
|
|
|
// 3. 엔티티 참조 확인 (V2: source === "entity")
|
|
// entity-reference API는 원본 테이블.컬럼으로 호출해야 함
|
|
// (백엔드에서 table_type_columns를 조회하여 참조 테이블/컬럼을 자동 매핑)
|
|
const originTable = comp.tableName || config.tableName;
|
|
const originColumn = comp.columnName || config.columnName;
|
|
|
|
const referenceTable =
|
|
config.entityTable ||
|
|
config.referenceTable ||
|
|
comp.referenceTable ||
|
|
detailSettings.referenceTable;
|
|
|
|
const referenceColumn =
|
|
config.entityValueColumn ||
|
|
config.referenceColumn ||
|
|
comp.referenceColumn ||
|
|
detailSettings.referenceColumn;
|
|
|
|
if (source === "entity" || referenceTable) {
|
|
return { type: "entity", originTable, originColumn, referenceTable, referenceColumn };
|
|
}
|
|
|
|
// 4. 정적 옵션 확인 (V2: source === "static" 또는 config.options 존재)
|
|
const staticOptions = config.options;
|
|
if (source === "static" || (staticOptions && Array.isArray(staticOptions) && staticOptions.length > 0)) {
|
|
return { type: "static", staticOptions };
|
|
}
|
|
|
|
return { type: "none" };
|
|
}, [selectedComponent]);
|
|
|
|
// 컴포넌트 선택 시 옵션 목록 로드 (카테고리, 코드, 엔티티, 정적)
|
|
useEffect(() => {
|
|
if (dataSourceInfo.type === "none") {
|
|
setOptions([]);
|
|
return;
|
|
}
|
|
|
|
// 정적 옵션은 즉시 설정
|
|
if (dataSourceInfo.type === "static") {
|
|
const staticOpts = dataSourceInfo.staticOptions || [];
|
|
setOptions(staticOpts.map((opt: any) => ({
|
|
value: opt.value || "",
|
|
label: opt.label || opt.value || "",
|
|
})));
|
|
return;
|
|
}
|
|
|
|
const loadOptions = async () => {
|
|
setIsLoadingOptions(true);
|
|
setLoadError(null);
|
|
|
|
try {
|
|
if (dataSourceInfo.type === "category" && dataSourceInfo.categoryTable && dataSourceInfo.categoryColumn) {
|
|
// 카테고리 값에서 옵션 로드 (category_values 테이블)
|
|
const response = await apiClient.get(
|
|
`/table-categories/${dataSourceInfo.categoryTable}/${dataSourceInfo.categoryColumn}/values`
|
|
);
|
|
const data = response.data;
|
|
if (data.success && data.data) {
|
|
// 트리 구조를 평탄화
|
|
const flattenTree = (items: any[], depth = 0): ConditionOption[] => {
|
|
const result: ConditionOption[] = [];
|
|
for (const item of items) {
|
|
const prefix = depth > 0 ? " ".repeat(depth) : "";
|
|
result.push({
|
|
value: item.valueCode || item.valueLabel,
|
|
label: `${prefix}${item.valueLabel}`,
|
|
});
|
|
if (item.children && item.children.length > 0) {
|
|
result.push(...flattenTree(item.children, depth + 1));
|
|
}
|
|
}
|
|
return result;
|
|
};
|
|
setOptions(flattenTree(Array.isArray(data.data) ? data.data : []));
|
|
} else {
|
|
setOptions([]);
|
|
}
|
|
} else if (dataSourceInfo.type === "code" && dataSourceInfo.codeCategory) {
|
|
// 코드 카테고리에서 옵션 로드
|
|
const codes = await getCodesByCategory(dataSourceInfo.codeCategory);
|
|
setOptions(codes.map((code) => ({
|
|
value: code.code,
|
|
label: code.name,
|
|
})));
|
|
} else if (dataSourceInfo.type === "entity") {
|
|
// 엔티티 참조에서 옵션 로드
|
|
// 방법 1: 원본 테이블.컬럼으로 entity-reference API 호출
|
|
// (백엔드에서 table_type_columns를 통해 참조 테이블/컬럼을 자동 매핑)
|
|
// 방법 2: 직접 참조 테이블로 폴백
|
|
let entityLoaded = false;
|
|
|
|
if (dataSourceInfo.originTable && dataSourceInfo.originColumn) {
|
|
try {
|
|
const entityData = await EntityReferenceAPI.getEntityReferenceData(
|
|
dataSourceInfo.originTable,
|
|
dataSourceInfo.originColumn,
|
|
{ limit: 100 }
|
|
);
|
|
setOptions(entityData.options.map((opt) => ({
|
|
value: opt.value,
|
|
label: opt.label,
|
|
})));
|
|
entityLoaded = true;
|
|
} catch {
|
|
// 원본 테이블.컬럼으로 실패 시 폴백
|
|
console.warn("원본 테이블.컬럼으로 엔티티 조회 실패, 직접 참조로 폴백");
|
|
}
|
|
}
|
|
|
|
// 폴백: 참조 테이블에서 직접 조회
|
|
if (!entityLoaded && dataSourceInfo.referenceTable) {
|
|
try {
|
|
const refColumn = dataSourceInfo.referenceColumn || "id";
|
|
const entityData = await EntityReferenceAPI.getEntityReferenceData(
|
|
dataSourceInfo.referenceTable,
|
|
refColumn,
|
|
{ limit: 100 }
|
|
);
|
|
setOptions(entityData.options.map((opt) => ({
|
|
value: opt.value,
|
|
label: opt.label,
|
|
})));
|
|
entityLoaded = true;
|
|
} catch {
|
|
console.warn("직접 참조 테이블로도 엔티티 조회 실패");
|
|
}
|
|
}
|
|
|
|
// 모든 방법 실패 시 빈 옵션으로 설정하고 에러 표시하지 않음
|
|
if (!entityLoaded) {
|
|
// 엔티티 소스이지만 테이블 조회 불가 시, 직접 입력 모드로 전환
|
|
setOptions([]);
|
|
}
|
|
} else {
|
|
setOptions([]);
|
|
}
|
|
} catch (error: any) {
|
|
console.error("옵션 목록 로드 실패:", error);
|
|
setLoadError(error.message || "옵션 목록을 불러올 수 없습니다.");
|
|
setOptions([]);
|
|
} finally {
|
|
setIsLoadingOptions(false);
|
|
}
|
|
};
|
|
|
|
loadOptions();
|
|
}, [dataSourceInfo]);
|
|
|
|
// 조건 저장
|
|
const handleSave = useCallback(() => {
|
|
if (!targetComponentId) {
|
|
return;
|
|
}
|
|
|
|
const condition: LayerCondition = {
|
|
targetComponentId,
|
|
operator,
|
|
value: operator === "in" ? multiValues : value,
|
|
};
|
|
|
|
onUpdateCondition(condition);
|
|
onClose?.();
|
|
}, [targetComponentId, operator, value, multiValues, onUpdateCondition, onClose]);
|
|
|
|
// 조건 삭제
|
|
const handleClear = useCallback(() => {
|
|
onUpdateCondition(undefined);
|
|
setTargetComponentId("");
|
|
setOperator("eq");
|
|
setValue("");
|
|
setMultiValues([]);
|
|
onClose?.();
|
|
}, [onUpdateCondition, onClose]);
|
|
|
|
// in 연산자용 다중 값 토글
|
|
const toggleMultiValue = useCallback((val: string) => {
|
|
setMultiValues((prev) =>
|
|
prev.includes(val)
|
|
? prev.filter((v) => v !== val)
|
|
: [...prev, val]
|
|
);
|
|
}, []);
|
|
|
|
// 컴포넌트 라벨 가져오기
|
|
const getComponentLabel = (comp: ComponentData) => {
|
|
return comp.label || (comp as any).columnName || comp.id;
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4 p-4">
|
|
<div className="flex items-center justify-between">
|
|
<h4 className="text-sm font-semibold">조건부 표시 설정</h4>
|
|
{layer.condition && (
|
|
<Badge variant="secondary" className="text-xs">
|
|
설정됨
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
|
|
{/* 트리거 컴포넌트 선택 */}
|
|
<div className="space-y-2">
|
|
<Label className="text-xs">트리거 컴포넌트</Label>
|
|
<Select value={targetComponentId} onValueChange={setTargetComponentId}>
|
|
<SelectTrigger className="h-8 text-xs">
|
|
<SelectValue placeholder="컴포넌트 선택..." />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{triggerableComponents.baseLayerTriggers.length === 0 &&
|
|
triggerableComponents.otherLayerTriggers.length === 0 ? (
|
|
<div className="p-2 text-xs text-muted-foreground text-center">
|
|
조건 설정 가능한 컴포넌트가 없습니다.
|
|
<br />
|
|
(셀렉트, 라디오, 코드 타입)
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* 기본 레이어 컴포넌트 (우선 표시) */}
|
|
{triggerableComponents.baseLayerTriggers.length > 0 && (
|
|
<>
|
|
{triggerableComponents.otherLayerTriggers.length > 0 && (
|
|
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground">
|
|
기본 레이어
|
|
</div>
|
|
)}
|
|
{triggerableComponents.baseLayerTriggers.map((comp) => (
|
|
<SelectItem key={comp.id} value={comp.id} className="text-xs">
|
|
<div className="flex items-center gap-2">
|
|
<span>{getComponentLabel(comp)}</span>
|
|
<Badge variant="outline" className="text-[10px]">
|
|
{comp.componentType || (comp as any).widgetType}
|
|
</Badge>
|
|
</div>
|
|
</SelectItem>
|
|
))}
|
|
</>
|
|
)}
|
|
{/* 다른 레이어 컴포넌트 (하단에 구분하여 표시) */}
|
|
{triggerableComponents.otherLayerTriggers.length > 0 && (
|
|
<>
|
|
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground border-t mt-1 pt-1">
|
|
다른 레이어
|
|
</div>
|
|
{triggerableComponents.otherLayerTriggers.map((comp) => (
|
|
<SelectItem key={comp.id} value={comp.id} className="text-xs">
|
|
<div className="flex items-center gap-2">
|
|
<span>{getComponentLabel(comp)}</span>
|
|
<Badge variant="outline" className="text-[10px]">
|
|
{comp.componentType || (comp as any).widgetType}
|
|
</Badge>
|
|
</div>
|
|
</SelectItem>
|
|
))}
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
{/* 데이터 소스 표시 */}
|
|
{dataSourceInfo.type === "code" && dataSourceInfo.codeCategory && (
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<Code2 className="h-3 w-3" />
|
|
<span>코드:</span>
|
|
<Badge variant="secondary" className="text-[10px]">
|
|
{dataSourceInfo.codeCategory}
|
|
</Badge>
|
|
</div>
|
|
)}
|
|
{dataSourceInfo.type === "entity" && (dataSourceInfo.referenceTable || dataSourceInfo.originTable) && (
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<Database className="h-3 w-3" />
|
|
<span>엔티티:</span>
|
|
<Badge variant="secondary" className="text-[10px]">
|
|
{dataSourceInfo.referenceTable || `${dataSourceInfo.originTable}.${dataSourceInfo.originColumn}`}
|
|
</Badge>
|
|
</div>
|
|
)}
|
|
{dataSourceInfo.type === "category" && dataSourceInfo.categoryTable && (
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<Database className="h-3 w-3" />
|
|
<span>카테고리:</span>
|
|
<Badge variant="secondary" className="text-[10px]">
|
|
{dataSourceInfo.categoryTable}.{dataSourceInfo.categoryColumn}
|
|
</Badge>
|
|
</div>
|
|
)}
|
|
{dataSourceInfo.type === "static" && (
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<span>정적 옵션</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 연산자 선택 */}
|
|
{targetComponentId && (
|
|
<div className="space-y-2">
|
|
<Label className="text-xs">조건</Label>
|
|
<Select
|
|
value={operator}
|
|
onValueChange={(val) => setOperator(val as OperatorType)}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{OPERATORS.map((op) => (
|
|
<SelectItem key={op.value} value={op.value} className="text-xs">
|
|
{op.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
|
|
{/* 조건 값 선택 */}
|
|
{targetComponentId && (
|
|
<div className="space-y-2">
|
|
<Label className="text-xs">
|
|
{operator === "in" ? "값 선택 (복수)" : "값"}
|
|
</Label>
|
|
|
|
{isLoadingOptions ? (
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground p-2">
|
|
<Loader2 className="h-3 w-3 animate-spin" />
|
|
옵션 목록 로딩 중...
|
|
</div>
|
|
) : loadError ? (
|
|
<div className="flex items-center gap-2 text-xs text-destructive p-2">
|
|
<AlertCircle className="h-3 w-3" />
|
|
{loadError}
|
|
</div>
|
|
) : options.length > 0 ? (
|
|
// 옵션이 있는 경우 - 선택 UI
|
|
operator === "in" ? (
|
|
// 다중 선택 (in 연산자)
|
|
<div className="space-y-1 max-h-40 overflow-y-auto border rounded-md p-2">
|
|
{options.map((opt) => (
|
|
<div
|
|
key={opt.value}
|
|
className={cn(
|
|
"flex items-center gap-2 p-1.5 rounded cursor-pointer text-xs hover:bg-accent",
|
|
multiValues.includes(opt.value) && "bg-primary/10"
|
|
)}
|
|
onClick={() => toggleMultiValue(opt.value)}
|
|
>
|
|
<div className={cn(
|
|
"w-4 h-4 rounded border flex items-center justify-center",
|
|
multiValues.includes(opt.value)
|
|
? "bg-primary border-primary"
|
|
: "border-input"
|
|
)}>
|
|
{multiValues.includes(opt.value) && (
|
|
<Check className="h-3 w-3 text-primary-foreground" />
|
|
)}
|
|
</div>
|
|
<span>{opt.label}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
// 단일 선택 (eq, neq 연산자)
|
|
<Select value={value} onValueChange={setValue}>
|
|
<SelectTrigger className="h-8 text-xs">
|
|
<SelectValue placeholder="값 선택..." />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{options.map((opt) => (
|
|
<SelectItem
|
|
key={opt.value}
|
|
value={opt.value}
|
|
className="text-xs"
|
|
>
|
|
{opt.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
)
|
|
) : (
|
|
// 옵션이 없는 경우 - 직접 입력
|
|
<Input
|
|
value={value}
|
|
onChange={(e) => setValue(e.target.value)}
|
|
placeholder="조건 값 입력..."
|
|
className="h-8 text-xs"
|
|
/>
|
|
)}
|
|
|
|
{/* 선택된 값 표시 (in 연산자) */}
|
|
{operator === "in" && multiValues.length > 0 && (
|
|
<div className="flex flex-wrap gap-1 mt-2">
|
|
{multiValues.map((val) => {
|
|
const opt = options.find((o) => o.value === val);
|
|
return (
|
|
<Badge
|
|
key={val}
|
|
variant="secondary"
|
|
className="text-[10px] gap-1"
|
|
>
|
|
{opt?.label || val}
|
|
<X
|
|
className="h-2.5 w-2.5 cursor-pointer hover:text-destructive"
|
|
onClick={() => toggleMultiValue(val)}
|
|
/>
|
|
</Badge>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 현재 조건 요약 */}
|
|
{targetComponentId && (value || multiValues.length > 0) && (
|
|
<div className="p-2 bg-muted rounded-md text-xs">
|
|
<span className="font-medium">요약: </span>
|
|
<span className="text-muted-foreground">
|
|
"{getComponentLabel(selectedComponent!)}" 값이{" "}
|
|
{operator === "eq" && `"${options.find(o => o.value === value)?.label || value}"와 같으면`}
|
|
{operator === "neq" && `"${options.find(o => o.value === value)?.label || value}"와 다르면`}
|
|
{operator === "in" && `[${multiValues.map(v => options.find(o => o.value === v)?.label || v).join(", ")}] 중 하나이면`}
|
|
{" "}이 레이어 표시
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* 표시 영역 설정 */}
|
|
<div className="space-y-2 border-t pt-3">
|
|
<Label className="text-xs font-semibold">표시 영역</Label>
|
|
|
|
{layer.displayRegion ? (
|
|
<>
|
|
{/* 현재 영역 정보 표시 */}
|
|
<div className="flex items-center gap-2 rounded-md border bg-muted/30 p-2">
|
|
<div className="flex-1 text-[10px] text-muted-foreground">
|
|
<span className="font-medium text-foreground">
|
|
{layer.displayRegion.width} x {layer.displayRegion.height}
|
|
</span>
|
|
<span className="ml-1">
|
|
({layer.displayRegion.x}, {layer.displayRegion.y})
|
|
</span>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-5 px-1.5 text-[10px] text-destructive hover:text-destructive"
|
|
onClick={() => onUpdateDisplayRegion(undefined)}
|
|
>
|
|
삭제
|
|
</Button>
|
|
</div>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
캔버스에서 점선 영역을 드래그하여 이동/리사이즈할 수 있습니다.
|
|
</p>
|
|
</>
|
|
) : (
|
|
<div className="space-y-2">
|
|
<div className="rounded-md border border-dashed p-3 text-center">
|
|
<p className="text-xs font-medium text-muted-foreground">
|
|
좌측의 레이어 항목을 캔버스로
|
|
</p>
|
|
<p className="text-xs font-medium text-muted-foreground">
|
|
드래그&드롭하여 영역을 배치하세요
|
|
</p>
|
|
</div>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
영역을 추가하면 조건 미충족 시 해당 영역이 사라지고 아래 컴포넌트가 위로 이동합니다.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 버튼 */}
|
|
<div className="flex gap-2 pt-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="flex-1 h-8 text-xs"
|
|
onClick={handleClear}
|
|
>
|
|
조건 삭제
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
className="flex-1 h-8 text-xs"
|
|
onClick={handleSave}
|
|
disabled={!targetComponentId || (!value && multiValues.length === 0)}
|
|
>
|
|
저장
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|