372 lines
13 KiB
TypeScript
372 lines
13 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 } from "lucide-react";
|
||
|
|
import { cn } from "@/lib/utils";
|
||
|
|
import { ComponentData, LayerCondition, LayerDefinition } from "@/types/screen-management";
|
||
|
|
import { getCodesByCategory, CodeItem } from "@/lib/api/codeManagement";
|
||
|
|
|
||
|
|
interface LayerConditionPanelProps {
|
||
|
|
layer: LayerDefinition;
|
||
|
|
components: ComponentData[]; // 화면의 모든 컴포넌트
|
||
|
|
onUpdateCondition: (condition: LayerCondition | 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,
|
||
|
|
onUpdateCondition,
|
||
|
|
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 [codeOptions, setCodeOptions] = useState<CodeItem[]>([]);
|
||
|
|
const [isLoadingCodes, setIsLoadingCodes] = useState(false);
|
||
|
|
const [codeLoadError, setCodeLoadError] = useState<string | null>(null);
|
||
|
|
|
||
|
|
// 트리거 가능한 컴포넌트 필터링 (셀렉트, 라디오, 코드 타입 등)
|
||
|
|
const triggerableComponents = useMemo(() => {
|
||
|
|
return components.filter((comp) => {
|
||
|
|
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"];
|
||
|
|
const isTriggerType = triggerTypes.some((type) =>
|
||
|
|
componentType.includes(type) ||
|
||
|
|
widgetType.includes(type) ||
|
||
|
|
webType.includes(type) ||
|
||
|
|
inputType.includes(type)
|
||
|
|
);
|
||
|
|
|
||
|
|
return isTriggerType;
|
||
|
|
});
|
||
|
|
}, [components]);
|
||
|
|
|
||
|
|
// 선택된 컴포넌트 정보
|
||
|
|
const selectedComponent = useMemo(() => {
|
||
|
|
return components.find((c) => c.id === targetComponentId);
|
||
|
|
}, [components, targetComponentId]);
|
||
|
|
|
||
|
|
// 선택된 컴포넌트의 코드 카테고리
|
||
|
|
const codeCategory = useMemo(() => {
|
||
|
|
if (!selectedComponent) return null;
|
||
|
|
|
||
|
|
// codeCategory 확인 (다양한 위치에 있을 수 있음)
|
||
|
|
const category =
|
||
|
|
(selectedComponent as any).codeCategory ||
|
||
|
|
(selectedComponent as any).componentConfig?.codeCategory ||
|
||
|
|
(selectedComponent as any).webTypeConfig?.codeCategory;
|
||
|
|
|
||
|
|
return category || null;
|
||
|
|
}, [selectedComponent]);
|
||
|
|
|
||
|
|
// 컴포넌트 선택 시 코드 목록 로드
|
||
|
|
useEffect(() => {
|
||
|
|
if (!codeCategory) {
|
||
|
|
setCodeOptions([]);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const loadCodes = async () => {
|
||
|
|
setIsLoadingCodes(true);
|
||
|
|
setCodeLoadError(null);
|
||
|
|
|
||
|
|
try {
|
||
|
|
const codes = await getCodesByCategory(codeCategory);
|
||
|
|
setCodeOptions(codes);
|
||
|
|
} catch (error: any) {
|
||
|
|
console.error("코드 목록 로드 실패:", error);
|
||
|
|
setCodeLoadError(error.message || "코드 목록을 불러올 수 없습니다.");
|
||
|
|
setCodeOptions([]);
|
||
|
|
} finally {
|
||
|
|
setIsLoadingCodes(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
loadCodes();
|
||
|
|
}, [codeCategory]);
|
||
|
|
|
||
|
|
// 조건 저장
|
||
|
|
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.length === 0 ? (
|
||
|
|
<div className="p-2 text-xs text-muted-foreground text-center">
|
||
|
|
조건 설정 가능한 컴포넌트가 없습니다.
|
||
|
|
<br />
|
||
|
|
(셀렉트, 라디오, 코드 타입)
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
triggerableComponents.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>
|
||
|
|
|
||
|
|
{/* 코드 카테고리 표시 */}
|
||
|
|
{codeCategory && (
|
||
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||
|
|
<span>카테고리:</span>
|
||
|
|
<Badge variant="secondary" className="text-[10px]">
|
||
|
|
{codeCategory}
|
||
|
|
</Badge>
|
||
|
|
</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>
|
||
|
|
|
||
|
|
{isLoadingCodes ? (
|
||
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground p-2">
|
||
|
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||
|
|
코드 목록 로딩 중...
|
||
|
|
</div>
|
||
|
|
) : codeLoadError ? (
|
||
|
|
<div className="flex items-center gap-2 text-xs text-destructive p-2">
|
||
|
|
<AlertCircle className="h-3 w-3" />
|
||
|
|
{codeLoadError}
|
||
|
|
</div>
|
||
|
|
) : codeOptions.length > 0 ? (
|
||
|
|
// 코드 카테고리가 있는 경우 - 선택 UI
|
||
|
|
operator === "in" ? (
|
||
|
|
// 다중 선택 (in 연산자)
|
||
|
|
<div className="space-y-1 max-h-40 overflow-y-auto border rounded-md p-2">
|
||
|
|
{codeOptions.map((code) => (
|
||
|
|
<div
|
||
|
|
key={code.codeValue}
|
||
|
|
className={cn(
|
||
|
|
"flex items-center gap-2 p-1.5 rounded cursor-pointer text-xs hover:bg-accent",
|
||
|
|
multiValues.includes(code.codeValue) && "bg-primary/10"
|
||
|
|
)}
|
||
|
|
onClick={() => toggleMultiValue(code.codeValue)}
|
||
|
|
>
|
||
|
|
<div className={cn(
|
||
|
|
"w-4 h-4 rounded border flex items-center justify-center",
|
||
|
|
multiValues.includes(code.codeValue)
|
||
|
|
? "bg-primary border-primary"
|
||
|
|
: "border-input"
|
||
|
|
)}>
|
||
|
|
{multiValues.includes(code.codeValue) && (
|
||
|
|
<Check className="h-3 w-3 text-primary-foreground" />
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
<span>{code.codeName}</span>
|
||
|
|
<span className="text-muted-foreground">({code.codeValue})</span>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
// 단일 선택 (eq, neq 연산자)
|
||
|
|
<Select value={value} onValueChange={setValue}>
|
||
|
|
<SelectTrigger className="h-8 text-xs">
|
||
|
|
<SelectValue placeholder="값 선택..." />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
{codeOptions.map((code) => (
|
||
|
|
<SelectItem
|
||
|
|
key={code.codeValue}
|
||
|
|
value={code.codeValue}
|
||
|
|
className="text-xs"
|
||
|
|
>
|
||
|
|
{code.codeName} ({code.codeValue})
|
||
|
|
</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 code = codeOptions.find((c) => c.codeValue === val);
|
||
|
|
return (
|
||
|
|
<Badge
|
||
|
|
key={val}
|
||
|
|
variant="secondary"
|
||
|
|
className="text-[10px] gap-1"
|
||
|
|
>
|
||
|
|
{code?.codeName || 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" && `"${codeOptions.find(c => c.codeValue === value)?.codeName || value}"와 같으면`}
|
||
|
|
{operator === "neq" && `"${codeOptions.find(c => c.codeValue === value)?.codeName || value}"와 다르면`}
|
||
|
|
{operator === "in" && `[${multiValues.map(v => codeOptions.find(c => c.codeValue === v)?.codeName || v).join(", ")}] 중 하나이면`}
|
||
|
|
{" "}이 레이어 표시
|
||
|
|
</span>
|
||
|
|
</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>
|
||
|
|
);
|
||
|
|
};
|