feat(universal-form-modal): 범용 폼 모달 컴포넌트 신규 개발

- 섹션 기반 폼 레이아웃 지원 (접힘/펼침, 그리드 컬럼)
- 반복 섹션 지원 (겸직 등 동일 필드 그룹 여러 개 추가)
- 채번규칙 연동 (모달 열릴 때 또는 저장 시점 자동 생성)
- 다중 행 저장 지원 (공통 필드 + 개별 필드 조합)
- Select 옵션 동적 로드 (정적/테이블/공통코드)
- 스크린 디자이너 설정 패널 구현
This commit is contained in:
SeongHyun Kim 2025-12-04 17:40:41 +09:00
parent dfc83f6114
commit 6c751eb489
7 changed files with 2615 additions and 0 deletions

View File

@ -74,6 +74,9 @@ import "./location-swap-selector/LocationSwapSelectorRenderer";
// 🆕 화면 임베딩 및 분할 패널 컴포넌트 // 🆕 화면 임베딩 및 분할 패널 컴포넌트
import "./screen-split-panel/ScreenSplitPanelRenderer"; // 화면 분할 패널 (좌우 화면 임베딩 + 데이터 전달) import "./screen-split-panel/ScreenSplitPanelRenderer"; // 화면 분할 패널 (좌우 화면 임베딩 + 데이터 전달)
// 🆕 범용 폼 모달 컴포넌트
import "./universal-form-modal/UniversalFormModalRenderer"; // 섹션 기반 폼, 채번규칙, 다중 행 저장 지원
/** /**
* *
*/ */

View File

