조건부 설정 구현
This commit is contained in:
parent
a717f97b34
commit
ac526c8578
|
|
@ -23,6 +23,7 @@ import { TableSearchWidgetHeightProvider, useTableSearchWidgetHeight } from "@/c
|
|||
import { ScreenContextProvider } from "@/contexts/ScreenContext"; // 컴포넌트 간 통신
|
||||
import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext"; // 분할 패널 리사이즈
|
||||
import { ActiveTabProvider } from "@/contexts/ActiveTabContext"; // 활성 탭 관리
|
||||
import { evaluateConditional } from "@/lib/utils/conditionalEvaluator"; // 조건부 표시 평가
|
||||
|
||||
function ScreenViewPage() {
|
||||
const params = useParams();
|
||||
|
|
@ -218,6 +219,67 @@ function ScreenViewPage() {
|
|||
initAutoFill();
|
||||
}, [layout, user]);
|
||||
|
||||
// 🆕 조건부 비활성화/숨김 시 해당 필드 값 초기화
|
||||
// 조건 필드들의 값을 추적하여 변경 시에만 실행
|
||||
const conditionalFieldValues = useMemo(() => {
|
||||
if (!layout?.components) return "";
|
||||
|
||||
// 조건부 설정에 사용되는 필드들의 현재 값을 JSON 문자열로 만들어 비교
|
||||
const conditionFields = new Set<string>();
|
||||
layout.components.forEach((component) => {
|
||||
const conditional = (component as any).conditional;
|
||||
if (conditional?.enabled && conditional.field) {
|
||||
conditionFields.add(conditional.field);
|
||||
}
|
||||
});
|
||||
|
||||
const values: Record<string, any> = {};
|
||||
conditionFields.forEach((field) => {
|
||||
values[field] = (formData as Record<string, any>)[field];
|
||||
});
|
||||
|
||||
return JSON.stringify(values);
|
||||
}, [layout?.components, formData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!layout?.components) return;
|
||||
|
||||
const fieldsToReset: string[] = [];
|
||||
|
||||
layout.components.forEach((component) => {
|
||||
const conditional = (component as any).conditional;
|
||||
if (!conditional?.enabled) return;
|
||||
|
||||
const conditionalResult = evaluateConditional(
|
||||
conditional,
|
||||
formData as Record<string, any>,
|
||||
layout.components,
|
||||
);
|
||||
|
||||
// 숨김 또는 비활성화 상태인 경우
|
||||
if (!conditionalResult.visible || conditionalResult.disabled) {
|
||||
const fieldName = (component as any).columnName || component.id;
|
||||
const currentValue = (formData as Record<string, any>)[fieldName];
|
||||
|
||||
// 값이 있으면 초기화 대상에 추가
|
||||
if (currentValue !== undefined && currentValue !== "" && currentValue !== null) {
|
||||
fieldsToReset.push(fieldName);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 초기화할 필드가 있으면 한 번에 처리
|
||||
if (fieldsToReset.length > 0) {
|
||||
setFormData((prev) => {
|
||||
const updated = { ...prev };
|
||||
fieldsToReset.forEach((fieldName) => {
|
||||
updated[fieldName] = "";
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
}, [conditionalFieldValues, layout?.components]);
|
||||
|
||||
// 캔버스 비율 조정 (사용자 화면에 맞게 자동 스케일) - 초기 로딩 시에만 계산
|
||||
// 브라우저 배율 조정 시 메뉴와 화면이 함께 축소/확대되도록 resize 이벤트는 감지하지 않음
|
||||
useEffect(() => {
|
||||
|
|
@ -469,9 +531,30 @@ function ScreenViewPage() {
|
|||
<>
|
||||
{/* 일반 컴포넌트들 */}
|
||||
{adjustedComponents.map((component) => {
|
||||
// 조건부 표시 설정이 있는 경우에만 평가
|
||||
const conditional = (component as any).conditional;
|
||||
let conditionalDisabled = false;
|
||||
|
||||
if (conditional?.enabled) {
|
||||
const conditionalResult = evaluateConditional(
|
||||
conditional,
|
||||
formData as Record<string, any>,
|
||||
layout?.components || [],
|
||||
);
|
||||
|
||||
// 조건에 따라 숨김 처리
|
||||
if (!conditionalResult.visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 조건에 따라 비활성화 처리
|
||||
conditionalDisabled = conditionalResult.disabled;
|
||||
}
|
||||
|
||||
// 화면 관리 해상도를 사용하므로 위치 조정 불필요
|
||||
return (
|
||||
<RealtimePreview
|
||||
conditionalDisabled={conditionalDisabled}
|
||||
key={component.id}
|
||||
component={component}
|
||||
isSelected={false}
|
||||
|
|
|
|||
|
|
@ -19,84 +19,7 @@ import { FlowVisibilityConfig } from "@/types/control-management";
|
|||
import { findAllButtonGroups } from "@/lib/utils/flowButtonGroupUtils";
|
||||
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
||||
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
|
||||
|
||||
// 조건부 표시 평가 함수
|
||||
function evaluateConditional(
|
||||
conditional: ComponentData["conditional"],
|
||||
formData: Record<string, any>,
|
||||
allComponents: ComponentData[],
|
||||
): { visible: boolean; disabled: boolean } {
|
||||
if (!conditional || !conditional.enabled) {
|
||||
return { visible: true, disabled: false };
|
||||
}
|
||||
|
||||
const { field, operator, value, action } = conditional;
|
||||
|
||||
// 참조 필드의 현재 값 가져오기
|
||||
// 필드 ID로 컴포넌트를 찾아 columnName 또는 id로 formData에서 값 조회
|
||||
const refComponent = allComponents.find((c) => c.id === field);
|
||||
const fieldName = (refComponent as any)?.columnName || field;
|
||||
const fieldValue = formData[fieldName];
|
||||
|
||||
// 조건 평가
|
||||
let conditionMet = false;
|
||||
switch (operator) {
|
||||
case "=":
|
||||
conditionMet = fieldValue === value || String(fieldValue) === String(value);
|
||||
break;
|
||||
case "!=":
|
||||
conditionMet = fieldValue !== value && String(fieldValue) !== String(value);
|
||||
break;
|
||||
case ">":
|
||||
conditionMet = Number(fieldValue) > Number(value);
|
||||
break;
|
||||
case "<":
|
||||
conditionMet = Number(fieldValue) < Number(value);
|
||||
break;
|
||||
case "in":
|
||||
if (Array.isArray(value)) {
|
||||
conditionMet = value.includes(fieldValue) || value.map(String).includes(String(fieldValue));
|
||||
}
|
||||
break;
|
||||
case "notIn":
|
||||
if (Array.isArray(value)) {
|
||||
conditionMet = !value.includes(fieldValue) && !value.map(String).includes(String(fieldValue));
|
||||
} else {
|
||||
conditionMet = true;
|
||||
}
|
||||
break;
|
||||
case "isEmpty":
|
||||
conditionMet =
|
||||
fieldValue === null ||
|
||||
fieldValue === undefined ||
|
||||
fieldValue === "" ||
|
||||
(Array.isArray(fieldValue) && fieldValue.length === 0);
|
||||
break;
|
||||
case "isNotEmpty":
|
||||
conditionMet =
|
||||
fieldValue !== null &&
|
||||
fieldValue !== undefined &&
|
||||
fieldValue !== "" &&
|
||||
!(Array.isArray(fieldValue) && fieldValue.length === 0);
|
||||
break;
|
||||
default:
|
||||
conditionMet = true;
|
||||
}
|
||||
|
||||
// 액션에 따른 결과 반환
|
||||
switch (action) {
|
||||
case "show":
|
||||
return { visible: conditionMet, disabled: false };
|
||||
case "hide":
|
||||
return { visible: !conditionMet, disabled: false };
|
||||
case "enable":
|
||||
return { visible: true, disabled: !conditionMet };
|
||||
case "disable":
|
||||
return { visible: true, disabled: conditionMet };
|
||||
default:
|
||||
return { visible: true, disabled: false };
|
||||
}
|
||||
}
|
||||
import { evaluateConditional } from "@/lib/utils/conditionalEvaluator";
|
||||
|
||||
// 컴포넌트 렌더러들을 강제로 로드하여 레지스트리에 등록
|
||||
import "@/lib/registry/components/ButtonRenderer";
|
||||
|
|
|
|||
|
|
@ -64,6 +64,9 @@ interface RealtimePreviewProps {
|
|||
|
||||
// 🆕 조건부 컨테이너 높이 변화 콜백
|
||||
onHeightChange?: (componentId: string, newHeight: number) => void;
|
||||
|
||||
// 🆕 조건부 비활성화 상태
|
||||
conditionalDisabled?: boolean;
|
||||
}
|
||||
|
||||
// 동적 위젯 타입 아이콘 (레지스트리에서 조회)
|
||||
|
|
@ -93,7 +96,7 @@ const getWidgetIcon = (widgetType: WebType | undefined): React.ReactNode => {
|
|||
return iconMap[widgetType] || <Type className="h-3 w-3" />;
|
||||
};
|
||||
|
||||
export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||
const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
||||
component,
|
||||
isSelected = false,
|
||||
isDesignMode = true, // 기본값은 편집 모드
|
||||
|
|
@ -128,6 +131,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
formData,
|
||||
onFormDataChange,
|
||||
onHeightChange, // 🆕 조건부 컨테이너 높이 변화 콜백
|
||||
conditionalDisabled, // 🆕 조건부 비활성화 상태
|
||||
}) => {
|
||||
const [actualHeight, setActualHeight] = React.useState<number | null>(null);
|
||||
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||
|
|
@ -509,6 +513,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
sortOrder={sortOrder}
|
||||
columnOrder={columnOrder}
|
||||
onHeightChange={onHeightChange}
|
||||
conditionalDisabled={conditionalDisabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -532,6 +537,12 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
);
|
||||
};
|
||||
|
||||
// React.memo로 래핑하여 불필요한 리렌더링 방지
|
||||
export const RealtimePreviewDynamic = React.memo(RealtimePreviewDynamicComponent);
|
||||
|
||||
// displayName 설정 (디버깅용)
|
||||
RealtimePreviewDynamic.displayName = "RealtimePreviewDynamic";
|
||||
|
||||
// 기존 RealtimePreview와의 호환성을 위한 export
|
||||
export { RealtimePreviewDynamic as RealtimePreview };
|
||||
export default RealtimePreviewDynamic;
|
||||
|
|
|
|||
|
|
@ -1548,18 +1548,67 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
action: "show",
|
||||
}
|
||||
}
|
||||
onChange={(newConfig: ConditionalConfig) => {
|
||||
onChange={(newConfig: ConditionalConfig | undefined) => {
|
||||
handleUpdate("conditional", newConfig);
|
||||
}}
|
||||
availableFields={
|
||||
allComponents
|
||||
?.filter((c) => c.type === "widget" && c.id !== selectedComponent.id)
|
||||
.map((c) => ({
|
||||
id: (c as any).columnName || c.id,
|
||||
label: (c as any).label || c.id,
|
||||
type: (c as any).widgetType || "text",
|
||||
})) || []
|
||||
?.filter((c) => {
|
||||
// 자기 자신 제외
|
||||
if (c.id === selectedComponent.id) return false;
|
||||
// widget 타입 또는 component 타입 (Unified 컴포넌트 포함)
|
||||
return c.type === "widget" || c.type === "component";
|
||||
})
|
||||
.map((c) => {
|
||||
const widgetType = (c as any).widgetType || (c as any).componentType || "text";
|
||||
const config = (c as any).componentConfig || (c as any).webTypeConfig || {};
|
||||
const detailSettings = (c as any).detailSettings || {};
|
||||
|
||||
// 정적 옵션 추출 (select, dropdown, radio, entity 등)
|
||||
let options: Array<{ value: string; label: string }> | undefined;
|
||||
|
||||
// Unified 컴포넌트의 경우
|
||||
if (config.options && Array.isArray(config.options)) {
|
||||
options = config.options;
|
||||
}
|
||||
// 레거시 컴포넌트의 경우
|
||||
else if ((c as any).options && Array.isArray((c as any).options)) {
|
||||
options = (c as any).options;
|
||||
}
|
||||
|
||||
// 엔티티 정보 추출 (config > detailSettings > 직접 속성 순으로 우선순위)
|
||||
const entityTable =
|
||||
config.entityTable ||
|
||||
detailSettings.referenceTable ||
|
||||
(c as any).entityTable ||
|
||||
(c as any).referenceTable;
|
||||
const entityValueColumn =
|
||||
config.entityValueColumn ||
|
||||
detailSettings.referenceColumn ||
|
||||
(c as any).entityValueColumn ||
|
||||
(c as any).referenceColumn;
|
||||
const entityLabelColumn =
|
||||
config.entityLabelColumn ||
|
||||
detailSettings.displayColumn ||
|
||||
(c as any).entityLabelColumn ||
|
||||
(c as any).displayColumn;
|
||||
|
||||
// 공통코드 정보 추출
|
||||
const codeGroup = config.codeGroup || detailSettings.codeGroup || (c as any).codeGroup;
|
||||
|
||||
return {
|
||||
id: (c as any).columnName || c.id,
|
||||
label: (c as any).label || config.label || c.id,
|
||||
type: widgetType,
|
||||
options,
|
||||
entityTable,
|
||||
entityValueColumn,
|
||||
entityLabelColumn,
|
||||
codeGroup,
|
||||
};
|
||||
}) || []
|
||||
}
|
||||
currentComponentId={selectedComponent.id}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -17,16 +17,24 @@ 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 { Zap, Plus, Trash2, HelpCircle } from "lucide-react";
|
||||
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 등
|
||||
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 {
|
||||
|
|
@ -85,6 +93,86 @@ export function ConditionalConfigPanel({
|
|||
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");
|
||||
const response = await apiClient.get(`/common-codes/${selectedField.codeGroup}/items`);
|
||||
if (response.data.success && response.data.data) {
|
||||
setDynamicOptions(
|
||||
response.data.data.map((item: { code: string; codeName: string }) => ({
|
||||
value: item.code,
|
||||
label: item.codeName,
|
||||
}))
|
||||
);
|
||||
}
|
||||
} 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);
|
||||
|
|
@ -171,21 +259,66 @@ export function ConditionalConfigPanel({
|
|||
);
|
||||
}
|
||||
|
||||
// 선택된 필드에 옵션이 있으면 Select로 표시
|
||||
if (selectedField?.options && selectedField.options.length > 0) {
|
||||
// 옵션 로딩 중
|
||||
if (loadingOptions) {
|
||||
return (
|
||||
<Select value={value} onValueChange={handleValueChange}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="값 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{selectedField.options.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value} className="text-xs">
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -460,24 +460,31 @@ export const UnifiedSelect = forwardRef<HTMLDivElement, UnifiedSelectProps>(
|
|||
|
||||
const [options, setOptions] = useState<SelectOption[]>(config.options || []);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [optionsLoaded, setOptionsLoaded] = useState(false);
|
||||
|
||||
// 데이터 소스에 따른 옵션 로딩
|
||||
// 옵션 로딩에 필요한 값들만 추출 (객체 참조 대신 원시값 사용)
|
||||
const source = config.source;
|
||||
const entityTable = config.entityTable;
|
||||
const entityValueColumn = config.entityValueColumn || config.entityValueField;
|
||||
const entityLabelColumn = config.entityLabelColumn || config.entityLabelField;
|
||||
const codeGroup = config.codeGroup;
|
||||
const table = config.table;
|
||||
const valueColumn = config.valueColumn;
|
||||
const labelColumn = config.labelColumn;
|
||||
const apiEndpoint = config.apiEndpoint;
|
||||
const staticOptions = config.options;
|
||||
|
||||
// 데이터 소스에 따른 옵션 로딩 (원시값 의존성만 사용)
|
||||
useEffect(() => {
|
||||
const loadOptions = async () => {
|
||||
console.log("🎯 UnifiedSelect 전체 props:", props);
|
||||
console.log("🎯 UnifiedSelect config:", config);
|
||||
console.log("🎯 UnifiedSelect loadOptions 호출:", {
|
||||
source: config.source,
|
||||
entityTable: config.entityTable,
|
||||
entityValueColumn: config.entityValueColumn,
|
||||
entityLabelColumn: config.entityLabelColumn,
|
||||
codeGroup: config.codeGroup,
|
||||
table: config.table,
|
||||
config,
|
||||
});
|
||||
// 이미 로드된 경우 스킵 (static 제외)
|
||||
if (optionsLoaded && source !== "static") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (config.source === "static") {
|
||||
setOptions(config.options || []);
|
||||
const loadOptions = async () => {
|
||||
if (source === "static") {
|
||||
setOptions(staticOptions || []);
|
||||
setOptionsLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -485,9 +492,9 @@ export const UnifiedSelect = forwardRef<HTMLDivElement, UnifiedSelectProps>(
|
|||
try {
|
||||
let fetchedOptions: SelectOption[] = [];
|
||||
|
||||
if (config.source === "code" && config.codeGroup) {
|
||||
if (source === "code" && codeGroup) {
|
||||
// 공통코드에서 로드
|
||||
const response = await apiClient.get(`/common-codes/${config.codeGroup}/items`);
|
||||
const response = await apiClient.get(`/common-codes/${codeGroup}/items`);
|
||||
const data = response.data;
|
||||
if (data.success && data.data) {
|
||||
fetchedOptions = data.data.map((item: { code: string; codeName: string }) => ({
|
||||
|
|
@ -495,37 +502,35 @@ export const UnifiedSelect = forwardRef<HTMLDivElement, UnifiedSelectProps>(
|
|||
label: item.codeName,
|
||||
}));
|
||||
}
|
||||
} else if (config.source === "db" && config.table) {
|
||||
} else if (source === "db" && table) {
|
||||
// DB 테이블에서 로드
|
||||
const response = await apiClient.get(`/entity/${config.table}/options`, {
|
||||
const response = await apiClient.get(`/entity/${table}/options`, {
|
||||
params: {
|
||||
value: config.valueColumn || "id",
|
||||
label: config.labelColumn || "name",
|
||||
value: valueColumn || "id",
|
||||
label: labelColumn || "name",
|
||||
},
|
||||
});
|
||||
const data = response.data;
|
||||
if (data.success && data.data) {
|
||||
fetchedOptions = data.data;
|
||||
}
|
||||
} else if (config.source === "entity" && config.entityTable) {
|
||||
} else if (source === "entity" && entityTable) {
|
||||
// 엔티티(참조 테이블)에서 로드
|
||||
const valueCol = config.entityValueColumn || config.entityValueField || "id";
|
||||
const labelCol = config.entityLabelColumn || config.entityLabelField || "name";
|
||||
console.log("🔍 Entity 옵션 API 호출:", `/entity/${config.entityTable}/options`, { value: valueCol, label: labelCol });
|
||||
const response = await apiClient.get(`/entity/${config.entityTable}/options`, {
|
||||
const valueCol = entityValueColumn || "id";
|
||||
const labelCol = entityLabelColumn || "name";
|
||||
const response = await apiClient.get(`/entity/${entityTable}/options`, {
|
||||
params: {
|
||||
value: valueCol,
|
||||
label: labelCol,
|
||||
},
|
||||
});
|
||||
const data = response.data;
|
||||
console.log("🔍 Entity 옵션 API 응답:", data);
|
||||
if (data.success && data.data) {
|
||||
fetchedOptions = data.data;
|
||||
}
|
||||
} else if (config.source === "api" && config.apiEndpoint) {
|
||||
} else if (source === "api" && apiEndpoint) {
|
||||
// 외부 API에서 로드
|
||||
const response = await apiClient.get(config.apiEndpoint);
|
||||
const response = await apiClient.get(apiEndpoint);
|
||||
const data = response.data;
|
||||
if (Array.isArray(data)) {
|
||||
fetchedOptions = data;
|
||||
|
|
@ -533,6 +538,7 @@ export const UnifiedSelect = forwardRef<HTMLDivElement, UnifiedSelectProps>(
|
|||
}
|
||||
|
||||
setOptions(fetchedOptions);
|
||||
setOptionsLoaded(true);
|
||||
} catch (error) {
|
||||
console.error("옵션 로딩 실패:", error);
|
||||
setOptions([]);
|
||||
|
|
@ -542,7 +548,7 @@ export const UnifiedSelect = forwardRef<HTMLDivElement, UnifiedSelectProps>(
|
|||
};
|
||||
|
||||
loadOptions();
|
||||
}, [config]);
|
||||
}, [source, entityTable, entityValueColumn, entityLabelColumn, codeGroup, table, valueColumn, labelColumn, apiEndpoint, staticOptions, optionsLoaded]);
|
||||
|
||||
// 모드별 컴포넌트 렌더링
|
||||
const renderSelect = () => {
|
||||
|
|
|
|||
|
|
@ -148,6 +148,8 @@ export interface DynamicComponentRendererProps {
|
|||
// 탭 관련 정보 (탭 내부의 컴포넌트에서 사용)
|
||||
parentTabId?: string; // 부모 탭 ID
|
||||
parentTabsComponentId?: string; // 부모 탭 컴포넌트 ID
|
||||
// 🆕 조건부 비활성화 상태
|
||||
conditionalDisabled?: boolean;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
|
|
@ -183,7 +185,8 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
label: (component as any).label,
|
||||
required: (component as any).required,
|
||||
readonly: (component as any).readonly,
|
||||
disabled: (component as any).disabled || props.disabledFields?.includes(fieldName),
|
||||
// conditionalDisabled가 true이면 비활성화
|
||||
disabled: (component as any).disabled || props.disabledFields?.includes(fieldName) || props.conditionalDisabled,
|
||||
value: currentValue,
|
||||
onChange: handleChange,
|
||||
tableName: (component as any).tableName || props.tableName,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,118 @@
|
|||
/**
|
||||
* 조건부 표시 평가 유틸리티
|
||||
* 컴포넌트의 조건부 표시 설정을 평가하여 visible/disabled 상태를 반환합니다.
|
||||
*/
|
||||
|
||||
import { ComponentData } from "@/types/screen";
|
||||
|
||||
export interface ConditionalResult {
|
||||
visible: boolean;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export interface ConditionalConfig {
|
||||
enabled: boolean;
|
||||
field: string;
|
||||
operator: "=" | "!=" | ">" | "<" | "in" | "notIn" | "isEmpty" | "isNotEmpty";
|
||||
value: string | string[];
|
||||
action: "show" | "hide" | "enable" | "disable";
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건부 표시를 평가합니다.
|
||||
* @param conditional - 컴포넌트의 조건부 설정
|
||||
* @param formData - 현재 폼 데이터
|
||||
* @param allComponents - 화면의 모든 컴포넌트 (필드 참조용)
|
||||
* @returns visible/disabled 상태
|
||||
*/
|
||||
export function evaluateConditional(
|
||||
conditional: ConditionalConfig | undefined,
|
||||
formData: Record<string, any>,
|
||||
allComponents: ComponentData[],
|
||||
): ConditionalResult {
|
||||
// 조건부 설정이 없거나 비활성화된 경우 기본값 반환
|
||||
if (!conditional || !conditional.enabled) {
|
||||
return { visible: true, disabled: false };
|
||||
}
|
||||
|
||||
const { field, operator, value, action } = conditional;
|
||||
|
||||
// 필드가 설정되지 않은 경우 기본값 반환
|
||||
if (!field) {
|
||||
console.warn("[evaluateConditional] 조건 필드가 설정되지 않음");
|
||||
return { visible: true, disabled: false };
|
||||
}
|
||||
|
||||
// 참조 필드의 현재 값 가져오기
|
||||
// field 값은 columnName 또는 id일 수 있으므로 양쪽으로 찾기
|
||||
const refComponent = allComponents.find((c) => {
|
||||
const columnName = (c as any)?.columnName;
|
||||
return c.id === field || columnName === field;
|
||||
});
|
||||
|
||||
// formData에서 값 조회: columnName 우선, 없으면 field 값 직접 사용
|
||||
const fieldName = (refComponent as any)?.columnName || field;
|
||||
const fieldValue = formData[fieldName];
|
||||
|
||||
// 조건 평가
|
||||
let conditionMet = false;
|
||||
switch (operator) {
|
||||
case "=":
|
||||
conditionMet = fieldValue === value || String(fieldValue) === String(value);
|
||||
break;
|
||||
case "!=":
|
||||
conditionMet = fieldValue !== value && String(fieldValue) !== String(value);
|
||||
break;
|
||||
case ">":
|
||||
conditionMet = Number(fieldValue) > Number(value);
|
||||
break;
|
||||
case "<":
|
||||
conditionMet = Number(fieldValue) < Number(value);
|
||||
break;
|
||||
case "in":
|
||||
if (Array.isArray(value)) {
|
||||
conditionMet = value.includes(fieldValue) || value.map(String).includes(String(fieldValue));
|
||||
}
|
||||
break;
|
||||
case "notIn":
|
||||
if (Array.isArray(value)) {
|
||||
conditionMet = !value.includes(fieldValue) && !value.map(String).includes(String(fieldValue));
|
||||
} else {
|
||||
conditionMet = true;
|
||||
}
|
||||
break;
|
||||
case "isEmpty":
|
||||
conditionMet =
|
||||
fieldValue === null ||
|
||||
fieldValue === undefined ||
|
||||
fieldValue === "" ||
|
||||
(Array.isArray(fieldValue) && fieldValue.length === 0);
|
||||
break;
|
||||
case "isNotEmpty":
|
||||
conditionMet =
|
||||
fieldValue !== null &&
|
||||
fieldValue !== undefined &&
|
||||
fieldValue !== "" &&
|
||||
!(Array.isArray(fieldValue) && fieldValue.length === 0);
|
||||
break;
|
||||
default:
|
||||
conditionMet = true;
|
||||
}
|
||||
|
||||
// 액션에 따른 결과 반환
|
||||
switch (action) {
|
||||
case "show":
|
||||
// 조건이 참이면 표시, 거짓이면 숨김
|
||||
return { visible: conditionMet, disabled: false };
|
||||
case "hide":
|
||||
// 조건이 참이면 숨김, 거짓이면 표시
|
||||
return { visible: !conditionMet, disabled: false };
|
||||
case "enable":
|
||||
return { visible: true, disabled: !conditionMet };
|
||||
case "disable":
|
||||
return { visible: true, disabled: conditionMet };
|
||||
default:
|
||||
return { visible: true, disabled: false };
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue