feat(universal-form-modal): 범용 폼 모달 컴포넌트 신규 개발
- 섹션 기반 폼 레이아웃 지원 (접힘/펼침, 그리드 컬럼) - 반복 섹션 지원 (겸직 등 동일 필드 그룹 여러 개 추가) - 채번규칙 연동 (모달 열릴 때 또는 저장 시점 자동 생성) - 다중 행 저장 지원 (공통 필드 + 개별 필드 조합) - Select 옵션 동적 로드 (정적/테이블/공통코드) - 스크린 디자이너 설정 패널 구현
This commit is contained in:
parent
dfc83f6114
commit
6c751eb489
|
|
@ -74,6 +74,9 @@ import "./location-swap-selector/LocationSwapSelectorRenderer";
|
||||||
// 🆕 화면 임베딩 및 분할 패널 컴포넌트
|
// 🆕 화면 임베딩 및 분할 패널 컴포넌트
|
||||||
import "./screen-split-panel/ScreenSplitPanelRenderer"; // 화면 분할 패널 (좌우 화면 임베딩 + 데이터 전달)
|
import "./screen-split-panel/ScreenSplitPanelRenderer"; // 화면 분할 패널 (좌우 화면 임베딩 + 데이터 전달)
|
||||||
|
|
||||||
|
// 🆕 범용 폼 모달 컴포넌트
|
||||||
|
import "./universal-form-modal/UniversalFormModalRenderer"; // 섹션 기반 폼, 채번규칙, 다중 행 저장 지원
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 컴포넌트 초기화 함수
|
* 컴포넌트 초기화 함수
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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();
|
||||||
|
|
@ -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");
|
||||||
|
};
|
||||||
|
|
@ -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";
|
||||||
|
|
@ -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;
|
||||||
Loading…
Reference in New Issue