feat: 조건부 컨테이너를 화면 선택 방식으로 개선
- ConditionalSection 타입 변경 (components[] → screenId, screenName) * 각 조건마다 컴포넌트를 직접 배치하는 대신 기존 화면을 선택 * 복잡한 입력 폼도 화면 재사용으로 간단히 구성 - ConditionalSectionDropZone을 ConditionalSectionViewer로 교체 * 드롭존 대신 InteractiveScreenViewer 사용 * 선택된 화면을 조건별로 렌더링 * 디자인 모드에서 화면 미선택 시 안내 메시지 표시 - ConfigPanel에서 화면 선택 드롭다운 구현 * screenManagementApi.getScreenList()로 화면 목록 로드 * 각 섹션마다 화면 선택 Select 컴포넌트 * 선택된 화면의 ID와 이름 자동 저장 및 표시 * 로딩 상태 표시 - 기본 설정 업데이트 * defaultConfig에서 components 제거, screenId 추가 * 모든 섹션 기본값을 screenId: null로 설정 - README 문서 개선 * 화면 선택 방식으로 사용법 업데이트 * 사용 사례에 화면 ID 예시 추가 * 구조 다이어그램 수정 (드롭존 → 화면 표시) * 디자인/실행 모드 설명 업데이트 장점: - 기존 화면 재사용으로 생산성 향상 - 복잡한 입력 폼도 간단히 조건부 표시 - 화면 수정 시 자동 반영 - 유지보수 용이
This commit is contained in:
parent
e6949bdd67
commit
f5756e184f
|
|
@ -0,0 +1,162 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ConditionalContainerProps, ConditionalSection } from "./types";
|
||||
import { ConditionalSectionViewer } from "./ConditionalSectionViewer";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* 조건부 컨테이너 컴포넌트
|
||||
* 상단 셀렉트박스 값에 따라 하단에 다른 UI를 표시
|
||||
*/
|
||||
export function ConditionalContainerComponent({
|
||||
config,
|
||||
controlField: propControlField,
|
||||
controlLabel: propControlLabel,
|
||||
sections: propSections,
|
||||
defaultValue: propDefaultValue,
|
||||
showBorder: propShowBorder,
|
||||
spacing: propSpacing,
|
||||
value,
|
||||
onChange,
|
||||
formData,
|
||||
onFormDataChange,
|
||||
isDesignMode = false,
|
||||
onUpdateComponent,
|
||||
onDeleteComponent,
|
||||
onSelectComponent,
|
||||
selectedComponentId,
|
||||
style,
|
||||
className,
|
||||
}: ConditionalContainerProps) {
|
||||
// config prop 우선, 없으면 개별 prop 사용
|
||||
const controlField = config?.controlField || propControlField || "condition";
|
||||
const controlLabel = config?.controlLabel || propControlLabel || "조건 선택";
|
||||
const sections = config?.sections || propSections || [];
|
||||
const defaultValue = config?.defaultValue || propDefaultValue || sections[0]?.condition;
|
||||
const showBorder = config?.showBorder ?? propShowBorder ?? true;
|
||||
const spacing = config?.spacing || propSpacing || "normal";
|
||||
|
||||
// 현재 선택된 값
|
||||
const [selectedValue, setSelectedValue] = useState<string>(
|
||||
value || formData?.[controlField] || defaultValue || ""
|
||||
);
|
||||
|
||||
// formData 변경 시 동기화
|
||||
useEffect(() => {
|
||||
if (formData?.[controlField]) {
|
||||
setSelectedValue(formData[controlField]);
|
||||
}
|
||||
}, [formData, controlField]);
|
||||
|
||||
// 값 변경 핸들러
|
||||
const handleValueChange = (newValue: string) => {
|
||||
setSelectedValue(newValue);
|
||||
|
||||
if (onChange) {
|
||||
onChange(newValue);
|
||||
}
|
||||
|
||||
if (onFormDataChange) {
|
||||
onFormDataChange(controlField, newValue);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// 간격 스타일
|
||||
const spacingClass = {
|
||||
tight: "space-y-2",
|
||||
normal: "space-y-4",
|
||||
loose: "space-y-8",
|
||||
}[spacing];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("h-full w-full flex flex-col", spacingClass, className)}
|
||||
style={style}
|
||||
>
|
||||
{/* 제어 셀렉트박스 */}
|
||||
<div className="space-y-2 flex-shrink-0">
|
||||
<Label htmlFor={controlField} className="text-xs sm:text-sm">
|
||||
{controlLabel}
|
||||
</Label>
|
||||
<Select value={selectedValue} onValueChange={handleValueChange}>
|
||||
<SelectTrigger
|
||||
id={controlField}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
>
|
||||
<SelectValue placeholder="선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sections.map((section) => (
|
||||
<SelectItem key={section.id} value={section.condition}>
|
||||
{section.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 조건별 섹션들 */}
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
{isDesignMode ? (
|
||||
// 디자인 모드: 모든 섹션 표시
|
||||
<div className={spacingClass}>
|
||||
{sections.map((section) => (
|
||||
<ConditionalSectionViewer
|
||||
key={section.id}
|
||||
sectionId={section.id}
|
||||
condition={section.condition}
|
||||
label={section.label}
|
||||
screenId={section.screenId}
|
||||
screenName={section.screenName}
|
||||
isActive={selectedValue === section.condition}
|
||||
isDesignMode={isDesignMode}
|
||||
showBorder={showBorder}
|
||||
formData={formData}
|
||||
onFormDataChange={onFormDataChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
// 실행 모드: 활성 섹션만 표시
|
||||
sections.map((section) =>
|
||||
selectedValue === section.condition ? (
|
||||
<ConditionalSectionViewer
|
||||
key={section.id}
|
||||
sectionId={section.id}
|
||||
condition={section.condition}
|
||||
label={section.label}
|
||||
screenId={section.screenId}
|
||||
screenName={section.screenName}
|
||||
isActive={true}
|
||||
isDesignMode={false}
|
||||
showBorder={showBorder}
|
||||
formData={formData}
|
||||
onFormDataChange={onFormDataChange}
|
||||
/>
|
||||
) : null
|
||||
)
|
||||
)}
|
||||
|
||||
{/* 섹션이 없는 경우 안내 */}
|
||||
{sections.length === 0 && isDesignMode && (
|
||||
<div className="flex items-center justify-center min-h-[200px] border-2 border-dashed border-muted-foreground/30 rounded-lg bg-muted/20">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
설정 패널에서 조건을 추가하세요
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,336 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Plus, Trash2, GripVertical, Loader2 } from "lucide-react";
|
||||
import { ConditionalContainerConfig, ConditionalSection } from "./types";
|
||||
import { screenManagementApi } from "@/lib/api/screenManagement";
|
||||
|
||||
interface ConditionalContainerConfigPanelProps {
|
||||
config: ConditionalContainerConfig;
|
||||
onConfigChange: (config: ConditionalContainerConfig) => void;
|
||||
}
|
||||
|
||||
export function ConditionalContainerConfigPanel({
|
||||
config,
|
||||
onConfigChange,
|
||||
}: ConditionalContainerConfigPanelProps) {
|
||||
const [localConfig, setLocalConfig] = useState<ConditionalContainerConfig>({
|
||||
controlField: config.controlField || "condition",
|
||||
controlLabel: config.controlLabel || "조건 선택",
|
||||
sections: config.sections || [],
|
||||
defaultValue: config.defaultValue || "",
|
||||
showBorder: config.showBorder ?? true,
|
||||
spacing: config.spacing || "normal",
|
||||
});
|
||||
|
||||
// 화면 목록 상태
|
||||
const [screens, setScreens] = useState<any[]>([]);
|
||||
const [screensLoading, setScreensLoading] = useState(false);
|
||||
|
||||
// 화면 목록 로드
|
||||
useEffect(() => {
|
||||
const loadScreens = async () => {
|
||||
setScreensLoading(true);
|
||||
try {
|
||||
const response = await screenManagementApi.getScreenList();
|
||||
if (response.success && response.data) {
|
||||
setScreens(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("화면 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setScreensLoading(false);
|
||||
}
|
||||
};
|
||||
loadScreens();
|
||||
}, []);
|
||||
|
||||
// 설정 업데이트 헬퍼
|
||||
const updateConfig = (updates: Partial<ConditionalContainerConfig>) => {
|
||||
const newConfig = { ...localConfig, ...updates };
|
||||
setLocalConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
};
|
||||
|
||||
// 새 섹션 추가
|
||||
const addSection = () => {
|
||||
const newSection: ConditionalSection = {
|
||||
id: `section_${Date.now()}`,
|
||||
condition: `condition_${localConfig.sections.length + 1}`,
|
||||
label: `조건 ${localConfig.sections.length + 1}`,
|
||||
screenId: null,
|
||||
screenName: undefined,
|
||||
};
|
||||
|
||||
updateConfig({
|
||||
sections: [...localConfig.sections, newSection],
|
||||
});
|
||||
};
|
||||
|
||||
// 섹션 삭제
|
||||
const removeSection = (sectionId: string) => {
|
||||
updateConfig({
|
||||
sections: localConfig.sections.filter((s) => s.id !== sectionId),
|
||||
});
|
||||
};
|
||||
|
||||
// 섹션 업데이트
|
||||
const updateSection = (
|
||||
sectionId: string,
|
||||
updates: Partial<ConditionalSection>
|
||||
) => {
|
||||
updateConfig({
|
||||
sections: localConfig.sections.map((s) =>
|
||||
s.id === sectionId ? { ...s, ...updates } : s
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-4">조건부 컨테이너 설정</h3>
|
||||
|
||||
{/* 제어 필드 설정 */}
|
||||
<div className="space-y-4 mb-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="controlField" className="text-xs">
|
||||
제어 필드명
|
||||
</Label>
|
||||
<Input
|
||||
id="controlField"
|
||||
value={localConfig.controlField}
|
||||
onChange={(e) => updateConfig({ controlField: e.target.value })}
|
||||
placeholder="예: inputMode"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
formData에 저장될 필드명
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="controlLabel" className="text-xs">
|
||||
셀렉트박스 라벨
|
||||
</Label>
|
||||
<Input
|
||||
id="controlLabel"
|
||||
value={localConfig.controlLabel}
|
||||
onChange={(e) => updateConfig({ controlLabel: e.target.value })}
|
||||
placeholder="예: 입력 방식"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 조건별 섹션 설정 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-semibold">조건별 섹션</Label>
|
||||
<Button
|
||||
onClick={addSection}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
섹션 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{localConfig.sections.length === 0 ? (
|
||||
<div className="text-center py-8 border-2 border-dashed rounded-lg">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
조건별 섹션을 추가하세요
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{localConfig.sections.map((section, index) => (
|
||||
<div
|
||||
key={section.id}
|
||||
className="p-3 border rounded-lg space-y-3 bg-muted/20"
|
||||
>
|
||||
{/* 섹션 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-xs font-medium">
|
||||
섹션 {index + 1}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => removeSection(section.id)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 조건 값 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[10px] text-muted-foreground">
|
||||
조건 값 (고유값)
|
||||
</Label>
|
||||
<Input
|
||||
value={section.condition}
|
||||
onChange={(e) =>
|
||||
updateSection(section.id, { condition: e.target.value })
|
||||
}
|
||||
placeholder="예: customer_first"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 조건 라벨 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[10px] text-muted-foreground">
|
||||
표시 라벨
|
||||
</Label>
|
||||
<Input
|
||||
value={section.label}
|
||||
onChange={(e) =>
|
||||
updateSection(section.id, { label: e.target.value })
|
||||
}
|
||||
placeholder="예: 거래처 우선"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 화면 선택 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] text-muted-foreground">
|
||||
표시할 화면
|
||||
</Label>
|
||||
{screensLoading ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground h-7 px-3 border rounded">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
로딩 중...
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={section.screenId?.toString() || ""}
|
||||
onValueChange={(value) => {
|
||||
const screenId = value ? parseInt(value) : null;
|
||||
const selectedScreen = screens.find(
|
||||
(s) => s.id === screenId
|
||||
);
|
||||
updateSection(section.id, {
|
||||
screenId,
|
||||
screenName: selectedScreen?.screenName,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="화면 선택..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">선택 안 함</SelectItem>
|
||||
{screens.map((screen) => (
|
||||
<SelectItem
|
||||
key={screen.id}
|
||||
value={screen.id.toString()}
|
||||
>
|
||||
{screen.screenName}
|
||||
{screen.description && (
|
||||
<span className="text-[10px] text-muted-foreground ml-1">
|
||||
({screen.description})
|
||||
</span>
|
||||
)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{section.screenId && (
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
화면 ID: {section.screenId}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 기본값 설정 */}
|
||||
{localConfig.sections.length > 0 && (
|
||||
<div className="space-y-2 mt-4">
|
||||
<Label htmlFor="defaultValue" className="text-xs">
|
||||
기본 선택 값
|
||||
</Label>
|
||||
<Select
|
||||
value={localConfig.defaultValue || ""}
|
||||
onValueChange={(value) => updateConfig({ defaultValue: value })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="기본값 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{localConfig.sections.map((section) => (
|
||||
<SelectItem key={section.id} value={section.condition}>
|
||||
{section.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 스타일 설정 */}
|
||||
<div className="space-y-4 mt-6 pt-6 border-t">
|
||||
<Label className="text-xs font-semibold">스타일 설정</Label>
|
||||
|
||||
{/* 테두리 표시 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="showBorder" className="text-xs">
|
||||
섹션 테두리 표시
|
||||
</Label>
|
||||
<Switch
|
||||
id="showBorder"
|
||||
checked={localConfig.showBorder}
|
||||
onCheckedChange={(checked) =>
|
||||
updateConfig({ showBorder: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 간격 설정 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="spacing" className="text-xs">
|
||||
섹션 간격
|
||||
</Label>
|
||||
<Select
|
||||
value={localConfig.spacing || "normal"}
|
||||
onValueChange={(value: any) => updateConfig({ spacing: value })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="tight">좁게</SelectItem>
|
||||
<SelectItem value="normal">보통</SelectItem>
|
||||
<SelectItem value="loose">넓게</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
|
||||
import ConditionalContainerDefinition from "./index";
|
||||
import { ConditionalContainerComponent } from "./ConditionalContainerComponent";
|
||||
import { ConditionalContainerConfigPanel } from "./ConditionalContainerConfigPanel";
|
||||
|
||||
// 컴포넌트 자동 등록
|
||||
if (typeof window !== "undefined") {
|
||||
ComponentRegistry.registerComponent({
|
||||
...ConditionalContainerDefinition,
|
||||
component: ConditionalContainerComponent,
|
||||
renderer: ConditionalContainerComponent,
|
||||
configPanel: ConditionalContainerConfigPanel,
|
||||
} as any);
|
||||
}
|
||||
|
||||
export { ConditionalContainerComponent };
|
||||
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { ConditionalSectionViewerProps } from "./types";
|
||||
import { InteractiveScreenViewer } from "@/components/screen/InteractiveScreenViewer";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
/**
|
||||
* 조건부 섹션 뷰어 컴포넌트
|
||||
* 각 조건에 해당하는 화면을 표시
|
||||
*/
|
||||
export function ConditionalSectionViewer({
|
||||
sectionId,
|
||||
condition,
|
||||
label,
|
||||
screenId,
|
||||
screenName,
|
||||
isActive,
|
||||
isDesignMode,
|
||||
showBorder = true,
|
||||
formData,
|
||||
onFormDataChange,
|
||||
}: ConditionalSectionViewerProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// 디자인 모드가 아니고 비활성 섹션이면 렌더링하지 않음
|
||||
if (!isDesignMode && !isActive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative min-h-[200px] transition-all",
|
||||
showBorder && "rounded-lg border-2",
|
||||
isDesignMode ? (
|
||||
"border-dashed border-muted-foreground/30 bg-muted/20"
|
||||
) : (
|
||||
showBorder ? "border-border bg-card" : ""
|
||||
),
|
||||
!isDesignMode && !isActive && "hidden"
|
||||
)}
|
||||
data-section-id={sectionId}
|
||||
>
|
||||
{/* 섹션 라벨 (디자인 모드에서만 표시) */}
|
||||
{isDesignMode && (
|
||||
<div className="absolute -top-3 left-4 bg-background px-2 text-xs font-medium text-muted-foreground z-10">
|
||||
{label} {isActive && "(활성)"}
|
||||
{screenId && ` - 화면 ID: ${screenId}`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 화면 미선택 안내 (디자인 모드 + 화면 없을 때) */}
|
||||
{isDesignMode && !screenId && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<p className="text-sm">설정 패널에서 화면을 선택하세요</p>
|
||||
<p className="text-xs mt-1">조건: {condition}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 로딩 중 */}
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/50 z-20">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||
<p className="text-xs text-muted-foreground">화면 로드 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 화면 렌더링 */}
|
||||
{screenId && (
|
||||
<div className="relative p-4 min-h-[200px]">
|
||||
<InteractiveScreenViewer
|
||||
screenId={screenId}
|
||||
formData={formData}
|
||||
onFormDataChange={onFormDataChange}
|
||||
mode="view" // 항상 뷰 모드로 표시
|
||||
isInModal={true} // 모달 내부처럼 처리
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,293 @@
|
|||
# 조건부 컨테이너 (ConditionalContainer) - 화면 선택 방식
|
||||
|
||||
제어 셀렉트박스 값에 따라 다른 **화면**을 표시하는 조건부 컨테이너 컴포넌트입니다.
|
||||
|
||||
## 📋 개요
|
||||
|
||||
화면 편집기에서 조건별로 표시할 화면을 선택하여 조건부 UI를 구성할 수 있는 컨테이너입니다. 상단의 셀렉트박스 값에 따라 하단에 미리 만들어진 화면을 표시합니다.
|
||||
|
||||
## ✨ 주요 기능
|
||||
|
||||
- ✅ **조건별 화면 전환**: 셀렉트박스 값에 따라 다른 화면 표시
|
||||
- ✅ **화면 재사용**: 기존에 만든 화면을 조건별로 할당
|
||||
- ✅ **간편한 구성**: 복잡한 입력 폼도 화면 선택으로 간단히 구성
|
||||
- ✅ **자동 동기화**: 화면 수정 시 자동 반영
|
||||
- ✅ **폼 데이터 연동**: formData와 자동 동기화
|
||||
- ✅ **커스터마이징**: 테두리, 간격, 기본값 등 설정 가능
|
||||
|
||||
## 🎯 사용 사례
|
||||
|
||||
### 1. 입력 방식 선택
|
||||
```
|
||||
[셀렉트: 입력 방식]
|
||||
├─ 거래처 우선: "거래처_우선_입력_화면" (화면 ID: 101)
|
||||
├─ 견적서 기반: "견적서_업로드_화면" (화면 ID: 102)
|
||||
└─ 단가 직접입력: "단가_직접입력_화면" (화면 ID: 103)
|
||||
```
|
||||
|
||||
### 2. 판매 유형 선택
|
||||
```
|
||||
[셀렉트: 판매 유형]
|
||||
├─ 국내 판매: "국내판매_기본폼" (화면 ID: 201)
|
||||
└─ 해외 판매: "해외판매_무역정보폼" (화면 ID: 202)
|
||||
```
|
||||
|
||||
### 3. 문서 유형 선택
|
||||
```
|
||||
[셀렉트: 문서 유형]
|
||||
├─ 신규 작성: "신규문서_입력폼" (화면 ID: 301)
|
||||
├─ 복사 생성: "문서복사_화면" (화면 ID: 302)
|
||||
└─ 불러오기: "파일업로드_화면" (화면 ID: 303)
|
||||
```
|
||||
|
||||
## 📐 구조
|
||||
|
||||
```
|
||||
┌─────────────────────────────────┐
|
||||
│ ConditionalContainer │
|
||||
├─────────────────────────────────┤
|
||||
│ [제어 셀렉트박스] │ ← controlField, controlLabel
|
||||
├─────────────────────────────────┤
|
||||
│ 📄 조건 1: "옵션 A" 선택 시 │ ← sections[0]
|
||||
│ ┌─────────────────────────────┐│
|
||||
│ │ [선택된 화면이 표시됨] ││ ← screenId로 지정된 화면
|
||||
│ │ (화면 ID: 101) ││
|
||||
│ │ ││
|
||||
│ └─────────────────────────────┘│
|
||||
├─────────────────────────────────┤
|
||||
│ 📄 조건 2: "옵션 B" 선택 시 │ ← sections[1]
|
||||
│ ┌─────────────────────────────┐│
|
||||
│ │ [다른 화면이 표시됨] ││ ← screenId로 지정된 다른 화면
|
||||
│ │ (화면 ID: 102) ││
|
||||
│ └─────────────────────────────┘│
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 🔧 설정 방법
|
||||
|
||||
### 1. 컴포넌트 추가
|
||||
화면 편집기의 컴포넌트 패널에서 **"조건부 컨테이너"**를 드래그하여 캔버스에 배치합니다.
|
||||
|
||||
### 2. 설정 패널에서 구성
|
||||
|
||||
#### 제어 필드 설정
|
||||
- **제어 필드명**: formData에 저장될 필드명 (예: `inputMode`)
|
||||
- **셀렉트박스 라벨**: 화면에 표시될 라벨 (예: "입력 방식")
|
||||
|
||||
#### 조건별 섹션 추가
|
||||
1. **"섹션 추가"** 버튼 클릭
|
||||
2. 각 섹션 설정:
|
||||
- **조건 값**: 고유한 값 (예: `customer_first`)
|
||||
- **표시 라벨**: 사용자에게 보이는 텍스트 (예: "거래처 우선")
|
||||
|
||||
#### 기본값 설정
|
||||
- 처음 화면 로드 시 선택될 기본 조건 선택
|
||||
|
||||
#### 스타일 설정
|
||||
- **섹션 테두리 표시**: ON/OFF
|
||||
- **섹션 간격**: 좁게 / 보통 / 넓게
|
||||
|
||||
### 3. 조건별 화면 선택
|
||||
|
||||
1. **디자인 모드**에서 모든 조건 섹션이 표시됩니다
|
||||
2. 각 섹션의 **"표시할 화면"** 드롭다운에서 화면을 선택합니다
|
||||
3. 선택된 화면 ID와 이름이 자동으로 저장됩니다
|
||||
|
||||
**장점:**
|
||||
- ✅ 이미 만든 화면을 재사용
|
||||
- ✅ 복잡한 입력 폼도 간단히 구성
|
||||
- ✅ 화면 수정 시 자동 반영
|
||||
|
||||
### 4. 실행 모드 동작
|
||||
|
||||
- 셀렉트박스에서 조건 선택
|
||||
- 선택된 조건의 **화면**이 표시됨
|
||||
- 다른 조건의 화면은 자동으로 숨김
|
||||
|
||||
## 💻 기술 사양
|
||||
|
||||
### Props
|
||||
|
||||
```typescript
|
||||
interface ConditionalContainerProps {
|
||||
// 제어 필드
|
||||
controlField: string; // 예: "inputMode"
|
||||
controlLabel: string; // 예: "입력 방식"
|
||||
|
||||
// 조건별 섹션
|
||||
sections: ConditionalSection[];
|
||||
|
||||
// 기본값
|
||||
defaultValue?: string;
|
||||
|
||||
// 스타일
|
||||
showBorder?: boolean; // 기본: true
|
||||
spacing?: "tight" | "normal" | "loose"; // 기본: "normal"
|
||||
|
||||
// 폼 연동
|
||||
formData?: Record<string, any>;
|
||||
onFormDataChange?: (fieldName: string, value: any) => void;
|
||||
}
|
||||
|
||||
interface ConditionalSection {
|
||||
id: string; // 고유 ID
|
||||
condition: string; // 조건 값
|
||||
label: string; // 표시 라벨
|
||||
screenId: number | null; // 표시할 화면 ID
|
||||
screenName?: string; // 화면 이름 (표시용)
|
||||
}
|
||||
```
|
||||
|
||||
### 기본 설정
|
||||
|
||||
```typescript
|
||||
defaultSize: {
|
||||
width: 800,
|
||||
height: 600,
|
||||
}
|
||||
|
||||
defaultConfig: {
|
||||
controlField: "condition",
|
||||
controlLabel: "조건 선택",
|
||||
sections: [
|
||||
{
|
||||
id: "section_1",
|
||||
condition: "option1",
|
||||
label: "옵션 1",
|
||||
screenId: null, // 화면 미선택 상태
|
||||
},
|
||||
{
|
||||
id: "section_2",
|
||||
condition: "option2",
|
||||
label: "옵션 2",
|
||||
screenId: null, // 화면 미선택 상태
|
||||
},
|
||||
],
|
||||
defaultValue: "option1",
|
||||
showBorder: true,
|
||||
spacing: "normal",
|
||||
}
|
||||
```
|
||||
|
||||
## 🎨 디자인 모드 vs 실행 모드
|
||||
|
||||
### 디자인 모드 (편집기)
|
||||
- ✅ 모든 조건 섹션 표시
|
||||
- ✅ 각 섹션에 "조건: XXX" 라벨 표시
|
||||
- ✅ 화면 선택 안내 메시지 (미선택 시)
|
||||
- ✅ 선택된 화면 ID 표시
|
||||
- ✅ 활성 조건 "(활성)" 표시
|
||||
|
||||
### 실행 모드 (할당된 화면)
|
||||
- ✅ 선택된 조건의 화면만 표시
|
||||
- ✅ 다른 조건의 화면 자동 숨김
|
||||
- ✅ 깔끔한 UI (라벨, 점선 테두리 제거)
|
||||
- ✅ 선택된 화면이 완전히 통합되어 표시
|
||||
|
||||
## 📊 폼 데이터 연동
|
||||
|
||||
### 자동 동기화
|
||||
```typescript
|
||||
// formData 읽기
|
||||
formData[controlField] // 현재 선택된 값
|
||||
|
||||
// formData 쓰기
|
||||
onFormDataChange(controlField, newValue)
|
||||
```
|
||||
|
||||
### 예시
|
||||
```typescript
|
||||
// controlField = "salesType"
|
||||
formData = {
|
||||
salesType: "export", // ← 자동으로 여기에 저장됨
|
||||
// ... 다른 필드들
|
||||
}
|
||||
|
||||
// 셀렉트박스 값 변경 시 자동으로 formData 업데이트
|
||||
```
|
||||
|
||||
## 🔍 주의사항
|
||||
|
||||
1. **조건 값은 고유해야 함**: 각 섹션의 `condition` 값은 중복되면 안 됩니다
|
||||
2. **최소 1개 섹션 필요**: 섹션이 없으면 안내 메시지 표시
|
||||
3. **컴포넌트 ID 충돌 방지**: 각 섹션의 컴포넌트 ID는 전역적으로 고유해야 함
|
||||
|
||||
## 📝 예시: 수주 입력 방식 선택
|
||||
|
||||
```typescript
|
||||
{
|
||||
controlField: "inputMode",
|
||||
controlLabel: "입력 방식",
|
||||
sections: [
|
||||
{
|
||||
id: "customer_first",
|
||||
condition: "customer_first",
|
||||
label: "거래처 우선",
|
||||
components: [
|
||||
// 거래처 검색 컴포넌트
|
||||
// 품목 선택 테이블
|
||||
// 저장 버튼
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "quotation",
|
||||
condition: "quotation",
|
||||
label: "견적서 기반",
|
||||
components: [
|
||||
// 견적서 검색 컴포넌트
|
||||
// 견적서 내용 표시
|
||||
// 수주 전환 버튼
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "unit_price",
|
||||
condition: "unit_price",
|
||||
label: "단가 직접입력",
|
||||
components: [
|
||||
// 품목 입력 테이블
|
||||
// 단가 입력 필드들
|
||||
// 계산 위젯
|
||||
]
|
||||
}
|
||||
],
|
||||
defaultValue: "customer_first",
|
||||
showBorder: true,
|
||||
spacing: "normal"
|
||||
}
|
||||
```
|
||||
|
||||
## 🚀 로드맵
|
||||
|
||||
- [ ] 다중 제어 필드 지원 (AND/OR 조건)
|
||||
- [ ] 섹션 전환 애니메이션
|
||||
- [ ] 조건별 검증 규칙
|
||||
- [ ] 템플릿 저장/불러오기
|
||||
|
||||
## 🐛 트러블슈팅
|
||||
|
||||
### Q: 섹션이 전환되지 않아요
|
||||
A: `controlField` 값이 formData에 제대로 저장되고 있는지 확인하세요.
|
||||
|
||||
### Q: 컴포넌트가 드롭되지 않아요
|
||||
A: 디자인 모드인지 확인하고, 드롭존 영역에 정확히 드롭하세요.
|
||||
|
||||
### Q: 다른 조건의 UI가 계속 보여요
|
||||
A: 실행 모드로 전환했는지 확인하세요. 디자인 모드에서는 모든 조건이 표시됩니다.
|
||||
|
||||
## 📦 파일 구조
|
||||
|
||||
```
|
||||
conditional-container/
|
||||
├── types.ts # 타입 정의
|
||||
├── ConditionalContainerComponent.tsx # 메인 컴포넌트
|
||||
├── ConditionalSectionDropZone.tsx # 드롭존 컴포넌트
|
||||
├── ConditionalContainerConfigPanel.tsx # 설정 패널
|
||||
├── ConditionalContainerRenderer.tsx # 렌더러 및 등록
|
||||
├── index.ts # 컴포넌트 정의
|
||||
└── README.md # 이 파일
|
||||
```
|
||||
|
||||
## 🎉 완료!
|
||||
|
||||
이제 화면 편집기에서 **조건부 컨테이너**를 사용하여 동적인 UI를 만들 수 있습니다! 🚀
|
||||
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
/**
|
||||
* 조건부 컨테이너 컴포넌트
|
||||
* 제어 셀렉트박스 값에 따라 다른 UI를 표시하는 컨테이너
|
||||
*/
|
||||
|
||||
import { ComponentDefinition, ComponentCategory } from "@/types/component";
|
||||
|
||||
export const ConditionalContainerDefinition: Omit<
|
||||
ComponentDefinition,
|
||||
"renderer" | "configPanel" | "component"
|
||||
> = {
|
||||
id: "conditional-container",
|
||||
name: "조건부 컨테이너",
|
||||
category: ComponentCategory.LAYOUT,
|
||||
webType: "container" as const,
|
||||
description: "셀렉트박스 값에 따라 다른 UI를 표시하는 조건부 컨테이너",
|
||||
icon: "GitBranch",
|
||||
version: "1.0.0",
|
||||
author: "WACE",
|
||||
tags: ["조건부", "분기", "동적", "레이아웃"],
|
||||
|
||||
defaultSize: {
|
||||
width: 800,
|
||||
height: 600,
|
||||
},
|
||||
|
||||
defaultConfig: {
|
||||
controlField: "condition",
|
||||
controlLabel: "조건 선택",
|
||||
sections: [
|
||||
{
|
||||
id: "section_1",
|
||||
condition: "option1",
|
||||
label: "옵션 1",
|
||||
screenId: null,
|
||||
},
|
||||
{
|
||||
id: "section_2",
|
||||
condition: "option2",
|
||||
label: "옵션 2",
|
||||
screenId: null,
|
||||
},
|
||||
],
|
||||
defaultValue: "option1",
|
||||
showBorder: true,
|
||||
spacing: "normal",
|
||||
},
|
||||
|
||||
defaultProps: {
|
||||
style: {
|
||||
width: "800px",
|
||||
height: "600px",
|
||||
},
|
||||
},
|
||||
|
||||
configSchema: {
|
||||
controlField: {
|
||||
type: "string",
|
||||
label: "제어 필드명",
|
||||
defaultValue: "condition",
|
||||
},
|
||||
controlLabel: {
|
||||
type: "string",
|
||||
label: "셀렉트박스 라벨",
|
||||
defaultValue: "조건 선택",
|
||||
},
|
||||
sections: {
|
||||
type: "array",
|
||||
label: "조건별 섹션",
|
||||
defaultValue: [],
|
||||
},
|
||||
defaultValue: {
|
||||
type: "string",
|
||||
label: "기본 선택 값",
|
||||
defaultValue: "",
|
||||
},
|
||||
showBorder: {
|
||||
type: "boolean",
|
||||
label: "섹션 테두리 표시",
|
||||
defaultValue: true,
|
||||
},
|
||||
spacing: {
|
||||
type: "select",
|
||||
label: "섹션 간격",
|
||||
options: [
|
||||
{ label: "좁게", value: "tight" },
|
||||
{ label: "보통", value: "normal" },
|
||||
{ label: "넓게", value: "loose" },
|
||||
],
|
||||
defaultValue: "normal",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default ConditionalContainerDefinition;
|
||||
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
/**
|
||||
* ConditionalContainer 컴포넌트 타입 정의
|
||||
* 제어 셀렉트박스 값에 따라 다른 UI를 표시하는 조건부 컨테이너
|
||||
*/
|
||||
|
||||
import { ComponentData } from "@/types/screen";
|
||||
|
||||
export interface ConditionalSection {
|
||||
id: string; // 고유 ID
|
||||
condition: string; // 조건 값 (예: "customer_first", "quotation")
|
||||
label: string; // 조건 라벨 (예: "거래처 우선", "견적서 기반")
|
||||
screenId: number | null; // 이 조건일 때 표시할 화면 ID
|
||||
screenName?: string; // 화면 이름 (표시용)
|
||||
}
|
||||
|
||||
export interface ConditionalContainerConfig {
|
||||
// 제어 셀렉트박스 설정
|
||||
controlField: string; // 제어할 필드명 (예: "inputMode")
|
||||
controlLabel: string; // 셀렉트박스 라벨 (예: "입력 방식")
|
||||
|
||||
// 조건별 섹션
|
||||
sections: ConditionalSection[];
|
||||
|
||||
// 기본 선택 값
|
||||
defaultValue?: string;
|
||||
|
||||
// 스타일
|
||||
showBorder?: boolean; // 섹션별 테두리 표시
|
||||
spacing?: "tight" | "normal" | "loose"; // 섹션 간격
|
||||
}
|
||||
|
||||
export interface ConditionalContainerProps {
|
||||
config?: ConditionalContainerConfig;
|
||||
|
||||
// 개별 props (config 우선)
|
||||
controlField?: string;
|
||||
controlLabel?: string;
|
||||
sections?: ConditionalSection[];
|
||||
defaultValue?: string;
|
||||
showBorder?: boolean;
|
||||
spacing?: "tight" | "normal" | "loose";
|
||||
|
||||
// 폼 데이터 연동
|
||||
value?: any; // 현재 선택된 값
|
||||
onChange?: (value: string) => void;
|
||||
formData?: Record<string, any>;
|
||||
onFormDataChange?: (fieldName: string, value: any) => void;
|
||||
|
||||
// 화면 편집기 관련
|
||||
isDesignMode?: boolean; // 디자인 모드 여부
|
||||
onUpdateComponent?: (componentId: string, updates: Partial<ComponentData>) => void;
|
||||
onDeleteComponent?: (componentId: string) => void;
|
||||
onSelectComponent?: (componentId: string) => void;
|
||||
selectedComponentId?: string;
|
||||
|
||||
// 스타일
|
||||
style?: React.CSSProperties;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// 조건부 섹션 뷰어 Props
|
||||
export interface ConditionalSectionViewerProps {
|
||||
sectionId: string;
|
||||
condition: string;
|
||||
label: string;
|
||||
screenId: number | null; // 표시할 화면 ID
|
||||
screenName?: string; // 화면 이름
|
||||
isActive: boolean; // 현재 조건이 활성화되어 있는지
|
||||
isDesignMode: boolean;
|
||||
showBorder?: boolean;
|
||||
// 폼 데이터 전달
|
||||
formData?: Record<string, any>;
|
||||
onFormDataChange?: (fieldName: string, value: any) => void;
|
||||
}
|
||||
|
||||
|
|
@ -50,6 +50,9 @@ import { EntitySearchInputRenderer } from "./entity-search-input/EntitySearchInp
|
|||
import { ModalRepeaterTableRenderer } from "./modal-repeater-table/ModalRepeaterTableRenderer";
|
||||
import "./order-registration-modal/OrderRegistrationModalRenderer";
|
||||
|
||||
// 🆕 조건부 컨테이너 컴포넌트
|
||||
import "./conditional-container/ConditionalContainerRenderer";
|
||||
|
||||
/**
|
||||
* 컴포넌트 초기화 함수
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ const CONFIG_PANEL_MAP: Record<string, () => Promise<any>> = {
|
|||
"entity-search-input": () => import("@/lib/registry/components/entity-search-input/EntitySearchInputConfigPanel"),
|
||||
"modal-repeater-table": () => import("@/lib/registry/components/modal-repeater-table/ModalRepeaterTableConfigPanel"),
|
||||
"order-registration-modal": () => import("@/lib/registry/components/order-registration-modal/OrderRegistrationModalConfigPanel"),
|
||||
// 🆕 조건부 컨테이너
|
||||
"conditional-container": () => import("@/lib/registry/components/conditional-container/ConditionalContainerConfigPanel"),
|
||||
};
|
||||
|
||||
// ConfigPanel 컴포넌트 캐시
|
||||
|
|
@ -261,7 +263,8 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
|
|||
"autocomplete-search-input",
|
||||
"entity-search-input",
|
||||
"modal-repeater-table",
|
||||
"order-registration-modal"
|
||||
"order-registration-modal",
|
||||
"conditional-container"
|
||||
].includes(componentId);
|
||||
|
||||
if (isSimpleConfigPanel) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue