feature/v2-unified-renewal #379
|
|
@ -23,6 +23,7 @@ import { TableSearchWidgetHeightProvider, useTableSearchWidgetHeight } from "@/c
|
||||||
import { ScreenContextProvider } from "@/contexts/ScreenContext"; // 컴포넌트 간 통신
|
import { ScreenContextProvider } from "@/contexts/ScreenContext"; // 컴포넌트 간 통신
|
||||||
import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext"; // 분할 패널 리사이즈
|
import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext"; // 분할 패널 리사이즈
|
||||||
import { ActiveTabProvider } from "@/contexts/ActiveTabContext"; // 활성 탭 관리
|
import { ActiveTabProvider } from "@/contexts/ActiveTabContext"; // 활성 탭 관리
|
||||||
|
import { evaluateConditional } from "@/lib/utils/conditionalEvaluator"; // 조건부 표시 평가
|
||||||
|
|
||||||
function ScreenViewPage() {
|
function ScreenViewPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
|
@ -218,6 +219,67 @@ function ScreenViewPage() {
|
||||||
initAutoFill();
|
initAutoFill();
|
||||||
}, [layout, user]);
|
}, [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 이벤트는 감지하지 않음
|
// 브라우저 배율 조정 시 메뉴와 화면이 함께 축소/확대되도록 resize 이벤트는 감지하지 않음
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -469,9 +531,30 @@ function ScreenViewPage() {
|
||||||
<>
|
<>
|
||||||
{/* 일반 컴포넌트들 */}
|
{/* 일반 컴포넌트들 */}
|
||||||
{adjustedComponents.map((component) => {
|
{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 (
|
return (
|
||||||
<RealtimePreview
|
<RealtimePreview
|
||||||
|
conditionalDisabled={conditionalDisabled}
|
||||||
key={component.id}
|
key={component.id}
|
||||||
component={component}
|
component={component}
|
||||||
isSelected={false}
|
isSelected={false}
|
||||||
|
|
|
||||||
|
|
@ -19,84 +19,7 @@ import { FlowVisibilityConfig } from "@/types/control-management";
|
||||||
import { findAllButtonGroups } from "@/lib/utils/flowButtonGroupUtils";
|
import { findAllButtonGroups } from "@/lib/utils/flowButtonGroupUtils";
|
||||||
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
||||||
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
|
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
|
||||||
|
import { evaluateConditional } from "@/lib/utils/conditionalEvaluator";
|
||||||
// 조건부 표시 평가 함수
|
|
||||||
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 "@/lib/registry/components/ButtonRenderer";
|
import "@/lib/registry/components/ButtonRenderer";
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,9 @@ interface RealtimePreviewProps {
|
||||||
|
|
||||||
// 🆕 조건부 컨테이너 높이 변화 콜백
|
// 🆕 조건부 컨테이너 높이 변화 콜백
|
||||||
onHeightChange?: (componentId: string, newHeight: number) => void;
|
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" />;
|
return iconMap[widgetType] || <Type className="h-3 w-3" />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
||||||
component,
|
component,
|
||||||
isSelected = false,
|
isSelected = false,
|
||||||
isDesignMode = true, // 기본값은 편집 모드
|
isDesignMode = true, // 기본값은 편집 모드
|
||||||
|
|
@ -128,6 +131,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
formData,
|
formData,
|
||||||
onFormDataChange,
|
onFormDataChange,
|
||||||
onHeightChange, // 🆕 조건부 컨테이너 높이 변화 콜백
|
onHeightChange, // 🆕 조건부 컨테이너 높이 변화 콜백
|
||||||
|
conditionalDisabled, // 🆕 조건부 비활성화 상태
|
||||||
}) => {
|
}) => {
|
||||||
const [actualHeight, setActualHeight] = React.useState<number | null>(null);
|
const [actualHeight, setActualHeight] = React.useState<number | null>(null);
|
||||||
const contentRef = React.useRef<HTMLDivElement>(null);
|
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -509,6 +513,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
sortOrder={sortOrder}
|
sortOrder={sortOrder}
|
||||||
columnOrder={columnOrder}
|
columnOrder={columnOrder}
|
||||||
onHeightChange={onHeightChange}
|
onHeightChange={onHeightChange}
|
||||||
|
conditionalDisabled={conditionalDisabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
// 기존 RealtimePreview와의 호환성을 위한 export
|
||||||
export { RealtimePreviewDynamic as RealtimePreview };
|
export { RealtimePreviewDynamic as RealtimePreview };
|
||||||
export default RealtimePreviewDynamic;
|
export default RealtimePreviewDynamic;
|
||||||
|
|
|
||||||
|
|
@ -1548,18 +1548,67 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
action: "show",
|
action: "show",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
onChange={(newConfig: ConditionalConfig) => {
|
onChange={(newConfig: ConditionalConfig | undefined) => {
|
||||||
handleUpdate("conditional", newConfig);
|
handleUpdate("conditional", newConfig);
|
||||||
}}
|
}}
|
||||||
availableFields={
|
availableFields={
|
||||||
allComponents
|
allComponents
|
||||||
?.filter((c) => c.type === "widget" && c.id !== selectedComponent.id)
|
?.filter((c) => {
|
||||||
.map((c) => ({
|
// 자기 자신 제외
|
||||||
id: (c as any).columnName || c.id,
|
if (c.id === selectedComponent.id) return false;
|
||||||
label: (c as any).label || c.id,
|
// widget 타입 또는 component 타입 (Unified 컴포넌트 포함)
|
||||||
type: (c as any).widgetType || "text",
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -17,16 +17,24 @@ import { Switch } from "@/components/ui/switch";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Separator } from "@/components/ui/separator";
|
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 { ConditionalConfig } from "@/types/unified-components";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
// ===== 타입 정의 =====
|
// ===== 타입 정의 =====
|
||||||
|
|
||||||
interface FieldOption {
|
interface FieldOption {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
type?: string; // text, number, select, checkbox 등
|
type?: string; // text, number, select, checkbox, entity, code 등
|
||||||
options?: Array<{ value: string; label: string }>; // select 타입일 경우 옵션들
|
options?: Array<{ value: string; label: string }>; // select 타입일 경우 옵션들
|
||||||
|
// 동적 옵션 로드를 위한 정보
|
||||||
|
entityTable?: string;
|
||||||
|
entityValueColumn?: string;
|
||||||
|
entityLabelColumn?: string;
|
||||||
|
codeGroup?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ConditionalConfigPanelProps {
|
interface ConditionalConfigPanelProps {
|
||||||
|
|
@ -85,6 +93,86 @@ export function ConditionalConfigPanel({
|
||||||
return selectableFields.find((f) => f.id === field);
|
return selectableFields.find((f) => f.id === field);
|
||||||
}, [selectableFields, 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 변경 시 로컬 상태 동기화
|
// config prop 변경 시 로컬 상태 동기화
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setEnabled(config?.enabled ?? false);
|
setEnabled(config?.enabled ?? false);
|
||||||
|
|
@ -171,21 +259,66 @@ export function ConditionalConfigPanel({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 선택된 필드에 옵션이 있으면 Select로 표시
|
// 옵션 로딩 중
|
||||||
if (selectedField?.options && selectedField.options.length > 0) {
|
if (loadingOptions) {
|
||||||
return (
|
return (
|
||||||
<Select value={value} onValueChange={handleValueChange}>
|
<div className="text-xs text-muted-foreground italic">
|
||||||
<SelectTrigger className="h-8 text-xs">
|
옵션 로딩 중...
|
||||||
<SelectValue placeholder="값 선택" />
|
</div>
|
||||||
</SelectTrigger>
|
);
|
||||||
<SelectContent>
|
}
|
||||||
{selectedField.options.map((opt) => (
|
|
||||||
<SelectItem key={opt.value} value={opt.value} className="text-xs">
|
// 옵션이 있으면 검색 가능한 Combobox로 표시
|
||||||
{opt.label}
|
if (fieldOptions.length > 0) {
|
||||||
</SelectItem>
|
const selectedOption = fieldOptions.find((opt) => opt.value === value);
|
||||||
))}
|
|
||||||
</SelectContent>
|
return (
|
||||||
</Select>
|
<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 [options, setOptions] = useState<SelectOption[]>(config.options || []);
|
||||||
const [loading, setLoading] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
const loadOptions = async () => {
|
// 이미 로드된 경우 스킵 (static 제외)
|
||||||
console.log("🎯 UnifiedSelect 전체 props:", props);
|
if (optionsLoaded && source !== "static") {
|
||||||
console.log("🎯 UnifiedSelect config:", config);
|
return;
|
||||||
console.log("🎯 UnifiedSelect loadOptions 호출:", {
|
}
|
||||||
source: config.source,
|
|
||||||
entityTable: config.entityTable,
|
|
||||||
entityValueColumn: config.entityValueColumn,
|
|
||||||
entityLabelColumn: config.entityLabelColumn,
|
|
||||||
codeGroup: config.codeGroup,
|
|
||||||
table: config.table,
|
|
||||||
config,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (config.source === "static") {
|
const loadOptions = async () => {
|
||||||
setOptions(config.options || []);
|
if (source === "static") {
|
||||||
|
setOptions(staticOptions || []);
|
||||||
|
setOptionsLoaded(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -485,9 +492,9 @@ export const UnifiedSelect = forwardRef<HTMLDivElement, UnifiedSelectProps>(
|
||||||
try {
|
try {
|
||||||
let fetchedOptions: SelectOption[] = [];
|
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;
|
const data = response.data;
|
||||||
if (data.success && data.data) {
|
if (data.success && data.data) {
|
||||||
fetchedOptions = data.data.map((item: { code: string; codeName: string }) => ({
|
fetchedOptions = data.data.map((item: { code: string; codeName: string }) => ({
|
||||||
|
|
@ -495,37 +502,35 @@ export const UnifiedSelect = forwardRef<HTMLDivElement, UnifiedSelectProps>(
|
||||||
label: item.codeName,
|
label: item.codeName,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
} else if (config.source === "db" && config.table) {
|
} else if (source === "db" && table) {
|
||||||
// DB 테이블에서 로드
|
// DB 테이블에서 로드
|
||||||
const response = await apiClient.get(`/entity/${config.table}/options`, {
|
const response = await apiClient.get(`/entity/${table}/options`, {
|
||||||
params: {
|
params: {
|
||||||
value: config.valueColumn || "id",
|
value: valueColumn || "id",
|
||||||
label: config.labelColumn || "name",
|
label: labelColumn || "name",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
if (data.success && data.data) {
|
if (data.success && data.data) {
|
||||||
fetchedOptions = 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 valueCol = entityValueColumn || "id";
|
||||||
const labelCol = config.entityLabelColumn || config.entityLabelField || "name";
|
const labelCol = entityLabelColumn || "name";
|
||||||
console.log("🔍 Entity 옵션 API 호출:", `/entity/${config.entityTable}/options`, { value: valueCol, label: labelCol });
|
const response = await apiClient.get(`/entity/${entityTable}/options`, {
|
||||||
const response = await apiClient.get(`/entity/${config.entityTable}/options`, {
|
|
||||||
params: {
|
params: {
|
||||||
value: valueCol,
|
value: valueCol,
|
||||||
label: labelCol,
|
label: labelCol,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
console.log("🔍 Entity 옵션 API 응답:", data);
|
|
||||||
if (data.success && data.data) {
|
if (data.success && data.data) {
|
||||||
fetchedOptions = data.data;
|
fetchedOptions = data.data;
|
||||||
}
|
}
|
||||||
} else if (config.source === "api" && config.apiEndpoint) {
|
} else if (source === "api" && apiEndpoint) {
|
||||||
// 외부 API에서 로드
|
// 외부 API에서 로드
|
||||||
const response = await apiClient.get(config.apiEndpoint);
|
const response = await apiClient.get(apiEndpoint);
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
if (Array.isArray(data)) {
|
if (Array.isArray(data)) {
|
||||||
fetchedOptions = data;
|
fetchedOptions = data;
|
||||||
|
|
@ -533,6 +538,7 @@ export const UnifiedSelect = forwardRef<HTMLDivElement, UnifiedSelectProps>(
|
||||||
}
|
}
|
||||||
|
|
||||||
setOptions(fetchedOptions);
|
setOptions(fetchedOptions);
|
||||||
|
setOptionsLoaded(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("옵션 로딩 실패:", error);
|
console.error("옵션 로딩 실패:", error);
|
||||||
setOptions([]);
|
setOptions([]);
|
||||||
|
|
@ -542,7 +548,7 @@ export const UnifiedSelect = forwardRef<HTMLDivElement, UnifiedSelectProps>(
|
||||||
};
|
};
|
||||||
|
|
||||||
loadOptions();
|
loadOptions();
|
||||||
}, [config]);
|
}, [source, entityTable, entityValueColumn, entityLabelColumn, codeGroup, table, valueColumn, labelColumn, apiEndpoint, staticOptions, optionsLoaded]);
|
||||||
|
|
||||||
// 모드별 컴포넌트 렌더링
|
// 모드별 컴포넌트 렌더링
|
||||||
const renderSelect = () => {
|
const renderSelect = () => {
|
||||||
|
|
|
||||||
|
|
@ -148,6 +148,8 @@ export interface DynamicComponentRendererProps {
|
||||||
// 탭 관련 정보 (탭 내부의 컴포넌트에서 사용)
|
// 탭 관련 정보 (탭 내부의 컴포넌트에서 사용)
|
||||||
parentTabId?: string; // 부모 탭 ID
|
parentTabId?: string; // 부모 탭 ID
|
||||||
parentTabsComponentId?: string; // 부모 탭 컴포넌트 ID
|
parentTabsComponentId?: string; // 부모 탭 컴포넌트 ID
|
||||||
|
// 🆕 조건부 비활성화 상태
|
||||||
|
conditionalDisabled?: boolean;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -183,7 +185,8 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
label: (component as any).label,
|
label: (component as any).label,
|
||||||
required: (component as any).required,
|
required: (component as any).required,
|
||||||
readonly: (component as any).readonly,
|
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,
|
value: currentValue,
|
||||||
onChange: handleChange,
|
onChange: handleChange,
|
||||||
tableName: (component as any).tableName || props.tableName,
|
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