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

1087 lines
36 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 [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는 렌더링 외부에서 호출해야 함 (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 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);
toast.error(error.message || "저장에 실패했습니다.");
} 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":
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;