Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into feature/v2-unified-renewal

This commit is contained in:
kjs
2026-02-06 10:20:45 +09:00
parent 4e2209bd5d
commit f2bee41336
8 changed files with 820 additions and 292 deletions

View File

@@ -249,8 +249,18 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
layers.forEach((layer) => {
if (layer.type === "conditional" && layer.condition) {
const { targetComponentId, operator, value } = layer.condition;
// 컴포넌트 ID를 키로 데이터 조회 - columnName 매핑이 필요할 수 있음
const targetValue = finalFormData[targetComponentId];
// 1. 컴포넌트 ID로 대상 컴포넌트 찾기
const targetComponent = allComponents.find((c) => c.id === targetComponentId);
// 2. 컴포넌트의 columnName으로 formData에서 값 조회
// columnName이 없으면 컴포넌트 ID로 폴백
const fieldKey =
(targetComponent as any)?.columnName ||
(targetComponent as any)?.componentConfig?.columnName ||
targetComponentId;
const targetValue = finalFormData[fieldKey];
let isMatch = false;
switch (operator) {
@@ -272,7 +282,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
}
}
});
}, [finalFormData, layers, handleLayerAction]);
}, [finalFormData, layers, allComponents, handleLayerAction]);
// 개선된 검증 시스템 (선택적 활성화)
const enhancedValidation = enableEnhancedValidation && screenInfo && tableColumns.length > 0

View File

@@ -0,0 +1,371 @@
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>
);
};

View File