@ -0,0 +1,951 @@
"use client";
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { ChevronDown, ChevronUp, Plus, Trash2, RefreshCw } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { generateNumberingCode } from "@/lib/api/numberingRule";
import {
UniversalFormModalComponentProps,
UniversalFormModalConfig,
FormSectionConfig,
FormFieldConfig,
FormDataState,
RepeatSectionItem,
SelectOptionConfig,
} from "./types";
import { defaultConfig, generateUniqueId } from "./config";
/**
*
*
* , , .
*/
export function UniversalFormModalComponent({
component,
config: propConfig,
isDesignMode = false,
isSelected = false,
className,
style,
initialData,
onSave,
onCancel,
onChange,
}: UniversalFormModalComponentProps) {
// 설정 병합
const config: UniversalFormModalConfig = useMemo(() => {
const componentConfig = component?.config || {};
return {
...defaultConfig,
...propConfig,
...componentConfig,
modal: {
...defaultConfig.modal,
...propConfig?.modal,
...componentConfig.modal,
},
saveConfig: {
...defaultConfig.saveConfig,
...propConfig?.saveConfig,
...componentConfig.saveConfig,
multiRowSave: {
...defaultConfig.saveConfig.multiRowSave,
...propConfig?.saveConfig?.multiRowSave,
...componentConfig.saveConfig?.multiRowSave,
},
afterSave: {
...defaultConfig.saveConfig.afterSave,
...propConfig?.saveConfig?.afterSave,
...componentConfig.saveConfig?.afterSave,
},
},
};
}, [component?.config, propConfig]);
// 폼 데이터 상태
const [formData, setFormData] = useState<FormDataState>({});
const [, setOriginalData] = useState<Record<string, any>>({});
// 반복 섹션 데이터
const [repeatSections, setRepeatSections] = useState<{
[sectionId: string]: RepeatSectionItem[];
}>({});
// 섹션 접힘 상태
const [collapsedSections, setCollapsedSections] = useState<Set<string>>(new Set());
// Select 옵션 캐시
const [selectOptionsCache, setSelectOptionsCache] = useState<{
[key: string]: { value: string; label: string }[];
}>({});
// 로딩 상태
const [saving, setSaving] = useState(false);
// 삭제 확인 다이얼로그
const [deleteDialog, setDeleteDialog] = useState<{
open: boolean;
sectionId: string;
itemId: string;
}>({ open: false, sectionId: "", itemId: "" });
// 초기화
useEffect(() => {
initializeForm();
}, [config, initialData]);
// 폼 초기화
const initializeForm = useCallback(async () => {
const newFormData: FormDataState = {};
const newRepeatSections: { [sectionId: string]: RepeatSectionItem[] } = {};
const newCollapsed = new Set<string>();
// 섹션별 초기화
for (const section of config.sections) {
// 접힘 상태 초기화
if (section.defaultCollapsed) {
newCollapsed.add(section.id);
}
if (section.repeatable) {
// 반복 섹션 초기화
const minItems = section.repeatConfig?.minItems || 0;
const items: RepeatSectionItem[] = [];
for (let i = 0; i < minItems; i++) {
items.push(createRepeatItem(section, i));
}
newRepeatSections[section.id] = items;
} else {
// 일반 섹션 필드 초기화
for (const field of section.fields) {
// 기본값 설정
let value = field.defaultValue ?? "";
// 부모에서 전달받은 값 적용
if (field.receiveFromParent && initialData) {
const parentField = field.parentFieldName || field.columnName;
if (initialData[parentField] !== undefined) {
value = initialData[parentField];
}
}
newFormData[field.columnName] = value;
}
}
}
setFormData(newFormData);
setRepeatSections(newRepeatSections);
setCollapsedSections(newCollapsed);
setOriginalData(initialData || {});
// 채번규칙 자동 생성
await generateNumberingValues(newFormData);
}, [config, initialData]);
// 반복 섹션 아이템 생성
const createRepeatItem = (section: FormSectionConfig, index: number): RepeatSectionItem => {
const item: RepeatSectionItem = {
_id: generateUniqueId("repeat"),
_index: index,
};
for (const field of section.fields) {
item[field.columnName] = field.defaultValue ?? "";
}
return item;
};
// 채번규칙 자동 생성
const generateNumberingValues = useCallback(
async (currentFormData: FormDataState) => {
const updatedData = { ...currentFormData };
let hasChanges = false;
for (const section of config.sections) {
if (section.repeatable) continue;
for (const field of section.fields) {
if (
field.numberingRule?.enabled &&
field.numberingRule?.generateOnOpen &&
field.numberingRule?.ruleId &&
!updatedData[field.columnName]
) {
try {
const response = await generateNumberingCode(field.numberingRule.ruleId);
if (response.success && response.data?.generatedCode) {
updatedData[field.columnName] = response.data.generatedCode;
hasChanges = true;
}
} catch (error) {
console.error(`채번규칙 생성 실패 (${field.columnName}):`, error);
}
}
}
}
if (hasChanges) {
setFormData(updatedData);
}
},
[config],
);
// 필드 값 변경 핸들러
const handleFieldChange = useCallback(
(columnName: string, value: any) => {
setFormData((prev) => {
const newData = { ...prev, [columnName]: value };
onChange?.(newData);
return newData;
});
},
[onChange],
);
// 반복 섹션 필드 값 변경 핸들러
const handleRepeatFieldChange = useCallback((sectionId: string, itemId: string, columnName: string, value: any) => {
setRepeatSections((prev) => {
const items = prev[sectionId] || [];
const newItems = items.map((item) => (item._id === itemId ? { ...item, [columnName]: value } : item));
return { ...prev, [sectionId]: newItems };
});
}, []);
// 반복 섹션 아이템 추가
const handleAddRepeatItem = useCallback(
(sectionId: string) => {
const section = config.sections.find((s) => s.id === sectionId);
if (!section) return;
const maxItems = section.repeatConfig?.maxItems || 10;
setRepeatSections((prev) => {
const items = prev[sectionId] || [];
if (items.length >= maxItems) {
toast.error(`최대 ${maxItems}개까지만 추가할 수 있습니다.`);
return prev;
}
const newItem = createRepeatItem(section, items.length);
return { ...prev, [sectionId]: [...items, newItem] };
});
},
[config],
);
// 반복 섹션 아이템 삭제
const handleRemoveRepeatItem = useCallback(
(sectionId: string, itemId: string) => {
const section = config.sections.find((s) => s.id === sectionId);
if (!section) return;
const minItems = section.repeatConfig?.minItems || 0;
setRepeatSections((prev) => {
const items = prev[sectionId] || [];
if (items.length <= minItems) {
toast.error(`최소 ${minItems}개는 유지해야 합니다.`);
return prev;
}
const newItems = items.filter((item) => item._id !== itemId).map((item, index) => ({ ...item, _index: index }));
return { ...prev, [sectionId]: newItems };
});
setDeleteDialog({ open: false, sectionId: "", itemId: "" });
},
[config],
);
// 섹션 접힘 토글
const toggleSectionCollapse = useCallback((sectionId: string) => {
setCollapsedSections((prev) => {
const newSet = new Set(prev);
if (newSet.has(sectionId)) {
newSet.delete(sectionId);
} else {
newSet.add(sectionId);
}
return newSet;
});
}, []);
// Select 옵션 로드
const loadSelectOptions = useCallback(
async (fieldId: string, optionConfig: SelectOptionConfig): Promise<{ value: string; label: string }[]> => {
// 캐시 확인
if (selectOptionsCache[fieldId]) {
return selectOptionsCache[fieldId];
}
let options: { value: string; label: string }[] = [];
try {
if (optionConfig.type === "static") {
options = optionConfig.staticOptions || [];
} else if (optionConfig.type === "table" && optionConfig.tableName) {
const response = await apiClient.get(`/table-management/tables/${optionConfig.tableName}/data`, {
params: { limit: 1000 },
});
if (response.data?.success && response.data?.data) {
options = response.data.data.map((row: any) => ({
value: String(row[optionConfig.valueColumn || "id"]),
label: String(row[optionConfig.labelColumn || "name"]),
}));
}
} else if (optionConfig.type === "code" && optionConfig.codeCategory) {
const response = await apiClient.get(`/common-code/${optionConfig.codeCategory}`);
if (response.data?.success && response.data?.data) {
options = response.data.data.map((code: any) => ({
value: code.code_value || code.codeValue,
label: code.code_name || code.codeName,
}));
}
}
// 캐시 저장
setSelectOptionsCache((prev) => ({ ...prev, [fieldId]: options }));
} catch (error) {
console.error(`Select 옵션 로드 실패 (${fieldId}):`, error);
}
return options;
},
[selectOptionsCache],
);
// 저장 처리
const handleSave = useCallback(async () => {
if (!config.saveConfig.tableName) {
toast.error("저장할 테이블이 설정되지 않았습니다.");
return;
}
setSaving(true);
try {
const { multiRowSave } = config.saveConfig;
if (multiRowSave?.enabled) {
// 다중 행 저장
await saveMultipleRows();
} else {
// 단일 행 저장
await saveSingleRow();
}
// 저장 후 동작
if (config.saveConfig.afterSave?.showToast) {
toast.success("저장되었습니다.");
}
if (config.saveConfig.afterSave?.refreshParent) {
window.dispatchEvent(new CustomEvent("refreshParentData"));
}
onSave?.(formData);
} catch (error: any) {
console.error("저장 실패:", error);
toast.error(error.message || "저장에 실패했습니다.");
} finally {
setSaving(false);
}
}, [config, formData, repeatSections, onSave]);
// 단일 행 저장
const saveSingleRow = async () => {
const dataToSave = { ...formData };
// 메타데이터 필드 제거
Object.keys(dataToSave).forEach((key) => {
if (key.startsWith("_")) {
delete dataToSave[key];
}
});
// 저장 시점 채번규칙 처리
for (const section of config.sections) {
for (const field of section.fields) {
if (
field.numberingRule?.enabled &&
field.numberingRule?.generateOnSave &&
field.numberingRule?.ruleId &&
!dataToSave[field.columnName]
) {
const response = await generateNumberingCode(field.numberingRule.ruleId);
if (response.success && response.data?.generatedCode) {
dataToSave[field.columnName] = response.data.generatedCode;
}
}
}
}
const response = await apiClient.post(`/table-management/tables/${config.saveConfig.tableName}/add`, dataToSave);
if (!response.data?.success) {
throw new Error(response.data?.message || "저장 실패");
}
};
// 다중 행 저장 (겸직 등)
const saveMultipleRows = async () => {
const { multiRowSave } = config.saveConfig;
if (!multiRowSave) return;
const { commonFields = [], repeatSectionId = "", typeColumn, mainTypeValue, subTypeValue, mainSectionFields } =
multiRowSave;
// 공통 필드 데이터 추출
const commonData: Record<string, any> = {};
for (const fieldName of commonFields) {
if (formData[fieldName] !== undefined) {
commonData[fieldName] = formData[fieldName];
}
}
// 메인 섹션 필드 데이터 추출
const mainSectionData: Record<string, any> = {};
if (mainSectionFields) {
for (const fieldName of mainSectionFields) {
if (formData[fieldName] !== undefined) {
mainSectionData[fieldName] = formData[fieldName];
}
}
}
// 저장할 행들 준비
const rowsToSave: Record<string, any>[] = [];
// 1. 메인 행 생성
const mainRow: Record<string, any> = {
...commonData,
...mainSectionData,
};
if (typeColumn) {
mainRow[typeColumn] = mainTypeValue || "main";
}
rowsToSave.push(mainRow);
// 2. 반복 섹션 행들 생성 (겸직 등)
const repeatItems = repeatSections[repeatSectionId] || [];
for (const item of repeatItems) {
const subRow: Record<string, any> = { ...commonData };
// 반복 섹션 필드 복사
Object.keys(item).forEach((key) => {
if (!key.startsWith("_")) {
subRow[key] = item[key];
}
});
if (typeColumn) {
subRow[typeColumn] = subTypeValue || "concurrent";
}
rowsToSave.push(subRow);
}
// 저장 시점 채번규칙 처리 (메인 행만)
for (const section of config.sections) {
if (section.repeatable) continue;
for (const field of section.fields) {
if (field.numberingRule?.enabled && field.numberingRule?.generateOnSave && field.numberingRule?.ruleId) {
const response = await generateNumberingCode(field.numberingRule.ruleId);
if (response.success && response.data?.generatedCode) {
// 모든 행에 동일한 채번 값 적용 (공통 필드인 경우)
if (commonFields.includes(field.columnName)) {
rowsToSave.forEach((row) => {
row[field.columnName] = response.data?.generatedCode;
});
} else {
rowsToSave[0][field.columnName] = response.data?.generatedCode;
}
}
}
}
}
// 모든 행 저장
for (const row of rowsToSave) {
const response = await apiClient.post(`/table-management/tables/${config.saveConfig.tableName}/add`, row);
if (!response.data?.success) {
throw new Error(response.data?.message || "저장 실패");
}
}
console.log(`[UniversalFormModal] ${rowsToSave.length}개 행 저장 완료`);
};
// 폼 초기화
const handleReset = useCallback(() => {
initializeForm();
toast.info("폼이 초기화되었습니다.");
}, [initializeForm]);
// 필드 렌더링
const renderField = (field: FormFieldConfig, value: any, onChangeHandler: (value: any) => void, fieldKey: string) => {
const isDisabled = field.disabled || (field.numberingRule?.enabled && !field.numberingRule?.editable);
const isHidden = field.numberingRule?.hidden;
if (isHidden) {
return null;
}
const fieldElement = (() => {
switch (field.fieldType) {
case "textarea":
return (
<Textarea
id={fieldKey}
value={value || ""}
onChange={(e) => onChangeHandler(e.target.value)}
placeholder={field.placeholder}
disabled={isDisabled}
readOnly={field.readOnly}
className="min-h-[80px]"
/>
);
case "checkbox":
return (
<div className="flex items-center space-x-2 pt-2">
<Checkbox
id={fieldKey}
checked={!!value}
onCheckedChange={(checked) => onChangeHandler(checked)}
disabled={isDisabled}
/>
<Label htmlFor={fieldKey} className="text-sm font-normal">
{field.placeholder || field.label}
</Label>
</div>
);
case "select":
return (
<SelectField
fieldId={fieldKey}
value={value}
onChange={onChangeHandler}
optionConfig={field.selectOptions}
placeholder={field.placeholder || "선택하세요"}
disabled={isDisabled}
loadOptions={loadSelectOptions}
/>
);
case "date":
return (
<Input
id={fieldKey}
type="date"
value={value || ""}
onChange={(e) => onChangeHandler(e.target.value)}
disabled={isDisabled}
readOnly={field.readOnly}
/>
);
case "datetime":
return (
<Input
id={fieldKey}
type="datetime-local"
value={value || ""}
onChange={(e) => onChangeHandler(e.target.value)}
disabled={isDisabled}
readOnly={field.readOnly}
/>
);
case "number":
return (
<Input
id={fieldKey}
type="number"
value={value ?? ""}
onChange={(e) => onChangeHandler(e.target.value ? Number(e.target.value) : "")}
placeholder={field.placeholder}
disabled={isDisabled}
readOnly={field.readOnly}
/>
);
case "password":
return (
<Input
id={fieldKey}
type="password"
value={value || ""}
onChange={(e) => onChangeHandler(e.target.value)}
placeholder={field.placeholder}
disabled={isDisabled}
readOnly={field.readOnly}
/>
);
case "email":
return (
<Input
id={fieldKey}
type="email"
value={value || ""}
onChange={(e) => onChangeHandler(e.target.value)}
placeholder={field.placeholder}
disabled={isDisabled}
readOnly={field.readOnly}
/>
);
case "tel":
return (
<Input
id={fieldKey}
type="tel"
value={value || ""}
onChange={(e) => onChangeHandler(e.target.value)}
placeholder={field.placeholder}
disabled={isDisabled}
readOnly={field.readOnly}
/>
);
default:
return (
<Input
id={fieldKey}
type="text"
value={value || ""}
onChange={(e) => onChangeHandler(e.target.value)}
placeholder={field.placeholder}
disabled={isDisabled}
readOnly={field.readOnly}
/>
);
}
})();
// 체크박스는 라벨이 옆에 있으므로 별도 처리
if (field.fieldType === "checkbox") {
return (
<div key={fieldKey} className="space-y-1" style={{ gridColumn: `span ${field.gridSpan || 6}` }}>
{fieldElement}
</div>
);
}
return (
<div key={fieldKey} className="space-y-1" style={{ gridColumn: `span ${field.gridSpan || 6}` }}>
<Label htmlFor={fieldKey} className="text-sm font-medium">
{field.label}
{field.required && <span className="text-destructive ml-1">*</span>}
{field.numberingRule?.enabled && <span className="text-muted-foreground ml-1 text-xs">()</span>}
</Label>
{fieldElement}
</div>
);
};
// 섹션 렌더링
const renderSection = (section: FormSectionConfig) => {
const isCollapsed = collapsedSections.has(section.id);
if (section.repeatable) {
return renderRepeatableSection(section, isCollapsed);
}
return (
<Card key={section.id} className="mb-4">
{section.collapsible ? (
<Collapsible open={!isCollapsed} onOpenChange={() => toggleSectionCollapse(section.id)}>
<CollapsibleTrigger asChild>
<CardHeader className="hover:bg-muted/50 cursor-pointer transition-colors">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">{section.title}</CardTitle>
{section.description && (
<CardDescription className="mt-1 text-xs">{section.description}</CardDescription>
)}
</div>
{isCollapsed ? <ChevronDown className="h-4 w-4" /> : <ChevronUp className="h-4 w-4" />}
</div>
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent>
<div className="grid gap-4" style={{ gridTemplateColumns: `repeat(12, 1fr)` }}>
{section.fields.map((field) =>
renderField(
field,
formData[field.columnName],
(value) => handleFieldChange(field.columnName, value),
`${section.id}-${field.id}`,
),
)}
</div>
</CardContent>
</CollapsibleContent>
</Collapsible>
) : (
<>
<CardHeader className="pb-3">
<CardTitle className="text-base">{section.title}</CardTitle>
{section.description && <CardDescription className="text-xs">{section.description}</CardDescription>}
</CardHeader>
<CardContent>
<div className="grid gap-4" style={{ gridTemplateColumns: `repeat(12, 1fr)` }}>
{section.fields.map((field) =>
renderField(
field,
formData[field.columnName],
(value) => handleFieldChange(field.columnName, value),
`${section.id}-${field.id}`,
),
)}
</div>
</CardContent>
</>
)}
</Card>
);
};
// 반복 섹션 렌더링
const renderRepeatableSection = (section: FormSectionConfig, isCollapsed: boolean) => {
const items = repeatSections[section.id] || [];
const maxItems = section.repeatConfig?.maxItems || 10;
const canAdd = items.length < maxItems;
const content = (
<>
{items.length === 0 ? (
<div className="text-muted-foreground py-8 text-center">
<p className="text-sm"> .</p>
<Button variant="outline" size="sm" className="mt-2" onClick={() => handleAddRepeatItem(section.id)}>
<Plus className="mr-1 h-4 w-4" />
{section.repeatConfig?.addButtonText || "+ 추가"}
</Button>
</div>
) : (
<div className="space-y-4">
{items.map((item, index) => (
<div key={item._id} className="bg-muted/30 relative rounded-lg border p-4">
<div className="mb-3 flex items-center justify-between">
<span className="text-muted-foreground text-sm font-medium">
{(section.repeatConfig?.itemTitle || "항목 {index}").replace("{index}", String(index + 1))}
</span>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive h-8 w-8 p-0"
onClick={() => {
if (section.repeatConfig?.confirmRemove) {
setDeleteDialog({ open: true, sectionId: section.id, itemId: item._id });
} else {
handleRemoveRepeatItem(section.id, item._id);
}
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="grid gap-4" style={{ gridTemplateColumns: `repeat(12, 1fr)` }}>
{section.fields.map((field) =>
renderField(
field,
item[field.columnName],
(value) => handleRepeatFieldChange(section.id, item._id, field.columnName, value),
`${section.id}-${item._id}-${field.id}`,
),
)}
</div>
</div>
))}
{canAdd && (
<Button variant="outline" size="sm" className="w-full" onClick={() => handleAddRepeatItem(section.id)}>
<Plus className="mr-1 h-4 w-4" />
{section.repeatConfig?.addButtonText || "+ 추가"}
</Button>
)}
</div>
)}
</>
);
return (
<Card key={section.id} className="mb-4">
{section.collapsible ? (
<Collapsible open={!isCollapsed} onOpenChange={() => toggleSectionCollapse(section.id)}>
<CollapsibleTrigger asChild>
<CardHeader className="hover:bg-muted/50 cursor-pointer transition-colors">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">
{section.title}
<span className="text-muted-foreground ml-2 text-sm font-normal">({items.length})</span>
</CardTitle>
{section.description && (
<CardDescription className="mt-1 text-xs">{section.description}</CardDescription>
)}
</div>
{isCollapsed ? <ChevronDown className="h-4 w-4" /> : <ChevronUp className="h-4 w-4" />}
</div>
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent>{content}</CardContent>
</CollapsibleContent>
</Collapsible>
) : (
<>
<CardHeader className="pb-3">
<CardTitle className="text-base">
{section.title}
<span className="text-muted-foreground ml-2 text-sm font-normal">({items.length})</span>
</CardTitle>
{section.description && <CardDescription className="text-xs">{section.description}</CardDescription>}
</CardHeader>
<CardContent>{content}</CardContent>
</>
)}
</Card>
);
};
// 디자인 모드 렌더링
if (isDesignMode) {
return (
<div
className={cn(
"min-h-[200px] rounded-lg border-2 border-dashed p-4",
isSelected ? "border-primary bg-primary/5" : "border-muted",
className,
)}
style={style}
>
<div className="text-muted-foreground text-center">
<p className="font-medium">{config.modal.title || "범용 폼 모달"}</p>
<p className="mt-1 text-xs">
{config.sections.length} |{config.sections.reduce((acc, s) => acc + s.fields.length, 0)}
</p>
<p className="mt-1 text-xs"> : {config.saveConfig.tableName || "(미설정)"}</p>
</div>
</div>
);
}
return (
<div className={cn("w-full", className)} style={style}>
{/* 섹션들 */}
<div className="space-y-4">{config.sections.map((section) => renderSection(section))}</div>
{/* 버튼 영역 */}
<div className="mt-6 flex justify-end gap-2 border-t pt-4">
{config.modal.showResetButton && (
<Button variant="outline" onClick={handleReset} disabled={saving}>
<RefreshCw className="mr-1 h-4 w-4" />
{config.modal.resetButtonText || "초기화"}
</Button>
)}
<Button variant="outline" onClick={onCancel} disabled={saving}>
{config.modal.cancelButtonText || "취소"}
</Button>
<Button onClick={handleSave} disabled={saving || !config.saveConfig.tableName}>
{saving ? "저장 중..." : config.modal.saveButtonText || "저장"}
</Button>
</div>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog
open={deleteDialog.open}
onOpenChange={(open) => !open && setDeleteDialog({ open: false, sectionId: "", itemId: "" })}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription> ? .</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={() => handleRemoveRepeatItem(deleteDialog.sectionId, deleteDialog.itemId)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
// Select 필드 컴포넌트 (옵션 로딩 포함)
interface SelectFieldProps {
fieldId: string;
value: any;
onChange: (value: any) => void;
optionConfig?: SelectOptionConfig;
placeholder?: string;
disabled?: boolean;
loadOptions: (fieldId: string, config: SelectOptionConfig) => Promise<{ value: string; label: string }[]>;
}
function SelectField({ fieldId, value, onChange, optionConfig, placeholder, disabled, loadOptions }: SelectFieldProps) {
const [options, setOptions] = useState<{ value: string; label: string }[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (optionConfig) {
setLoading(true);
loadOptions(fieldId, optionConfig)
.then(setOptions)
.finally(() => setLoading(false));
}
}, [fieldId, optionConfig, loadOptions]);
return (
<Select value={value || ""} onValueChange={onChange} disabled={disabled || loading}>
<SelectTrigger>
<SelectValue placeholder={loading ? "로딩 중..." : placeholder} />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
export default UniversalFormModalComponent;

View File

@ -0,0 +1,35 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { UniversalFormModalDefinition } from "./index";
import { UniversalFormModalComponent } from "./UniversalFormModalComponent";
/**
*
*
*/
export class UniversalFormModalRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = UniversalFormModalDefinition;
render(): React.ReactElement {
return <UniversalFormModalComponent {...this.props} />;
}
/**
*
*/
protected handleFormDataChange = (data: any) => {
this.updateComponent({ formData: data });
};
/**
*
*/
protected handleSave = (data: any) => {
console.log("[UniversalFormModalRenderer] 저장 완료:", data);
};
}
// 자동 등록 실행
UniversalFormModalRenderer.registerSelf();

View File

@ -0,0 +1,138 @@
/**
*
*/
import { UniversalFormModalConfig } from "./types";
// 기본 설정값
export const defaultConfig: UniversalFormModalConfig = {
modal: {
title: "데이터 입력",
description: "",
size: "lg",
closeOnOutsideClick: false,
showCloseButton: true,
saveButtonText: "저장",
cancelButtonText: "취소",
showResetButton: false,
resetButtonText: "초기화",
},
sections: [
{
id: "default",
title: "기본 정보",
description: "",
collapsible: false,
defaultCollapsed: false,
columns: 2,
gap: "16px",
fields: [],
repeatable: false,
},
],
saveConfig: {
tableName: "",
primaryKeyColumn: "id",
multiRowSave: {
enabled: false,
commonFields: [],
repeatSectionId: "",
typeColumn: "",
mainTypeValue: "main",
subTypeValue: "concurrent",
mainSectionFields: [],
},
afterSave: {
closeModal: true,
refreshParent: true,
showToast: true,
},
},
editMode: {
enabled: false,
loadDataOnOpen: true,
identifierField: "id",
},
};
// 기본 필드 설정
export const defaultFieldConfig = {
id: "",
columnName: "",
label: "",
fieldType: "text" as const,
required: false,
defaultValue: "",
placeholder: "",
disabled: false,
readOnly: false,
width: "100%",
gridSpan: 6,
receiveFromParent: false,
};
// 기본 섹션 설정
export const defaultSectionConfig = {
id: "",
title: "새 섹션",
description: "",
collapsible: false,
defaultCollapsed: false,
columns: 2,
gap: "16px",
fields: [],
repeatable: false,
repeatConfig: {
minItems: 0,
maxItems: 10,
addButtonText: "+ 추가",
removeButtonText: "삭제",
itemTitle: "항목 {index}",
confirmRemove: false,
},
};
// 기본 채번규칙 설정
export const defaultNumberingRuleConfig = {
enabled: false,
ruleId: "",
editable: false,
hidden: false,
generateOnOpen: true,
generateOnSave: false,
};
// 기본 Select 옵션 설정
export const defaultSelectOptionsConfig = {
type: "static" as const,
staticOptions: [],
tableName: "",
valueColumn: "",
labelColumn: "",
filterCondition: "",
codeCategory: "",
};
// 모달 크기별 너비
export const MODAL_SIZE_MAP = {
sm: 400,
md: 600,
lg: 800,
xl: 1000,
full: "100%",
} as const;
// 유틸리티: 고유 ID 생성
export const generateUniqueId = (prefix: string = "item"): string => {
return `${prefix}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
};
// 유틸리티: 섹션 ID 생성
export const generateSectionId = (): string => {
return generateUniqueId("section");
};
// 유틸리티: 필드 ID 생성
export const generateFieldId = (): string => {
return generateUniqueId("field");
};

View File

@ -0,0 +1,77 @@
"use client";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { UniversalFormModalComponent } from "./UniversalFormModalComponent";
import { UniversalFormModalConfigPanel } from "./UniversalFormModalConfigPanel";
import { defaultConfig } from "./config";
/**
*
*
* , ,
* .
*/
export const UniversalFormModalDefinition = createComponentDefinition({
id: "universal-form-modal",
name: "범용 폼 모달",
nameEng: "Universal Form Modal",
description: "섹션 기반 폼 레이아웃, 채번규칙, 다중 행 저장을 지원하는 범용 모달 컴포넌트",
category: ComponentCategory.INPUT,
webType: "form",
component: UniversalFormModalComponent,
defaultConfig: defaultConfig,
defaultSize: {
width: 800,
height: 600,
gridColumnSpan: "12",
},
configPanel: UniversalFormModalConfigPanel,
icon: "FormInput",
tags: ["폼", "모달", "입력", "저장", "채번", "겸직", "다중행"],
version: "1.0.0",
author: "개발팀",
documentation: `
##
###
- ** **: ,
- ** **:
- ** **: ( )
- ** **: +
- ** **:
###
1. +
2. +
3. +
###
1.
2. ( , )
3.
4. ( )
5. ( )
6. ( )
`,
});
// 컴포넌트 내보내기
export { UniversalFormModalComponent } from "./UniversalFormModalComponent";
export { UniversalFormModalConfigPanel } from "./UniversalFormModalConfigPanel";
export { defaultConfig } from "./config";
// 타입 내보내기
export type {
UniversalFormModalConfig,
UniversalFormModalComponentProps,
UniversalFormModalConfigPanelProps,
FormSectionConfig,
FormFieldConfig,
SaveConfig,
MultiRowSaveConfig,
NumberingRuleConfig,
SelectOptionConfig,
FormDataState,
RepeatSectionItem,
} from "./types";

View File

@ -0,0 +1,259 @@
/**
*
*
* , ,
* .
*/
// Select 옵션 설정
export interface SelectOptionConfig {
type?: "static" | "table" | "code"; // 옵션 타입 (기본: static)
// 정적 옵션
staticOptions?: { value: string; label: string }[];
// 테이블 기반 옵션
tableName?: string;
valueColumn?: string;
labelColumn?: string;
filterCondition?: string;
// 공통코드 기반 옵션
codeCategory?: string;
}
// 채번규칙 설정
export interface NumberingRuleConfig {
enabled?: boolean; // 사용 여부 (기본: false)
ruleId?: string; // 채번규칙 ID
editable?: boolean; // 사용자 수정 가능 여부 (기본: false)
hidden?: boolean; // 숨김 여부 - 자동 저장만 (기본: false)
generateOnOpen?: boolean; // 모달 열릴 때 생성 (기본: true)
generateOnSave?: boolean; // 저장 시점에 생성 (기본: false)
}
// 필드 유효성 검사 설정
export interface FieldValidationConfig {
minLength?: number;
maxLength?: number;
min?: number;
max?: number;
pattern?: string;
patternMessage?: string;
customValidator?: string; // 커스텀 검증 함수명
}
// 필드 설정
export interface FormFieldConfig {
id: string;
columnName: string; // DB 컬럼명
label: string; // 표시 라벨
fieldType:
| "text"
| "number"
| "date"
| "datetime"
| "select"
| "checkbox"
| "textarea"
| "password"
| "email"
| "tel";
required?: boolean;
defaultValue?: any;
placeholder?: string;
disabled?: boolean;
readOnly?: boolean;
hidden?: boolean; // 화면에 표시하지 않고 자동 저장만
// 레이아웃
width?: string; // 필드 너비 (예: "50%", "100%")
gridColumn?: number; // 그리드 컬럼 위치 (1-12)
gridSpan?: number; // 그리드 컬럼 스팬 (1-12)
// 채번규칙 설정
numberingRule?: NumberingRuleConfig;
// Select 옵션
selectOptions?: SelectOptionConfig;
// 유효성 검사
validation?: FieldValidationConfig;
// 외부 데이터 수신
receiveFromParent?: boolean; // 부모에서 값 받기
parentFieldName?: string; // 부모 필드명 (다르면 지정)
// 조건부 표시
visibleCondition?: {
field: string; // 참조할 필드
operator: "eq" | "ne" | "gt" | "lt" | "in" | "notIn";
value: any;
};
// 필드 간 연동
dependsOn?: {
field: string; // 의존하는 필드
action: "filter" | "setValue" | "clear";
config?: any;
};
}
// 반복 섹션 설정
export interface RepeatSectionConfig {
minItems?: number; // 최소 항목 수 (기본: 0)
maxItems?: number; // 최대 항목 수 (기본: 10)
addButtonText?: string; // 추가 버튼 텍스트 (기본: "+ 추가")
removeButtonText?: string; // 삭제 버튼 텍스트 (기본: "삭제")
itemTitle?: string; // 항목 제목 템플릿 (예: "겸직 {index}")
confirmRemove?: boolean; // 삭제 시 확인 (기본: false)
}
// 섹션 설정
export interface FormSectionConfig {
id: string;
title: string;
description?: string;
collapsible?: boolean; // 접을 수 있는지 (기본: false)
defaultCollapsed?: boolean; // 기본 접힘 상태 (기본: false)
fields: FormFieldConfig[];
// 반복 섹션 (겸직 등)
repeatable?: boolean;
repeatConfig?: RepeatSectionConfig;
// 섹션 레이아웃
columns?: number; // 필드 배치 컬럼 수 (기본: 2)
gap?: string; // 필드 간 간격
}
// 다중 행 저장 설정
export interface MultiRowSaveConfig {
enabled?: boolean; // 사용 여부 (기본: false)
commonFields?: string[]; // 모든 행에 공통 저장할 필드 (columnName 기준)
repeatSectionId?: string; // 반복 섹션 ID
typeColumn?: string; // 구분 컬럼명 (예: "employment_type")
mainTypeValue?: string; // 메인 행 값 (예: "main")
subTypeValue?: string; // 서브 행 값 (예: "concurrent")
// 메인 섹션 필드 (반복 섹션이 아닌 곳의 부서/직급 등)
mainSectionFields?: string[]; // 메인 행에만 저장할 필드
}
// 저장 설정
export interface SaveConfig {
tableName: string;
primaryKeyColumn?: string; // PK 컬럼 (수정 시 사용)
// 다중 행 저장 설정
multiRowSave?: MultiRowSaveConfig;
// 저장 후 동작 (간편 설정)
showToast?: boolean; // 토스트 메시지 (기본: true)
refreshParent?: boolean; // 부모 새로고침 (기본: true)
// 저장 후 동작 (상세 설정)
afterSave?: {
closeModal?: boolean; // 모달 닫기 (기본: true)
refreshParent?: boolean; // 부모 새로고침 (기본: true)
showToast?: boolean; // 토스트 메시지 (기본: true)
customAction?: string; // 커스텀 액션 이벤트명
};
}
// 모달 설정
export interface ModalConfig {
title: string;
description?: string;
size: "sm" | "md" | "lg" | "xl" | "full";
closeOnOutsideClick?: boolean;
showCloseButton?: boolean;
// 버튼 설정
saveButtonText?: string; // 저장 버튼 텍스트 (기본: "저장")
cancelButtonText?: string; // 취소 버튼 텍스트 (기본: "취소")
showResetButton?: boolean; // 초기화 버튼 표시
resetButtonText?: string; // 초기화 버튼 텍스트
}
// 전체 설정
export interface UniversalFormModalConfig {
modal: ModalConfig;
sections: FormSectionConfig[];
saveConfig: SaveConfig;
// 수정 모드 설정
editMode?: {
enabled: boolean;
loadDataOnOpen?: boolean; // 모달 열릴 때 데이터 로드
identifierField?: string; // 식별자 필드 (user_id 등)
};
}
// 반복 섹션 데이터 아이템
export interface RepeatSectionItem {
_id: string; // 내부 고유 ID
_index: number; // 인덱스
[key: string]: any; // 필드 데이터
}
// 폼 데이터 상태
export interface FormDataState {
// 일반 필드 데이터
[key: string]: any;
// 반복 섹션 데이터
_repeatSections?: {
[sectionId: string]: RepeatSectionItem[];
};
}
// 컴포넌트 Props
export interface UniversalFormModalComponentProps {
component?: any;
config?: UniversalFormModalConfig;
isDesignMode?: boolean;
isSelected?: boolean;
className?: string;
style?: React.CSSProperties;
// 외부에서 전달받는 초기 데이터
initialData?: Record<string, any>;
// 이벤트 핸들러
onSave?: (data: any) => void;
onCancel?: () => void;
onChange?: (data: FormDataState) => void;
}
// ConfigPanel Props
export interface UniversalFormModalConfigPanelProps {
config: UniversalFormModalConfig;
onChange: (config: UniversalFormModalConfig) => void;
}
// 필드 타입 옵션
export const FIELD_TYPE_OPTIONS = [
{ value: "text", label: "텍스트" },
{ value: "number", label: "숫자" },
{ value: "date", label: "날짜" },
{ value: "datetime", label: "날짜시간" },
{ value: "select", label: "선택(드롭다운)" },
{ value: "checkbox", label: "체크박스" },
{ value: "textarea", label: "여러 줄 텍스트" },
{ value: "password", label: "비밀번호" },
{ value: "email", label: "이메일" },
{ value: "tel", label: "전화번호" },
] as const;
// 모달 크기 옵션
export const MODAL_SIZE_OPTIONS = [
{ value: "sm", label: "작게 (400px)" },
{ value: "md", label: "보통 (600px)" },
{ value: "lg", label: "크게 (800px)" },
{ value: "xl", label: "매우 크게 (1000px)" },
{ value: "full", label: "전체 화면" },
] as const;
// Select 옵션 타입
export const SELECT_OPTION_TYPE_OPTIONS = [
{ value: "static", label: "직접 입력" },
{ value: "table", label: "테이블 참조" },
{ value: "code", label: "공통코드" },
] as const;