1742 lines
63 KiB
TypeScript
1742 lines
63 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useCallback, useMemo, useRef } 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, Loader2 } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { cn } from "@/lib/utils";
|
|
import { apiClient } from "@/lib/api/client";
|
|
import { generateNumberingCode, allocateNumberingCode, previewNumberingCode } from "@/lib/api/numberingRule";
|
|
import { useCascadingDropdown } from "@/hooks/useCascadingDropdown";
|
|
import { CascadingDropdownConfig } from "@/types/screen-management";
|
|
|
|
import {
|
|
UniversalFormModalComponentProps,
|
|
UniversalFormModalConfig,
|
|
FormSectionConfig,
|
|
FormFieldConfig,
|
|
FormDataState,
|
|
RepeatSectionItem,
|
|
SelectOptionConfig,
|
|
} from "./types";
|
|
import { defaultConfig, generateUniqueId } from "./config";
|
|
|
|
/**
|
|
* 🔗 연쇄 드롭다운 Select 필드 컴포넌트
|
|
*/
|
|
interface CascadingSelectFieldProps {
|
|
fieldId: string;
|
|
config: CascadingDropdownConfig;
|
|
parentValue?: string | number | null;
|
|
value?: string;
|
|
onChange: (value: string) => void;
|
|
placeholder?: string;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
const CascadingSelectField: React.FC<CascadingSelectFieldProps> = ({
|
|
fieldId,
|
|
config,
|
|
parentValue,
|
|
value,
|
|
onChange,
|
|
placeholder,
|
|
disabled,
|
|
}) => {
|
|
const { options, loading } = useCascadingDropdown({
|
|
config,
|
|
parentValue,
|
|
});
|
|
|
|
const getPlaceholder = () => {
|
|
if (!parentValue) {
|
|
return config.emptyParentMessage || "상위 항목을 먼저 선택하세요";
|
|
}
|
|
if (loading) {
|
|
return config.loadingMessage || "로딩 중...";
|
|
}
|
|
if (options.length === 0) {
|
|
return config.noOptionsMessage || "선택 가능한 항목이 없습니다";
|
|
}
|
|
return placeholder || "선택하세요";
|
|
};
|
|
|
|
const isDisabled = disabled || !parentValue || loading;
|
|
|
|
return (
|
|
<Select value={value || ""} onValueChange={onChange} disabled={isDisabled}>
|
|
<SelectTrigger id={fieldId} className="w-full">
|
|
{loading ? (
|
|
<div className="flex items-center gap-2">
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
<span className="text-muted-foreground text-sm">로딩 중...</span>
|
|
</div>
|
|
) : (
|
|
<SelectValue placeholder={getPlaceholder()} />
|
|
)}
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{options.length === 0 ? (
|
|
<div className="text-muted-foreground px-2 py-4 text-center text-sm">
|
|
{!parentValue
|
|
? config.emptyParentMessage || "상위 항목을 먼저 선택하세요"
|
|
: config.noOptionsMessage || "선택 가능한 항목이 없습니다"}
|
|
</div>
|
|
) : (
|
|
options.map((option) => (
|
|
<SelectItem key={option.value} value={option.value}>
|
|
{option.label}
|
|
</SelectItem>
|
|
))
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* 범용 폼 모달 컴포넌트
|
|
*
|
|
* 섹션 기반 폼 레이아웃, 채번규칙, 다중 행 저장을 지원합니다.
|
|
*/
|
|
export function UniversalFormModalComponent({
|
|
component,
|
|
config: propConfig,
|
|
isDesignMode = false,
|
|
isSelected = false,
|
|
className,
|
|
style,
|
|
initialData: propInitialData,
|
|
// DynamicComponentRenderer에서 전달되는 props (DOM 전달 방지를 위해 _ prefix 사용)
|
|
_initialData,
|
|
_originalData,
|
|
_groupedData,
|
|
onSave,
|
|
onCancel,
|
|
onChange,
|
|
...restProps // 나머지 props는 DOM에 전달하지 않음
|
|
}: UniversalFormModalComponentProps & { _initialData?: any; _originalData?: any; _groupedData?: any }) {
|
|
// initialData 우선순위: 직접 전달된 prop > DynamicComponentRenderer에서 전달된 prop
|
|
const initialData = propInitialData || _initialData;
|
|
// 설정 병합
|
|
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: "" });
|
|
|
|
// 초기 데이터를 한 번만 캡처 (컴포넌트 마운트 시)
|
|
const capturedInitialData = useRef<Record<string, any> | undefined>(undefined);
|
|
const hasInitialized = useRef(false);
|
|
// 마지막으로 초기화된 데이터의 ID를 추적 (수정 모달에서 다른 항목 선택 시 재초기화 필요)
|
|
const lastInitializedId = useRef<string | undefined>(undefined);
|
|
|
|
// 초기화 - 최초 마운트 시 또는 initialData의 ID가 변경되었을 때 실행
|
|
useEffect(() => {
|
|
// initialData에서 ID 값 추출 (id, ID, objid 등)
|
|
const currentId = initialData?.id || initialData?.ID || initialData?.objid;
|
|
const currentIdString = currentId !== undefined ? String(currentId) : undefined;
|
|
|
|
// 이미 초기화되었고, ID가 동일하면 스킵
|
|
if (hasInitialized.current && lastInitializedId.current === currentIdString) {
|
|
return;
|
|
}
|
|
|
|
// 🆕 수정 모드: initialData에 데이터가 있으면서 ID가 변경된 경우 재초기화
|
|
if (hasInitialized.current && currentIdString && lastInitializedId.current !== currentIdString) {
|
|
console.log("[UniversalFormModal] ID 변경 감지 - 재초기화:", {
|
|
prevId: lastInitializedId.current,
|
|
newId: currentIdString,
|
|
initialData: initialData,
|
|
});
|
|
// 채번 플래그 초기화 (새 항목이므로)
|
|
numberingGeneratedRef.current = false;
|
|
isGeneratingRef.current = false;
|
|
}
|
|
|
|
// 최초 initialData 캡처 (이후 변경되어도 이 값 사용)
|
|
if (initialData && Object.keys(initialData).length > 0) {
|
|
capturedInitialData.current = JSON.parse(JSON.stringify(initialData)); // 깊은 복사
|
|
lastInitializedId.current = currentIdString;
|
|
console.log("[UniversalFormModal] 초기 데이터 캡처:", capturedInitialData.current);
|
|
}
|
|
|
|
hasInitialized.current = true;
|
|
initializeForm();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [initialData?.id, initialData?.ID, initialData?.objid]); // ID 값 변경 시 재초기화
|
|
|
|
// config 변경 시에만 재초기화 (initialData 변경은 무시) - 채번규칙 제외
|
|
useEffect(() => {
|
|
if (!hasInitialized.current) return; // 최초 초기화 전이면 스킵
|
|
|
|
console.log("[useEffect config 변경] 재초기화 스킵 (채번 중복 방지)");
|
|
// initializeForm(); // 주석 처리 - config 변경 시 재초기화 안 함 (채번 중복 방지)
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [config]);
|
|
|
|
// 컴포넌트 unmount 시 채번 플래그 초기화
|
|
useEffect(() => {
|
|
return () => {
|
|
console.log("[채번] 컴포넌트 unmount - 플래그 초기화");
|
|
numberingGeneratedRef.current = false;
|
|
isGeneratingRef.current = false;
|
|
};
|
|
}, []);
|
|
|
|
// 🆕 beforeFormSave 이벤트 리스너 - ButtonPrimary 저장 시 formData를 전달
|
|
// 설정된 필드(columnName)만 병합하여 의도치 않은 덮어쓰기 방지
|
|
useEffect(() => {
|
|
const handleBeforeFormSave = (event: Event) => {
|
|
if (!(event instanceof CustomEvent) || !event.detail?.formData) return;
|
|
|
|
// 설정에 정의된 필드 columnName 목록 수집
|
|
const configuredFields = new Set<string>();
|
|
config.sections.forEach((section) => {
|
|
section.fields.forEach((field) => {
|
|
if (field.columnName) {
|
|
configuredFields.add(field.columnName);
|
|
}
|
|
});
|
|
});
|
|
|
|
console.log("[UniversalFormModal] beforeFormSave 이벤트 수신");
|
|
console.log("[UniversalFormModal] 설정된 필드 목록:", Array.from(configuredFields));
|
|
|
|
// UniversalFormModal에 설정된 필드만 병합 (채번 규칙 포함)
|
|
// 외부 formData에 이미 값이 있어도 UniversalFormModal 값으로 덮어씀
|
|
// (UniversalFormModal이 해당 필드의 주인이므로)
|
|
for (const [key, value] of Object.entries(formData)) {
|
|
// 설정에 정의된 필드 또는 채번 규칙 ID 필드만 병합
|
|
const isConfiguredField = configuredFields.has(key);
|
|
const isNumberingRuleId = key.endsWith("_numberingRuleId");
|
|
|
|
if (isConfiguredField || isNumberingRuleId) {
|
|
if (value !== undefined && value !== null && value !== "") {
|
|
event.detail.formData[key] = value;
|
|
console.log(`[UniversalFormModal] 필드 병합: ${key} =`, value);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 반복 섹션 데이터도 병합 (필요한 경우)
|
|
if (Object.keys(repeatSections).length > 0) {
|
|
for (const [sectionId, items] of Object.entries(repeatSections)) {
|
|
const sectionKey = `_repeatSection_${sectionId}`;
|
|
event.detail.formData[sectionKey] = items;
|
|
console.log(`[UniversalFormModal] 반복 섹션 병합: ${sectionKey}`, items);
|
|
}
|
|
}
|
|
};
|
|
|
|
window.addEventListener("beforeFormSave", handleBeforeFormSave as EventListener);
|
|
|
|
return () => {
|
|
window.removeEventListener("beforeFormSave", handleBeforeFormSave as EventListener);
|
|
};
|
|
}, [formData, repeatSections, config.sections]);
|
|
|
|
// 필드 레벨 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]) {
|
|
await loadLinkedFieldData(tableName);
|
|
}
|
|
}
|
|
};
|
|
|
|
loadData();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [config.sections]);
|
|
|
|
// 폼 초기화
|
|
const initializeForm = useCallback(async () => {
|
|
console.log("[initializeForm] 시작");
|
|
|
|
// 캡처된 initialData 사용 (props로 전달된 initialData가 아닌)
|
|
const effectiveInitialData = capturedInitialData.current || initialData;
|
|
|
|
console.log("[initializeForm] 초기 데이터:", {
|
|
capturedInitialData: capturedInitialData.current,
|
|
initialData: initialData,
|
|
effectiveInitialData: effectiveInitialData,
|
|
hasData: effectiveInitialData && Object.keys(effectiveInitialData).length > 0,
|
|
});
|
|
|
|
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 ?? "";
|
|
|
|
// 부모에서 전달받은 값 적용 (receiveFromParent 또는 effectiveInitialData에 해당 값이 있으면)
|
|
if (effectiveInitialData) {
|
|
const parentField = field.parentFieldName || field.columnName;
|
|
if (effectiveInitialData[parentField] !== undefined) {
|
|
// receiveFromParent가 true이거나, effectiveInitialData에 값이 있으면 적용
|
|
if (field.receiveFromParent || value === "" || value === undefined) {
|
|
value = effectiveInitialData[parentField];
|
|
}
|
|
}
|
|
}
|
|
|
|
newFormData[field.columnName] = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
setFormData(newFormData);
|
|
setRepeatSections(newRepeatSections);
|
|
setCollapsedSections(newCollapsed);
|
|
setOriginalData(effectiveInitialData || {});
|
|
|
|
// 채번규칙 자동 생성
|
|
console.log("[initializeForm] generateNumberingValues 호출");
|
|
await generateNumberingValues(newFormData);
|
|
console.log("[initializeForm] 완료");
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [config]); // initialData는 의존성에서 제거 (capturedInitialData.current 사용)
|
|
|
|
// 반복 섹션 아이템 생성
|
|
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 numberingGeneratedRef = useRef(false);
|
|
const isGeneratingRef = useRef(false); // 진행 중 플래그 추가
|
|
|
|
const generateNumberingValues = useCallback(
|
|
async (currentFormData: FormDataState) => {
|
|
// 이미 생성되었거나 진행 중이면 스킵
|
|
if (numberingGeneratedRef.current) {
|
|
console.log("[채번] 이미 생성됨 - 스킵");
|
|
return;
|
|
}
|
|
|
|
if (isGeneratingRef.current) {
|
|
console.log("[채번] 생성 진행 중 - 스킵");
|
|
return;
|
|
}
|
|
|
|
isGeneratingRef.current = true; // 진행 중 표시
|
|
console.log("[채번] 생성 시작");
|
|
|
|
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 {
|
|
console.log(`[채번 미리보기 API 호출] ${field.columnName}, ruleId: ${field.numberingRule.ruleId}`);
|
|
// generateOnOpen: 미리보기만 표시 (DB 시퀀스 증가 안 함)
|
|
const response = await previewNumberingCode(field.numberingRule.ruleId);
|
|
if (response.success && response.data?.generatedCode) {
|
|
updatedData[field.columnName] = response.data.generatedCode;
|
|
|
|
// 저장 시 실제 할당을 위해 ruleId 저장 (TextInput과 동일한 키 형식)
|
|
const ruleIdKey = `${field.columnName}_numberingRuleId`;
|
|
updatedData[ruleIdKey] = field.numberingRule.ruleId;
|
|
|
|
hasChanges = true;
|
|
numberingGeneratedRef.current = true; // 생성 완료 표시
|
|
console.log(
|
|
`[채번 미리보기 완료] ${field.columnName} = ${response.data.generatedCode} (저장 시 실제 할당)`,
|
|
);
|
|
console.log(`[채번 규칙 ID 저장] ${ruleIdKey} = ${field.numberingRule.ruleId}`);
|
|
|
|
// 부모 컴포넌트에도 ruleId 전달 (ModalRepeaterTable → ScreenModal)
|
|
if (onChange) {
|
|
onChange({
|
|
...updatedData,
|
|
[ruleIdKey]: field.numberingRule.ruleId,
|
|
});
|
|
console.log(`[채번] 부모에게 ruleId 전달: ${ruleIdKey}`);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error(`채번규칙 미리보기 실패 (${field.columnName}):`, error);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
isGeneratingRef.current = false; // 진행 완료
|
|
|
|
if (hasChanges) {
|
|
setFormData(updatedData);
|
|
}
|
|
},
|
|
[config, onChange],
|
|
);
|
|
|
|
// 필드 값 변경 핸들러
|
|
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) {
|
|
// POST 방식으로 테이블 데이터 조회 (autoFilter 포함)
|
|
const response = await apiClient.post(`/table-management/tables/${optionConfig.tableName}/data`, {
|
|
page: 1,
|
|
size: 1000,
|
|
autoFilter: { enabled: true, filterColumn: "company_code" },
|
|
});
|
|
|
|
// 응답 데이터 파싱
|
|
let dataArray: any[] = [];
|
|
if (response.data?.success) {
|
|
const responseData = response.data?.data;
|
|
if (responseData?.data && Array.isArray(responseData.data)) {
|
|
dataArray = responseData.data;
|
|
} else if (Array.isArray(responseData)) {
|
|
dataArray = responseData;
|
|
} else if (responseData?.rows && Array.isArray(responseData.rows)) {
|
|
dataArray = responseData.rows;
|
|
}
|
|
}
|
|
|
|
options = dataArray.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 saveSingleRow = useCallback(async () => {
|
|
const dataToSave = { ...formData };
|
|
|
|
// 메타데이터 필드 제거 (채번 규칙 ID는 유지 - buttonActions.ts에서 사용)
|
|
Object.keys(dataToSave).forEach((key) => {
|
|
if (key.startsWith("_") && !key.includes("_numberingRuleId")) {
|
|
delete dataToSave[key];
|
|
}
|
|
});
|
|
|
|
// 저장 시점 채번규칙 처리 (generateOnSave만 처리)
|
|
for (const section of config.sections) {
|
|
for (const field of section.fields) {
|
|
if (field.numberingRule?.enabled && field.numberingRule?.generateOnSave && field.numberingRule?.ruleId) {
|
|
const response = await allocateNumberingCode(field.numberingRule.ruleId);
|
|
if (response.success && response.data?.generatedCode) {
|
|
dataToSave[field.columnName] = response.data.generatedCode;
|
|
console.log(`[채번 할당] ${field.columnName} = ${response.data.generatedCode}`);
|
|
} else {
|
|
console.error(`[채번 실패] ${field.columnName}:`, response.error);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const response = await apiClient.post(`/table-management/tables/${config.saveConfig.tableName}/add`, dataToSave);
|
|
|
|
if (!response.data?.success) {
|
|
throw new Error(response.data?.message || "저장 실패");
|
|
}
|
|
}, [config.sections, config.saveConfig.tableName, formData]);
|
|
|
|
// 다중 행 저장 (겸직 등)
|
|
const saveMultipleRows = useCallback(async () => {
|
|
const { multiRowSave } = config.saveConfig;
|
|
if (!multiRowSave) return;
|
|
|
|
let { commonFields = [], repeatSectionId = "" } = multiRowSave;
|
|
const { 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));
|
|
}
|
|
|
|
// 반복 섹션 ID가 설정되지 않은 경우, 첫 번째 반복 섹션 사용
|
|
if (!repeatSectionId) {
|
|
const repeatableSection = config.sections.find((s) => s.repeatable);
|
|
if (repeatableSection) {
|
|
repeatSectionId = repeatableSection.id;
|
|
}
|
|
}
|
|
|
|
// 반복 섹션 데이터
|
|
const repeatItems = repeatSections[repeatSectionId] || [];
|
|
|
|
// 저장할 행들 생성
|
|
const rowsToSave: any[] = [];
|
|
|
|
// 공통 데이터 (모든 행에 적용)
|
|
const commonData: any = {};
|
|
commonFields.forEach((fieldName) => {
|
|
if (formData[fieldName] !== undefined) {
|
|
commonData[fieldName] = formData[fieldName];
|
|
}
|
|
});
|
|
|
|
// 메인 섹션 필드 데이터 (메인 행에만 적용되는 부서/직급 등)
|
|
const mainSectionData: any = {};
|
|
mainSectionFields.forEach((fieldName) => {
|
|
if (formData[fieldName] !== undefined) {
|
|
mainSectionData[fieldName] = formData[fieldName];
|
|
}
|
|
});
|
|
|
|
// 메인 행 (공통 데이터 + 메인 섹션 필드)
|
|
const mainRow: any = { ...commonData, ...mainSectionData };
|
|
if (typeColumn) {
|
|
mainRow[typeColumn] = mainTypeValue || "main";
|
|
}
|
|
rowsToSave.push(mainRow);
|
|
|
|
// 반복 섹션 행들 (공통 데이터 + 반복 섹션 필드)
|
|
for (const item of repeatItems) {
|
|
const subRow: any = { ...commonData };
|
|
|
|
// 반복 섹션의 필드 값 추가
|
|
const repeatSection = config.sections.find((s) => s.id === repeatSectionId);
|
|
repeatSection?.fields.forEach((field) => {
|
|
if (item[field.columnName] !== undefined) {
|
|
subRow[field.columnName] = item[field.columnName];
|
|
}
|
|
});
|
|
|
|
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?.ruleId) {
|
|
// generateOnSave 또는 generateOnOpen 모두 저장 시 실제 순번 할당
|
|
const shouldAllocate = field.numberingRule.generateOnSave || field.numberingRule.generateOnOpen;
|
|
if (shouldAllocate) {
|
|
const response = await allocateNumberingCode(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 (let i = 0; i < rowsToSave.length; i++) {
|
|
const row = rowsToSave[i];
|
|
|
|
// 빈 객체 체크
|
|
if (Object.keys(row).length === 0) {
|
|
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}번째 행 저장 실패`);
|
|
}
|
|
}
|
|
}, [config.sections, config.saveConfig, formData, repeatSections]);
|
|
|
|
// 다중 테이블 저장 (범용)
|
|
const saveWithMultiTable = useCallback(async () => {
|
|
const { customApiSave } = config.saveConfig;
|
|
if (!customApiSave?.multiTable) return;
|
|
|
|
const { multiTable } = customApiSave;
|
|
|
|
// 1. 메인 테이블 데이터 구성
|
|
const mainData: Record<string, any> = {};
|
|
config.sections.forEach((section) => {
|
|
if (section.repeatable) return; // 반복 섹션은 제외
|
|
section.fields.forEach((field) => {
|
|
const value = formData[field.columnName];
|
|
if (value !== undefined && value !== null && value !== "") {
|
|
mainData[field.columnName] = value;
|
|
}
|
|
});
|
|
});
|
|
|
|
// 1-1. 채번규칙 처리 (저장 시점에 실제 순번 할당)
|
|
for (const section of config.sections) {
|
|
if (section.repeatable) continue;
|
|
|
|
for (const field of section.fields) {
|
|
// 채번규칙이 활성화된 필드 처리
|
|
if (field.numberingRule?.enabled && field.numberingRule?.ruleId) {
|
|
// 신규 생성이거나 값이 없는 경우에만 채번
|
|
const isNewRecord = !initialData?.[multiTable.mainTable.primaryKeyColumn];
|
|
const hasNoValue = !mainData[field.columnName];
|
|
|
|
if (isNewRecord || hasNoValue) {
|
|
try {
|
|
// allocateNumberingCode로 실제 순번 증가
|
|
const response = await allocateNumberingCode(field.numberingRule.ruleId);
|
|
if (response.success && response.data?.generatedCode) {
|
|
mainData[field.columnName] = response.data.generatedCode;
|
|
}
|
|
} catch (error) {
|
|
console.error(`채번규칙 할당 실패 (${field.columnName}):`, error);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. 서브 테이블 데이터 구성
|
|
const subTablesData: Array<{
|
|
tableName: string;
|
|
linkColumn: { mainField: string; subColumn: string };
|
|
items: Record<string, any>[];
|
|
options?: {
|
|
saveMainAsFirst?: boolean;
|
|
mainFieldMappings?: Array<{ formField: string; targetColumn: string }>;
|
|
mainMarkerColumn?: string;
|
|
mainMarkerValue?: any;
|
|
subMarkerValue?: any;
|
|
deleteExistingBefore?: boolean;
|
|
};
|
|
}> = [];
|
|
|
|
for (const subTableConfig of multiTable.subTables || []) {
|
|
if (!subTableConfig.enabled || !subTableConfig.tableName || !subTableConfig.repeatSectionId) {
|
|
continue;
|
|
}
|
|
|
|
const subItems: Record<string, any>[] = [];
|
|
const repeatData = repeatSections[subTableConfig.repeatSectionId] || [];
|
|
|
|
// 반복 섹션 데이터를 필드 매핑에 따라 변환
|
|
for (const item of repeatData) {
|
|
const mappedItem: Record<string, any> = {};
|
|
|
|
// 연결 컬럼 값 설정
|
|
if (subTableConfig.linkColumn?.mainField && subTableConfig.linkColumn?.subColumn) {
|
|
mappedItem[subTableConfig.linkColumn.subColumn] = mainData[subTableConfig.linkColumn.mainField];
|
|
}
|
|
|
|
// 필드 매핑에 따라 데이터 변환
|
|
for (const mapping of subTableConfig.fieldMappings || []) {
|
|
if (mapping.formField && mapping.targetColumn) {
|
|
mappedItem[mapping.targetColumn] = item[mapping.formField];
|
|
}
|
|
}
|
|
|
|
// 메인/서브 구분 컬럼 설정 (서브 데이터는 서브 마커 값)
|
|
if (subTableConfig.options?.mainMarkerColumn) {
|
|
mappedItem[subTableConfig.options.mainMarkerColumn] = subTableConfig.options?.subMarkerValue ?? false;
|
|
}
|
|
|
|
if (Object.keys(mappedItem).length > 0) {
|
|
subItems.push(mappedItem);
|
|
}
|
|
}
|
|
|
|
// saveMainAsFirst가 활성화된 경우, 메인 데이터를 서브 테이블에 저장하기 위한 매핑 생성
|
|
let mainFieldMappings: Array<{ formField: string; targetColumn: string }> | undefined;
|
|
if (subTableConfig.options?.saveMainAsFirst) {
|
|
mainFieldMappings = [];
|
|
|
|
// 메인 섹션(비반복)의 필드들을 서브 테이블에 매핑
|
|
// 서브 테이블의 fieldMappings에서 targetColumn을 찾아서 매핑
|
|
for (const mapping of subTableConfig.fieldMappings || []) {
|
|
if (mapping.targetColumn) {
|
|
// 메인 데이터에서 동일한 컬럼명이 있으면 매핑
|
|
if (mainData[mapping.targetColumn] !== undefined) {
|
|
mainFieldMappings.push({
|
|
formField: mapping.targetColumn,
|
|
targetColumn: mapping.targetColumn,
|
|
});
|
|
}
|
|
// 또는 메인 섹션의 필드 중 같은 이름이 있으면 매핑
|
|
else {
|
|
config.sections.forEach((section) => {
|
|
if (section.repeatable) return;
|
|
const matchingField = section.fields.find((f) => f.columnName === mapping.targetColumn);
|
|
if (matchingField && mainData[matchingField.columnName] !== undefined) {
|
|
mainFieldMappings!.push({
|
|
formField: matchingField.columnName,
|
|
targetColumn: mapping.targetColumn,
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// 중복 제거
|
|
mainFieldMappings = mainFieldMappings.filter(
|
|
(m, idx, arr) => arr.findIndex((x) => x.targetColumn === m.targetColumn) === idx,
|
|
);
|
|
}
|
|
|
|
subTablesData.push({
|
|
tableName: subTableConfig.tableName,
|
|
linkColumn: subTableConfig.linkColumn,
|
|
items: subItems,
|
|
options: {
|
|
...subTableConfig.options,
|
|
mainFieldMappings, // 메인 데이터 매핑 추가
|
|
},
|
|
});
|
|
}
|
|
|
|
// 3. 범용 다중 테이블 저장 API 호출
|
|
const response = await apiClient.post("/table-management/multi-table-save", {
|
|
mainTable: multiTable.mainTable,
|
|
mainData,
|
|
subTables: subTablesData,
|
|
isUpdate: !!initialData?.[multiTable.mainTable.primaryKeyColumn],
|
|
});
|
|
|
|
if (!response.data?.success) {
|
|
throw new Error(response.data?.message || "다중 테이블 저장 실패");
|
|
}
|
|
}, [config.sections, config.saveConfig, formData, repeatSections, initialData]);
|
|
|
|
// 커스텀 API 저장
|
|
const saveWithCustomApi = useCallback(async () => {
|
|
const { customApiSave } = config.saveConfig;
|
|
if (!customApiSave) return;
|
|
|
|
const saveWithGenericCustomApi = async () => {
|
|
if (!customApiSave.customEndpoint) {
|
|
throw new Error("커스텀 API 엔드포인트가 설정되지 않았습니다.");
|
|
}
|
|
|
|
const dataToSave = { ...formData };
|
|
|
|
// 메타데이터 필드 제거
|
|
Object.keys(dataToSave).forEach((key) => {
|
|
if (key.startsWith("_")) {
|
|
delete dataToSave[key];
|
|
}
|
|
});
|
|
|
|
// 반복 섹션 데이터 포함
|
|
if (Object.keys(repeatSections).length > 0) {
|
|
dataToSave._repeatSections = repeatSections;
|
|
}
|
|
|
|
const method = customApiSave.customMethod || "POST";
|
|
const response =
|
|
method === "PUT"
|
|
? await apiClient.put(customApiSave.customEndpoint, dataToSave)
|
|
: await apiClient.post(customApiSave.customEndpoint, dataToSave);
|
|
|
|
if (!response.data?.success) {
|
|
throw new Error(response.data?.message || "저장 실패");
|
|
}
|
|
};
|
|
|
|
switch (customApiSave.apiType) {
|
|
case "multi-table":
|
|
await saveWithMultiTable();
|
|
break;
|
|
case "custom":
|
|
await saveWithGenericCustomApi();
|
|
break;
|
|
default:
|
|
throw new Error(`지원하지 않는 API 타입: ${customApiSave.apiType}`);
|
|
}
|
|
}, [config.saveConfig, formData, repeatSections, saveWithMultiTable]);
|
|
|
|
// 저장 처리
|
|
const handleSave = useCallback(async () => {
|
|
// 커스텀 API 저장 모드가 아닌 경우에만 테이블명 체크
|
|
if (!config.saveConfig.customApiSave?.enabled && !config.saveConfig.tableName) {
|
|
toast.error("저장할 테이블이 설정되지 않았습니다.");
|
|
return;
|
|
}
|
|
|
|
// 필수 필드 검증
|
|
const { valid, missingFields } = validateRequiredFields();
|
|
if (!valid) {
|
|
toast.error(`필수 항목을 입력해주세요: ${missingFields.join(", ")}`);
|
|
return;
|
|
}
|
|
|
|
setSaving(true);
|
|
|
|
try {
|
|
const { multiRowSave, customApiSave } = config.saveConfig;
|
|
|
|
// 커스텀 API 저장 모드
|
|
if (customApiSave?.enabled) {
|
|
await saveWithCustomApi();
|
|
} else 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,
|
|
saveSingleRow,
|
|
saveMultipleRows,
|
|
saveWithCustomApi,
|
|
]);
|
|
|
|
// 폼 초기화
|
|
const handleReset = useCallback(() => {
|
|
initializeForm();
|
|
toast.info("폼이 초기화되었습니다.");
|
|
}, [initializeForm]);
|
|
|
|
// 필드 요소 렌더링 (입력 컴포넌트만)
|
|
// repeatContext: 반복 섹션인 경우 { sectionId, itemId }를 전달
|
|
const renderFieldElement = (
|
|
field: FormFieldConfig,
|
|
value: any,
|
|
onChangeHandler: (value: any) => void,
|
|
fieldKey: string,
|
|
isDisabled: boolean,
|
|
repeatContext?: { sectionId: string; itemId: string },
|
|
) => {
|
|
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": {
|
|
// 🆕 연쇄 드롭다운 처리
|
|
if (field.cascading?.enabled) {
|
|
const cascadingConfig = field.cascading;
|
|
const parentValue = formData[cascadingConfig.parentField];
|
|
|
|
return (
|
|
<CascadingSelectField
|
|
fieldId={fieldKey}
|
|
config={cascadingConfig as CascadingDropdownConfig}
|
|
parentValue={parentValue}
|
|
value={value}
|
|
onChange={onChangeHandler}
|
|
placeholder={field.placeholder || "선택하세요"}
|
|
disabled={isDisabled}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// 다중 컬럼 저장이 활성화된 경우
|
|
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];
|
|
|
|
// 반복 섹션인 경우 repeatSections에 저장, 아니면 formData에 저장
|
|
if (repeatContext) {
|
|
setRepeatSections((prev) => {
|
|
const items = prev[repeatContext.sectionId] || [];
|
|
const newItems = items.map((item) =>
|
|
item._id === repeatContext.itemId
|
|
? { ...item, [mapping.targetColumn]: mappedValue }
|
|
: item,
|
|
);
|
|
return { ...prev, [repeatContext.sectionId]: newItems };
|
|
});
|
|
} else {
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
[mapping.targetColumn]: mappedValue,
|
|
}));
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}}
|
|
disabled={isDisabled}
|
|
>
|
|
<SelectTrigger id={fieldKey} className="w-full" size="default">
|
|
<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);
|
|
};
|
|
|
|
// 필드 렌더링 (섹션 열 수 적용)
|
|
// repeatContext: 반복 섹션인 경우 { sectionId, itemId }를 전달
|
|
const renderFieldWithColumns = (
|
|
field: FormFieldConfig,
|
|
value: any,
|
|
onChangeHandler: (value: any) => void,
|
|
fieldKey: string,
|
|
sectionColumns: number = 2,
|
|
repeatContext?: { sectionId: string; itemId: string },
|
|
) => {
|
|
// 섹션 열 수에 따른 기본 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, repeatContext);
|
|
|
|
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,
|
|
{ sectionId: section.id, itemId: item._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>
|
|
|
|
{/* 버튼 영역 - 저장 버튼이 표시될 때만 렌더링 */}
|
|
{config.modal.showSaveButton !== false && (
|
|
<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"
|
|
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 size="default">
|
|
<SelectValue placeholder={loading ? "로딩 중..." : placeholder} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{options.map((option) => (
|
|
<SelectItem key={option.value} value={option.value}>
|
|
{option.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
);
|
|
}
|
|
|
|
export default UniversalFormModalComponent;
|