@@ -1,4 +1,4 @@
import React, { useState, useMemo } from "react";
import React, { useState, useMemo, useCallback } from "react";
import { useLayer } from "@/contexts/LayerContext";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
@@ -10,6 +10,11 @@ import {
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { Badge } from "@/components/ui/badge";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Eye,
EyeOff,
@@ -22,10 +27,13 @@ import {
SplitSquareVertical,
PanelRight,
ChevronDown,
ChevronRight,
Settings2,
Zap,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { LayerType, LayerDefinition, ComponentData } from "@/types/screen-management";
import { LayerType, LayerDefinition, ComponentData, LayerCondition } from "@/types/screen-management";
import { LayerConditionPanel } from "./LayerConditionPanel";
// 레이어 타입별 아이콘
const getLayerTypeIcon = (type: LayerType) => {
@@ -78,137 +86,196 @@ function getLayerTypeColor(type: LayerType): string {
interface LayerItemProps {
layer: LayerDefinition;
isActive: boolean;
componentCount: number; // 🆕 실제 컴포넌트 수 (layout.components 기반)
componentCount: number; // 실제 컴포넌트 수 (layout.components 기반)
allComponents: ComponentData[]; // 조건 설정에 필요한 전체 컴포넌트
onSelect: () => void;
onToggleVisibility: () => void;
onToggleLock: () => void;
onRemove: () => void;
onUpdateName: (name: string) => void;
onUpdateCondition: (condition: LayerCondition | undefined) => void;
}
const LayerItem: React.FC<LayerItemProps> = ({
layer,
isActive,
componentCount,
allComponents,
onSelect,
onToggleVisibility,
onToggleLock,
onRemove,
onUpdateName,
onUpdateCondition,
}) => {
const [isEditing, setIsEditing] = useState(false);
const [isConditionOpen, setIsConditionOpen] = useState(false);
// 조건부 레이어인지 확인
const isConditionalLayer = layer.type === "conditional";
// 조건 설정 여부
const hasCondition = !!layer.condition;
return (
<div
className={cn(
"flex items-center gap-2 rounded-md border p-2 text-sm transition-all cursor-pointer",
isActive
? "border-primary bg-primary/5 shadow-sm"
: "hover:bg-muted border-transparent",
!layer.isVisible && "opacity-50",
)}
onClick={onSelect}
>
{/* 드래그 핸들 */}
<GripVertical className="text-muted-foreground h-4 w-4 cursor-grab flex-shrink-0" />
<div className="space-y-0">
{/* 레이어 메인 영역 */}
<div
className={cn(
"flex items-center gap-2 rounded-md border p-2 text-sm transition-all cursor-pointer",
isActive
? "border-primary bg-primary/5 shadow-sm"
: "hover:bg-muted border-transparent",
!layer.isVisible && "opacity-50",
isConditionOpen && "rounded-b-none border-b-0",
)}
onClick={onSelect}
>
{/* 드래그 핸들 */}
<GripVertical className="text-muted-foreground h-4 w-4 cursor-grab flex-shrink-0" />
{/* 레이어 정보 */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
{/* 레이어 타입 아이콘 */}
<span className={cn("flex-shrink-0", getLayerTypeColor(layer.type), "p-1 rounded")}>
{getLayerTypeIcon(layer.type)}
</span>
{/* 레이어 정보 */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
{/* 레이어 타입 아이콘 */}
<span className={cn("flex-shrink-0", getLayerTypeColor(layer.type), "p-1 rounded")}>
{getLayerTypeIcon(layer.type)}
</span>
{/* 레이어 이름 */}
{isEditing ? (
<input
type="text"
value={layer.name}
onChange={(e) => onUpdateName(e.target.value)}
onBlur={() => setIsEditing(false)}
onKeyDown={(e) => {
if (e.key === "Enter") setIsEditing(false);
}}
className="flex-1 bg-transparent outline-none border-b border-primary text-sm"
autoFocus
onClick={(e) => e.stopPropagation()}
/>
) : (
<span
className="flex-1 truncate font-medium"
onDoubleClick={(e) => {
e.stopPropagation();
setIsEditing(true);
}}
>
{layer.name}
</span>
)}
</div>
{/* 레이어 이름 */}
{isEditing ? (
<input
type="text"
value={layer.name}
onChange={(e) => onUpdateName(e.target.value)}
onBlur={() => setIsEditing(false)}
onKeyDown={(e) => {
if (e.key === "Enter") setIsEditing(false);
}}
className="flex-1 bg-transparent outline-none border-b border-primary text-sm"
autoFocus
onClick={(e) => e.stopPropagation()}
/>
) : (
<span
className="flex-1 truncate font-medium"
onDoubleClick={(e) => {
{/* 레이어 메타 정보 */}
<div className="flex items-center gap-2 mt-0.5">
<Badge variant="outline" className="text-[10px] px-1 py-0 h-4">
{getLayerTypeLabel(layer.type)}
</Badge>
<span className="text-muted-foreground text-[10px]">
{componentCount}
</span>
{/* 조건 설정됨 표시 */}
{hasCondition && (
<Badge variant="secondary" className="text-[10px] px-1 py-0 h-4 gap-0.5">
<Zap className="h-2.5 w-2.5" />
</Badge>
)}
</div>
</div>
{/* 액션 버튼들 */}
<div className="flex items-center gap-0.5 flex-shrink-0">
{/* 조건부 레이어일 때 조건 설정 버튼 */}
{isConditionalLayer && (
<Button
variant="ghost"
size="icon"
className={cn(
"h-6 w-6",
hasCondition && "text-amber-600"
)}
title="조건 설정"
onClick={(e) => {
e.stopPropagation();
setIsEditing(true);
setIsConditionOpen(!isConditionOpen);
}}
>
{layer.name}
</span>
{isConditionOpen ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</Button>
)}
</div>
{/* 레이어 메타 정보 */}
<div className="flex items-center gap-2 mt-0.5">
<Badge variant="outline" className="text-[10px] px-1 py-0 h-4">
{getLayerTypeLabel(layer.type)}
</Badge>
<span className="text-muted-foreground text-[10px]">
{componentCount}
</span>
</div>
</div>
{/* 액션 버튼들 */}
<div className="flex items-center gap-0.5 flex-shrink-0">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
title={layer.isVisible ? "레이어 숨기기" : "레이어 표시"}
onClick={(e) => {
e.stopPropagation();
onToggleVisibility();
}}
>
{layer.isVisible ? (
<Eye className="h-3.5 w-3.5" />
) : (
<EyeOff className="text-muted-foreground h-3.5 w-3.5" />
)}
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
title={layer.isLocked ? "편집 잠금 해제" : "편집 잠금"}
onClick={(e) => {
e.stopPropagation();
onToggleLock();
}}
>
{layer.isLocked ? (
<Lock className="text-destructive h-3.5 w-3.5" />
) : (
<Unlock className="text-muted-foreground h-3.5 w-3.5" />
)}
</Button>
{layer.type !== "base" && (
<Button
variant="ghost"
size="icon"
className="hover:text-destructive h-6 w-6"
title="레이어 삭제"
className="h-6 w-6"
title={layer.isVisible ? "레이어 숨기기" : "레이어 표시"}
onClick={(e) => {
e.stopPropagation();
onRemove();
onToggleVisibility();
}}
>
<Trash2 className="h-3.5 w-3.5" />
{layer.isVisible ? (
<Eye className="h-3.5 w-3.5" />
) : (
<EyeOff className="text-muted-foreground h-3.5 w-3.5" />
)}
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
title={layer.isLocked ? "편집 잠금 해제" : "편집 잠금"}
onClick={(e) => {
e.stopPropagation();
onToggleLock();
}}
>
{layer.isLocked ? (
<Lock className="text-destructive h-3.5 w-3.5" />
) : (
<Unlock className="text-muted-foreground h-3.5 w-3.5" />
)}
</Button>
{layer.type !== "base" && (
<Button
variant="ghost"
size="icon"
className="hover:text-destructive h-6 w-6"
title="레이어 삭제"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
{/* 조건 설정 패널 (조건부 레이어만) */}
{isConditionalLayer && isConditionOpen && (
<div className={cn(
"border border-t-0 rounded-b-md bg-muted/30",
isActive ? "border-primary" : "border-border"
)}>
<LayerConditionPanel
layer={layer}
components={allComponents}
onUpdateCondition={onUpdateCondition}
onClose={() => setIsConditionOpen(false)}
/>
</div>
)}
</div>
);
};
@@ -229,6 +296,11 @@ export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({ components
updateLayer,
} = useLayer();
// 레이어 조건 업데이트 핸들러
const handleUpdateCondition = useCallback((layerId: string, condition: LayerCondition | undefined) => {
updateLayer(layerId, { condition });
}, [updateLayer]);
// 🆕 각 레이어별 컴포넌트 수 계산 (layout.components 기반)
const componentCountByLayer = useMemo(() => {
const counts: Record<string, number> = {};
@@ -311,11 +383,13 @@ export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({ components
layer={layer}
isActive={activeLayerId === layer.id}
componentCount={componentCountByLayer[layer.id] || 0}
allComponents={components}
onSelect={() => setActiveLayerId(layer.id)}
onToggleVisibility={() => toggleLayerVisibility(layer.id)}
onToggleLock={() => toggleLayerLock(layer.id)}
onRemove={() => removeLayer(layer.id)}
onUpdateName={(name) => updateLayer(layer.id, { name })}
onUpdateCondition={(condition) => handleUpdateCondition(layer.id, condition)}
/>
))
)}