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 "./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