ERP-node/frontend/components/screen/LayerConditionPanel.tsx

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>
);
};