494 lines
18 KiB
TypeScript
494 lines
18 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* ConditionalConfigPanel
|
|
*
|
|
* 비개발자도 쉽게 조건부 표시/숨김/활성화/비활성화를 설정할 수 있는 UI
|
|
*
|
|
* 사용처:
|
|
* - 화면관리 > 상세설정 패널
|
|
* - 화면관리 > 속성 패널
|
|
*/
|
|
|
|
import React, { useState, useEffect, useMemo } from "react";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
|
import { Zap, Plus, Trash2, HelpCircle, Check, ChevronsUpDown } from "lucide-react";
|
|
import { ConditionalConfig } from "@/types/unified-components";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
// ===== 타입 정의 =====
|
|
|
|
interface FieldOption {
|
|
id: string;
|
|
label: string;
|
|
type?: string; // text, number, select, checkbox, entity, code 등
|
|
options?: Array<{ value: string; label: string }>; // select 타입일 경우 옵션들
|
|
// 동적 옵션 로드를 위한 정보
|
|
entityTable?: string;
|
|
entityValueColumn?: string;
|
|
entityLabelColumn?: string;
|
|
codeGroup?: string;
|
|
}
|
|
|
|
interface ConditionalConfigPanelProps {
|
|
/** 현재 조건부 설정 */
|
|
config?: ConditionalConfig;
|
|
/** 설정 변경 콜백 */
|
|
onChange: (config: ConditionalConfig | undefined) => void;
|
|
/** 같은 화면에 있는 다른 필드들 (조건 필드로 선택 가능) */
|
|
availableFields: FieldOption[];
|
|
/** 현재 컴포넌트 ID (자기 자신은 조건 필드에서 제외) */
|
|
currentComponentId?: string;
|
|
}
|
|
|
|
// 연산자 옵션
|
|
const OPERATORS: Array<{ value: ConditionalConfig["operator"]; label: string; description: string }> = [
|
|
{ value: "=", label: "같음", description: "값이 정확히 일치할 때" },
|
|
{ value: "!=", label: "다름", description: "값이 일치하지 않을 때" },
|
|
{ value: ">", label: "보다 큼", description: "값이 더 클 때 (숫자)" },
|
|
{ value: "<", label: "보다 작음", description: "값이 더 작을 때 (숫자)" },
|
|
{ value: "in", label: "포함됨", description: "여러 값 중 하나일 때" },
|
|
{ value: "notIn", label: "포함 안됨", description: "여러 값 중 아무것도 아닐 때" },
|
|
{ value: "isEmpty", label: "비어있음", description: "값이 없을 때" },
|
|
{ value: "isNotEmpty", label: "값이 있음", description: "값이 있을 때" },
|
|
];
|
|
|
|
// 동작 옵션
|
|
const ACTIONS: Array<{ value: ConditionalConfig["action"]; label: string; description: string }> = [
|
|
{ value: "show", label: "표시", description: "조건 만족 시 이 필드를 표시" },
|
|
{ value: "hide", label: "숨김", description: "조건 만족 시 이 필드를 숨김" },
|
|
{ value: "enable", label: "활성화", description: "조건 만족 시 이 필드를 활성화" },
|
|
{ value: "disable", label: "비활성화", description: "조건 만족 시 이 필드를 비활성화" },
|
|
];
|
|
|
|
// ===== 컴포넌트 =====
|
|
|
|
export function ConditionalConfigPanel({
|
|
config,
|
|
onChange,
|
|
availableFields,
|
|
currentComponentId,
|
|
}: ConditionalConfigPanelProps) {
|
|
// 로컬 상태
|
|
const [enabled, setEnabled] = useState(config?.enabled ?? false);
|
|
const [field, setField] = useState(config?.field ?? "");
|
|
const [operator, setOperator] = useState<ConditionalConfig["operator"]>(config?.operator ?? "=");
|
|
const [value, setValue] = useState<string>(String(config?.value ?? ""));
|
|
const [action, setAction] = useState<ConditionalConfig["action"]>(config?.action ?? "show");
|
|
|
|
// 자기 자신을 제외한 필드 목록
|
|
const selectableFields = useMemo(() => {
|
|
return availableFields.filter((f) => f.id !== currentComponentId);
|
|
}, [availableFields, currentComponentId]);
|
|
|
|
// 선택된 필드 정보
|
|
const selectedField = useMemo(() => {
|
|
return selectableFields.find((f) => f.id === field);
|
|
}, [selectableFields, field]);
|
|
|
|
// 동적 옵션 로드 상태
|
|
const [dynamicOptions, setDynamicOptions] = useState<Array<{ value: string; label: string }>>([]);
|
|
const [loadingOptions, setLoadingOptions] = useState(false);
|
|
|
|
// Combobox 열림 상태
|
|
const [comboboxOpen, setComboboxOpen] = useState(false);
|
|
|
|
// 엔티티/공통코드 필드 선택 시 동적으로 옵션 로드
|
|
useEffect(() => {
|
|
const loadDynamicOptions = async () => {
|
|
if (!selectedField) {
|
|
setDynamicOptions([]);
|
|
return;
|
|
}
|
|
|
|
// 정적 옵션이 있으면 사용
|
|
if (selectedField.options && selectedField.options.length > 0) {
|
|
setDynamicOptions([]);
|
|
return;
|
|
}
|
|
|
|
// 엔티티 타입 (타입이 entity이거나, entityTable이 있으면 엔티티로 간주)
|
|
if (selectedField.entityTable) {
|
|
setLoadingOptions(true);
|
|
try {
|
|
const { apiClient } = await import("@/lib/api/client");
|
|
const valueCol = selectedField.entityValueColumn || "id";
|
|
const labelCol = selectedField.entityLabelColumn || "name";
|
|
const response = await apiClient.get(`/entity/${selectedField.entityTable}/options`, {
|
|
params: { value: valueCol, label: labelCol },
|
|
});
|
|
if (response.data.success && response.data.data) {
|
|
setDynamicOptions(response.data.data);
|
|
}
|
|
} catch (error) {
|
|
console.error("엔티티 옵션 로드 실패:", error);
|
|
setDynamicOptions([]);
|
|
} finally {
|
|
setLoadingOptions(false);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// 공통코드 타입 (타입이 code이거나, codeGroup이 있으면 공통코드로 간주)
|
|
if (selectedField.codeGroup) {
|
|
setLoadingOptions(true);
|
|
try {
|
|
const { apiClient } = await import("@/lib/api/client");
|
|
// 올바른 API 경로: /common-codes/categories/:categoryCode/options
|
|
const response = await apiClient.get(`/common-codes/categories/${selectedField.codeGroup}/options`);
|
|
if (response.data.success && response.data.data) {
|
|
setDynamicOptions(
|
|
response.data.data.map((item: { value: string; label: string }) => ({
|
|
value: item.value,
|
|
label: item.label,
|
|
}))
|
|
);
|
|
}
|
|
} catch (error) {
|
|
console.error("공통코드 옵션 로드 실패:", error);
|
|
setDynamicOptions([]);
|
|
} finally {
|
|
setLoadingOptions(false);
|
|
}
|
|
return;
|
|
}
|
|
|
|
setDynamicOptions([]);
|
|
};
|
|
|
|
loadDynamicOptions();
|
|
}, [selectedField?.id, selectedField?.entityTable, selectedField?.entityValueColumn, selectedField?.entityLabelColumn, selectedField?.codeGroup]);
|
|
|
|
// 최종 옵션 (정적 + 동적)
|
|
const fieldOptions = useMemo(() => {
|
|
if (selectedField?.options && selectedField.options.length > 0) {
|
|
return selectedField.options;
|
|
}
|
|
return dynamicOptions;
|
|
}, [selectedField?.options, dynamicOptions]);
|
|
|
|
// config prop 변경 시 로컬 상태 동기화
|
|
useEffect(() => {
|
|
setEnabled(config?.enabled ?? false);
|
|
setField(config?.field ?? "");
|
|
setOperator(config?.operator ?? "=");
|
|
setValue(String(config?.value ?? ""));
|
|
setAction(config?.action ?? "show");
|
|
}, [config]);
|
|
|
|
// 설정 변경 시 부모에게 알림
|
|
const updateConfig = (updates: Partial<ConditionalConfig>) => {
|
|
const newConfig: ConditionalConfig = {
|
|
enabled: updates.enabled ?? enabled,
|
|
field: updates.field ?? field,
|
|
operator: updates.operator ?? operator,
|
|
value: updates.value ?? value,
|
|
action: updates.action ?? action,
|
|
};
|
|
|
|
// enabled가 false이면 undefined 반환 (설정 제거)
|
|
if (!newConfig.enabled) {
|
|
onChange(undefined);
|
|
} else {
|
|
onChange(newConfig);
|
|
}
|
|
};
|
|
|
|
// 활성화 토글
|
|
const handleEnabledChange = (checked: boolean) => {
|
|
setEnabled(checked);
|
|
updateConfig({ enabled: checked });
|
|
};
|
|
|
|
// 조건 필드 변경
|
|
const handleFieldChange = (newField: string) => {
|
|
setField(newField);
|
|
setValue(""); // 필드 변경 시 값 초기화
|
|
updateConfig({ field: newField, value: "" });
|
|
};
|
|
|
|
// 연산자 변경
|
|
const handleOperatorChange = (newOperator: ConditionalConfig["operator"]) => {
|
|
setOperator(newOperator);
|
|
// 비어있음/값이있음 연산자는 value 필요 없음
|
|
if (newOperator === "isEmpty" || newOperator === "isNotEmpty") {
|
|
setValue("");
|
|
updateConfig({ operator: newOperator, value: "" });
|
|
} else {
|
|
updateConfig({ operator: newOperator });
|
|
}
|
|
};
|
|
|
|
// 값 변경
|
|
const handleValueChange = (newValue: string) => {
|
|
setValue(newValue);
|
|
|
|
// 타입에 따라 적절한 값으로 변환
|
|
let parsedValue: unknown = newValue;
|
|
if (selectedField?.type === "number") {
|
|
parsedValue = Number(newValue);
|
|
} else if (newValue === "true") {
|
|
parsedValue = true;
|
|
} else if (newValue === "false") {
|
|
parsedValue = false;
|
|
}
|
|
|
|
updateConfig({ value: parsedValue });
|
|
};
|
|
|
|
// 동작 변경
|
|
const handleActionChange = (newAction: ConditionalConfig["action"]) => {
|
|
setAction(newAction);
|
|
updateConfig({ action: newAction });
|
|
};
|
|
|
|
// 값 입력 필드 렌더링 (필드 타입에 따라 다르게)
|
|
const renderValueInput = () => {
|
|
// 비어있음/값이있음은 값 입력 불필요
|
|
if (operator === "isEmpty" || operator === "isNotEmpty") {
|
|
return (
|
|
<div className="text-xs text-muted-foreground italic">
|
|
(값 입력 불필요)
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 옵션 로딩 중
|
|
if (loadingOptions) {
|
|
return (
|
|
<div className="text-xs text-muted-foreground italic">
|
|
옵션 로딩 중...
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 옵션이 있으면 검색 가능한 Combobox로 표시
|
|
if (fieldOptions.length > 0) {
|
|
const selectedOption = fieldOptions.find((opt) => opt.value === value);
|
|
|
|
return (
|
|
<Popover open={comboboxOpen} onOpenChange={setComboboxOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
aria-expanded={comboboxOpen}
|
|
className="h-8 w-full justify-between text-xs font-normal"
|
|
>
|
|
<span className="truncate">
|
|
{selectedOption ? selectedOption.label : "값 선택"}
|
|
</span>
|
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-[300px] p-0" align="start">
|
|
<Command>
|
|
<CommandInput placeholder="검색..." className="h-8 text-xs" />
|
|
<CommandList>
|
|
<CommandEmpty className="py-2 text-center text-xs">
|
|
검색 결과가 없습니다
|
|
</CommandEmpty>
|
|
<CommandGroup>
|
|
{fieldOptions.map((opt) => (
|
|
<CommandItem
|
|
key={opt.value}
|
|
value={opt.label}
|
|
onSelect={() => {
|
|
handleValueChange(opt.value);
|
|
setComboboxOpen(false);
|
|
}}
|
|
className="text-xs"
|
|
>
|
|
<Check
|
|
className={cn(
|
|
"mr-2 h-3 w-3",
|
|
value === opt.value ? "opacity-100" : "opacity-0"
|
|
)}
|
|
/>
|
|
<span className="truncate">{opt.label}</span>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|
|
|
|
// 체크박스 타입이면 true/false Select
|
|
if (selectedField?.type === "checkbox" || selectedField?.type === "boolean") {
|
|
return (
|
|
<Select value={value} onValueChange={handleValueChange}>
|
|
<SelectTrigger className="h-8 text-xs">
|
|
<SelectValue placeholder="값 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="true" className="text-xs">체크됨</SelectItem>
|
|
<SelectItem value="false" className="text-xs">체크 안됨</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
);
|
|
}
|
|
|
|
// 숫자 타입
|
|
if (selectedField?.type === "number") {
|
|
return (
|
|
<Input
|
|
type="number"
|
|
value={value}
|
|
onChange={(e) => handleValueChange(e.target.value)}
|
|
placeholder="숫자 입력"
|
|
className="h-8 text-xs"
|
|
/>
|
|
);
|
|
}
|
|
|
|
// 기본: 텍스트 입력
|
|
return (
|
|
<Input
|
|
value={value}
|
|
onChange={(e) => handleValueChange(e.target.value)}
|
|
placeholder="값 입력"
|
|
className="h-8 text-xs"
|
|
/>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* 헤더 */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Zap className="h-4 w-4 text-orange-500" />
|
|
<span className="text-sm font-medium">조건부 표시</span>
|
|
<span
|
|
className="text-muted-foreground cursor-help"
|
|
title="다른 필드의 값에 따라 이 필드를 표시/숨김/활성화/비활성화할 수 있습니다."
|
|
>
|
|
<HelpCircle className="h-3 w-3" />
|
|
</span>
|
|
</div>
|
|
<Switch
|
|
checked={enabled}
|
|
onCheckedChange={handleEnabledChange}
|
|
aria-label="조건부 표시 활성화"
|
|
/>
|
|
</div>
|
|
|
|
{/* 조건 설정 영역 */}
|
|
{enabled && (
|
|
<div className="space-y-3 rounded-lg border bg-muted/30 p-3">
|
|
{/* 조건 필드 선택 */}
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-medium">조건 필드</Label>
|
|
<Select value={field} onValueChange={handleFieldChange}>
|
|
<SelectTrigger className="h-8 text-xs">
|
|
<SelectValue placeholder="필드 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{selectableFields.length === 0 ? (
|
|
<div className="p-2 text-xs text-muted-foreground">
|
|
선택 가능한 필드가 없습니다
|
|
</div>
|
|
) : (
|
|
selectableFields.map((f) => (
|
|
<SelectItem key={f.id} value={f.id} className="text-xs">
|
|
{f.label || f.id}
|
|
</SelectItem>
|
|
))
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
이 필드의 값에 따라 조건이 적용됩니다
|
|
</p>
|
|
</div>
|
|
|
|
{/* 연산자 선택 */}
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-medium">조건</Label>
|
|
<Select value={operator} onValueChange={handleOperatorChange}>
|
|
<SelectTrigger className="h-8 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{OPERATORS.map((op) => (
|
|
<SelectItem key={op.value} value={op.value} className="text-xs">
|
|
<div className="flex flex-col">
|
|
<span>{op.label}</span>
|
|
</div>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 값 입력 */}
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-medium">값</Label>
|
|
{renderValueInput()}
|
|
</div>
|
|
|
|
{/* 동작 선택 */}
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-medium">동작</Label>
|
|
<Select value={action} onValueChange={handleActionChange}>
|
|
<SelectTrigger className="h-8 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{ACTIONS.map((act) => (
|
|
<SelectItem key={act.value} value={act.value} className="text-xs">
|
|
<div className="flex flex-col">
|
|
<span>{act.label}</span>
|
|
</div>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
조건이 만족되면 이 필드를 {ACTIONS.find(a => a.value === action)?.label}합니다
|
|
</p>
|
|
</div>
|
|
|
|
{/* 미리보기 */}
|
|
{field && (
|
|
<div className="mt-3 rounded bg-slate-100 p-2">
|
|
<p className="text-[10px] font-medium text-slate-600">설정 요약:</p>
|
|
<p className="text-[11px] text-slate-800">
|
|
"{selectableFields.find(f => f.id === field)?.label || field}" 필드가{" "}
|
|
<span className="font-medium">
|
|
{operator === "isEmpty" ? "비어있으면" :
|
|
operator === "isNotEmpty" ? "값이 있으면" :
|
|
`"${value}"${operator === "=" ? "이면" :
|
|
operator === "!=" ? "이 아니면" :
|
|
operator === ">" ? "보다 크면" :
|
|
operator === "<" ? "보다 작으면" :
|
|
operator === "in" ? "에 포함되면" : "에 포함되지 않으면"}`}
|
|
</span>{" "}
|
|
→ 이 필드를{" "}
|
|
<span className="font-medium text-orange-600">
|
|
{action === "show" ? "표시" :
|
|
action === "hide" ? "숨김" :
|
|
action === "enable" ? "활성화" : "비활성화"}
|
|
</span>
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default ConditionalConfigPanel;
|
|
|