ERP-node/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent...

1260 lines
43 KiB
TypeScript

"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 [linkedFieldDataCache, setLinkedFieldDataCache] = useState<{
[tableKey: string]: Record<string, any>[];
}>({});
// 로딩 상태
const [saving, setSaving] = useState(false);
// 삭제 확인 다이얼로그
const [deleteDialog, setDeleteDialog] = useState<{
open: boolean;
sectionId: string;
itemId: string;
}>({ open: false, sectionId: "", itemId: "" });
// 초기화
useEffect(() => {
initializeForm();
}, [config, initialData]);
// 필드 레벨 linkedFieldGroup 데이터 로드
useEffect(() => {
const loadData = async () => {
const tablesToLoad = new Set<string>();
// 모든 섹션의 필드에서 linkedFieldGroup.sourceTable 수집
config.sections.forEach((section) => {
section.fields.forEach((field) => {
if (field.linkedFieldGroup?.enabled && field.linkedFieldGroup?.sourceTable) {
tablesToLoad.add(field.linkedFieldGroup.sourceTable);
}
});
});
// 각 테이블 데이터 로드
for (const tableName of tablesToLoad) {
if (!linkedFieldDataCache[tableName]) {
console.log(`[UniversalFormModal] linkedFieldGroup 데이터 로드: ${tableName}`);
await loadLinkedFieldData(tableName);
}
}
};
loadData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config.sections]);
// 폼 초기화
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는 렌더링 외부에서 호출해야 함 (setTimeout 사용)
if (onChange) {
setTimeout(() => onChange(newData), 0);
}
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 loadLinkedFieldData = useCallback(
async (sourceTable: string): Promise<Record<string, any>[]> => {
// 캐시 확인 - 이미 배열로 캐시되어 있으면 반환
if (Array.isArray(linkedFieldDataCache[sourceTable]) && linkedFieldDataCache[sourceTable].length > 0) {
return linkedFieldDataCache[sourceTable];
}
let data: Record<string, any>[] = [];
try {
console.log(`[연동필드] ${sourceTable} 데이터 로드 시작`);
// 현재 회사 기준으로 데이터 조회 (POST 메서드, autoFilter 사용)
const response = await apiClient.post(`/table-management/tables/${sourceTable}/data`, {
page: 1,
size: 1000,
autoFilter: { enabled: true, filterColumn: "company_code" }, // 현재 회사 기준 자동 필터링
});
console.log(`[연동필드] ${sourceTable} API 응답:`, response.data);
if (response.data?.success) {
// data 구조 확인: { data: { data: [...], total, page, ... } } 또는 { data: [...] }
const responseData = response.data?.data;
if (Array.isArray(responseData)) {
// 직접 배열인 경우
data = responseData;
} else if (responseData?.data && Array.isArray(responseData.data)) {
// { data: [...], total: ... } 형태 (tableManagementService 응답)
data = responseData.data;
} else if (responseData?.rows && Array.isArray(responseData.rows)) {
// { rows: [...], total: ... } 형태 (다른 API 응답)
data = responseData.rows;
}
console.log(`[연동필드] ${sourceTable} 파싱된 데이터 ${data.length}개:`, data.slice(0, 3));
}
// 캐시 저장 (빈 배열이라도 저장하여 중복 요청 방지)
setLinkedFieldDataCache((prev) => ({ ...prev, [sourceTable]: data }));
} catch (error) {
console.error(`연동 필드 데이터 로드 실패 (${sourceTable}):`, error);
// 실패해도 빈 배열로 캐시하여 무한 요청 방지
setLinkedFieldDataCache((prev) => ({ ...prev, [sourceTable]: [] }));
}
return data;
},
[linkedFieldDataCache],
);
// 필수 필드 검증
const validateRequiredFields = useCallback((): { valid: boolean; missingFields: string[] } => {
const missingFields: string[] = [];
for (const section of config.sections) {
if (section.repeatable) continue; // 반복 섹션은 별도 검증
for (const field of section.fields) {
if (field.required && !field.hidden && !field.numberingRule?.hidden) {
const value = formData[field.columnName];
if (value === undefined || value === null || value === "") {
missingFields.push(field.label || field.columnName);
}
}
}
}
return { valid: missingFields.length === 0, missingFields };
}, [config.sections, formData]);
// 저장 처리
const handleSave = useCallback(async () => {
if (!config.saveConfig.tableName) {
toast.error("저장할 테이블이 설정되지 않았습니다.");
return;
}
// 필수 필드 검증
const { valid, missingFields } = validateRequiredFields();
if (!valid) {
toast.error(`필수 항목을 입력해주세요: ${missingFields.join(", ")}`);
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 콜백은 저장 완료 알림용으로만 사용
// 실제 저장은 이미 위에서 완료됨 (saveSingleRow 또는 saveMultipleRows)
// EditModal 등 부모 컴포넌트의 저장 로직이 다시 실행되지 않도록
// _saveCompleted 플래그를 포함하여 전달
if (onSave) {
onSave({ ...formData, _saveCompleted: true });
}
} catch (error: any) {
console.error("저장 실패:", error);
// axios 에러의 경우 서버 응답 메시지 추출
const errorMessage =
error.response?.data?.message ||
error.response?.data?.error?.details ||
error.message ||
"저장에 실패했습니다.";
toast.error(errorMessage);
} finally {
setSaving(false);
}
}, [config, formData, repeatSections, onSave, validateRequiredFields]);
// 단일 행 저장
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;
let { commonFields = [], repeatSectionId = "", typeColumn, mainTypeValue, subTypeValue, mainSectionFields = [] } =
multiRowSave;
// 공통 필드가 설정되지 않은 경우, 기본정보 섹션의 모든 필드를 공통 필드로 사용
if (commonFields.length === 0) {
const nonRepeatableSections = config.sections.filter((s) => !s.repeatable);
commonFields = nonRepeatableSections.flatMap((s) => s.fields.map((f) => f.columnName));
console.log("[UniversalFormModal] 공통 필드 자동 설정:", commonFields);
}
// 반복 섹션 ID가 설정되지 않은 경우, 첫 번째 반복 섹션 사용
if (!repeatSectionId) {
const repeatableSection = config.sections.find((s) => s.repeatable);
if (repeatableSection) {
repeatSectionId = repeatableSection.id;
console.log("[UniversalFormModal] 반복 섹션 자동 설정:", repeatSectionId);
}
}
// 디버깅: 설정 확인
console.log("[UniversalFormModal] 다중 행 저장 설정:", {
commonFields,
mainSectionFields,
repeatSectionId,
typeColumn,
mainTypeValue,
subTypeValue,
});
console.log("[UniversalFormModal] 현재 formData:", formData);
// 공통 필드 데이터 추출
const commonData: Record<string, any> = {};
for (const fieldName of commonFields) {
if (formData[fieldName] !== undefined) {
commonData[fieldName] = formData[fieldName];
}
}
console.log("[UniversalFormModal] 추출된 공통 데이터:", commonData);
// 메인 섹션 필드 데이터 추출
const mainSectionData: Record<string, any> = {};
if (mainSectionFields && mainSectionFields.length > 0) {
for (const fieldName of mainSectionFields) {
if (formData[fieldName] !== undefined) {
mainSectionData[fieldName] = formData[fieldName];
}
}
}
console.log("[UniversalFormModal] 추출된 메인 섹션 데이터:", mainSectionData);
// 저장할 행들 준비
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;
}
}
}
}
}
// 모든 행 저장
console.log("[UniversalFormModal] 저장할 행들:", rowsToSave);
console.log("[UniversalFormModal] 저장 테이블:", config.saveConfig.tableName);
for (let i = 0; i < rowsToSave.length; i++) {
const row = rowsToSave[i];
console.log(`[UniversalFormModal] ${i + 1}번째 행 저장 시도:`, row);
// 빈 객체 체크
if (Object.keys(row).length === 0) {
console.warn(`[UniversalFormModal] ${i + 1}번째 행이 비어있습니다. 건너뜁니다.`);
continue;
}
const response = await apiClient.post(`/table-management/tables/${config.saveConfig.tableName}/add`, row);
if (!response.data?.success) {
throw new Error(response.data?.message || `${i + 1}번째 행 저장 실패`);
}
}
console.log(`[UniversalFormModal] ${rowsToSave.length}개 행 저장 완료`);
};
// 폼 초기화
const handleReset = useCallback(() => {
initializeForm();
toast.info("폼이 초기화되었습니다.");
}, [initializeForm]);
// 필드 요소 렌더링 (입력 컴포넌트만)
const renderFieldElement = (
field: FormFieldConfig,
value: any,
onChangeHandler: (value: any) => void,
fieldKey: string,
isDisabled: boolean,
) => {
return (() => {
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": {
// 다중 컬럼 저장이 활성화된 경우
const lfgMappings = field.linkedFieldGroup?.mappings;
if (field.linkedFieldGroup?.enabled && field.linkedFieldGroup?.sourceTable && lfgMappings && lfgMappings.length > 0) {
const lfg = field.linkedFieldGroup;
const sourceTableName = lfg.sourceTable as string;
const cachedData = linkedFieldDataCache[sourceTableName];
const sourceData = Array.isArray(cachedData) ? cachedData : [];
// 첫 번째 매핑의 sourceColumn을 드롭다운 값으로 사용
const valueColumn = lfgMappings[0].sourceColumn || "";
// 데이터 로드 (아직 없으면)
if (!cachedData && sourceTableName) {
loadLinkedFieldData(sourceTableName);
}
// 표시 텍스트 생성 함수
const getDisplayText = (row: Record<string, unknown>): string => {
const displayVal = row[lfg.displayColumn || ""] || "";
const valueVal = row[valueColumn] || "";
switch (lfg.displayFormat) {
case "code_name":
return `${valueVal} - ${displayVal}`;
case "name_code":
return `${displayVal} (${valueVal})`;
case "name_only":
default:
return String(displayVal);
}
};
return (
<Select
value={value || ""}
onValueChange={(selectedValue) => {
// 선택된 값에 해당하는 행 찾기
const selectedRow = sourceData.find((row) => String(row[valueColumn]) === selectedValue);
// 기본 필드 값 변경 (첫 번째 매핑의 값)
onChangeHandler(selectedValue);
// 매핑된 컬럼들도 함께 저장
if (selectedRow && lfg.mappings) {
lfg.mappings.forEach((mapping) => {
if (mapping.sourceColumn && mapping.targetColumn) {
const mappedValue = selectedRow[mapping.sourceColumn];
// formData에 직접 저장
setFormData((prev) => ({
...prev,
[mapping.targetColumn]: mappedValue,
}));
}
});
}
}}
disabled={isDisabled}
>
<SelectTrigger id={fieldKey} className="w-full">
<SelectValue placeholder={field.placeholder || "선택하세요"} />
</SelectTrigger>
<SelectContent>
{sourceData.length > 0 ? (
sourceData.map((row, index) => (
<SelectItem
key={`${row[valueColumn] || index}_${index}`}
value={String(row[valueColumn] || "")}
>
{getDisplayText(row)}
</SelectItem>
))
) : (
<SelectItem value="_empty" disabled>
{cachedData === undefined ? "데이터를 불러오는 중..." : "데이터가 없습니다"}
</SelectItem>
)}
</SelectContent>
</Select>
);
}
// 일반 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}
/>
);
}
})();
};
// 섹션의 열 수에 따른 기본 gridSpan 계산
const getDefaultGridSpan = (sectionColumns: number = 2): number => {
// 12칸 그리드 기준: 1열=12, 2열=6, 3열=4, 4열=3
return Math.floor(12 / sectionColumns);
};
// 필드 렌더링 (섹션 열 수 적용)
const renderFieldWithColumns = (
field: FormFieldConfig,
value: any,
onChangeHandler: (value: any) => void,
fieldKey: string,
sectionColumns: number = 2,
) => {
// 섹션 열 수에 따른 기본 gridSpan 계산 (섹션 열 수가 우선)
const defaultSpan = getDefaultGridSpan(sectionColumns);
// 섹션이 1열이면 무조건 12(전체 너비), 그 외에는 필드 설정 또는 기본값 사용
const actualGridSpan = sectionColumns === 1 ? 12 : field.gridSpan || defaultSpan;
const isDisabled = !!(field.disabled || (field.numberingRule?.enabled && !field.numberingRule?.editable));
const isHidden = field.hidden || field.numberingRule?.hidden;
if (isHidden) {
return null;
}
const fieldElement = renderFieldElement(field, value, onChangeHandler, fieldKey, isDisabled);
if (field.fieldType === "checkbox") {
return (
<div key={fieldKey} className="space-y-1" style={{ gridColumn: `span ${actualGridSpan}` }}>
{fieldElement}
</div>
);
}
return (
<div key={fieldKey} className="space-y-1" style={{ gridColumn: `span ${actualGridSpan}` }}>
<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);
const sectionColumns = section.columns || 2;
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) =>
renderFieldWithColumns(
field,
formData[field.columnName],
(value) => handleFieldChange(field.columnName, value),
`${section.id}-${field.id}`,
sectionColumns,
),
)}
</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) =>
renderFieldWithColumns(
field,
formData[field.columnName],
(value) => handleFieldChange(field.columnName, value),
`${section.id}-${field.id}`,
sectionColumns,
),
)}
</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 sectionColumns = section.columns || 2;
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) =>
renderFieldWithColumns(
field,
item[field.columnName],
(value) => handleRepeatFieldChange(section.id, item._id, field.columnName, value),
`${section.id}-${item._id}-${field.id}`,
sectionColumns,
),
)}
</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
type="button"
variant="outline"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleReset();
}}
disabled={saving}
>
<RefreshCw className="mr-1 h-4 w-4" />
{config.modal.resetButtonText || "초기화"}
</Button>
)}
<Button
type="button"
variant="outline"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onCancel?.();
}}
disabled={saving}
>
{config.modal.cancelButtonText || "취소"}
</Button>
<Button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
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;