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

2239 lines
88 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, ChevronRight, Plus, Trash2, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { 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,
OptionalFieldGroupConfig,
} from "./types";
import { defaultConfig, generateUniqueId } from "./config";
import { TableSectionRenderer } from "./TableSectionRenderer";
/**
* 🔗 연쇄 드롭다운 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
.filter((option) => option.value && option.value !== "")
.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,
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());
// 옵셔널 필드 그룹 활성화 상태 (섹션ID-그룹ID 조합)
const [activatedOptionalFieldGroups, setActivatedOptionalFieldGroups] = 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>[];
}>({});
// 채번규칙 원본 값 추적 (수동 모드 감지용)
// key: columnName, value: 자동 생성된 원본 값
const [numberingOriginalValues, setNumberingOriginalValues] = useState<Record<string, string>>({});
// 🆕 수정 모드: 원본 그룹 데이터 (INSERT/UPDATE/DELETE 추적용)
const [originalGroupedData, setOriginalGroupedData] = useState<any[]>([]);
const groupedDataInitializedRef = useRef(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가 변경되었을 때 실행
useEffect(() => {
// console.log("[UniversalFormModal] useEffect 시작", {
// initialData,
// hasInitialized: hasInitialized.current,
// lastInitializedId: lastInitializedId.current,
// });
// initialData에서 ID 값 추출 (id, ID, objid 등)
const currentId = initialData?.id || initialData?.ID || initialData?.objid;
const currentIdString = currentId !== undefined ? String(currentId) : undefined;
// 생성 모드에서 부모로부터 전달받은 데이터 해시 (ID가 없을 때만)
const createModeDataHash =
!currentIdString && initialData && Object.keys(initialData).length > 0 ? JSON.stringify(initialData) : undefined;
// 이미 초기화되었고, ID가 동일하고, 생성 모드 데이터도 동일하면 스킵
if (hasInitialized.current && lastInitializedId.current === currentIdString) {
// 생성 모드에서 데이터가 새로 전달된 경우는 재초기화 필요
if (!createModeDataHash || capturedInitialData.current) {
// console.log("[UniversalFormModal] 초기화 스킵 - 이미 초기화됨");
// 🆕 채번 플래그가 true인데 formData에 값이 없으면 재생성 필요
// (컴포넌트 remount로 인해 state가 초기화된 경우)
return;
}
}
// 🆕 컴포넌트 remount 감지: hasInitialized가 true인데 formData가 비어있으면 재초기화
// (React의 Strict Mode나 EmbeddedScreen 리렌더링으로 인한 remount)
if (hasInitialized.current && !currentIdString) {
// console.log("[UniversalFormModal] 컴포넌트 remount 감지 - 채번 플래그 초기화");
numberingGeneratedRef.current = false;
isGeneratingRef.current = false;
}
// 🆕 수정 모드: 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);
}
// console.log("[UniversalFormModal] initializeForm 호출 예정");
hasInitialized.current = true;
initializeForm();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialData]); // initialData 전체 변경 시 재초기화
// 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));
// 🆕 시스템 필드 병합: id는 설정 여부와 관계없이 항상 전달 (UPDATE/INSERT 판단용)
// - 신규 등록: formData.id가 없으므로 영향 없음
// - 편집 모드: formData.id가 있으면 메인 테이블 UPDATE에 사용
if (formData.id !== undefined && formData.id !== null && formData.id !== "") {
event.detail.formData.id = formData.id;
console.log(`[UniversalFormModal] 시스템 필드 병합: id =`, formData.id);
}
// 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);
}
}
// 🆕 테이블 섹션 데이터 병합 (품목 리스트 등)
for (const [key, value] of Object.entries(formData)) {
if (key.startsWith("_tableSection_") && Array.isArray(value)) {
event.detail.formData[key] = value;
console.log(`[UniversalFormModal] 테이블 섹션 병합: ${key}, ${value.length}개 항목`);
}
}
// 🆕 수정 모드: 원본 그룹 데이터 전달 (UPDATE/DELETE 추적용)
if (originalGroupedData.length > 0) {
event.detail.formData._originalGroupedData = originalGroupedData;
console.log(`[UniversalFormModal] 원본 그룹 데이터 병합: ${originalGroupedData.length}`);
}
};
window.addEventListener("beforeFormSave", handleBeforeFormSave as EventListener);
return () => {
window.removeEventListener("beforeFormSave", handleBeforeFormSave as EventListener);
};
}, [formData, repeatSections, config.sections, originalGroupedData]);
// 🆕 수정 모드: _groupedData가 있으면 테이블 섹션 초기화
useEffect(() => {
if (!_groupedData || _groupedData.length === 0) return;
if (groupedDataInitializedRef.current) return; // 이미 초기화됨
// 테이블 타입 섹션 찾기
const tableSection = config.sections.find((s) => s.type === "table");
if (!tableSection) {
return;
}
// 원본 데이터 저장 (수정/삭제 추적용)
setOriginalGroupedData(JSON.parse(JSON.stringify(_groupedData)));
// 테이블 섹션 데이터 설정
const tableSectionKey = `_tableSection_${tableSection.id}`;
setFormData((prev) => ({
...prev,
[tableSectionKey]: _groupedData,
}));
groupedDataInitializedRef.current = true;
}, [_groupedData, 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]);
// 채번규칙 자동 생성 (중복 호출 방지)
// 중요: initializeForm에서 호출되므로 반드시 initializeForm보다 먼저 선언해야 함
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("[채번] 생성 시작", { sectionsCount: config.sections.length });
const updatedData = { ...currentFormData };
let hasChanges = false;
for (const section of config.sections) {
// console.log("[채번] 섹션 검사:", section.title, { type: section.type, repeatable: section.repeatable, fieldsCount: section.fields?.length });
if (section.repeatable || section.type === "table") continue;
for (const field of section.fields || []) {
// generateOnOpen은 기본값 true (undefined일 경우 true로 처리)
const shouldGenerateOnOpen = field.numberingRule?.generateOnOpen !== false;
// console.log("[채번] 필드 검사:", field.columnName, {
// hasNumberingRule: !!field.numberingRule,
// enabled: field.numberingRule?.enabled,
// generateOnOpen: field.numberingRule?.generateOnOpen,
// shouldGenerateOnOpen,
// ruleId: field.numberingRule?.ruleId,
// currentValue: updatedData[field.columnName],
// });
if (
field.numberingRule?.enabled &&
shouldGenerateOnOpen &&
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) {
const generatedCode = response.data.generatedCode;
updatedData[field.columnName] = generatedCode;
// 저장 시 실제 할당을 위해 ruleId 저장 (TextInput과 동일한 키 형식)
const ruleIdKey = `${field.columnName}_numberingRuleId`;
updatedData[ruleIdKey] = field.numberingRule.ruleId;
// 원본 채번 값 저장 (수동 모드 감지용)
setNumberingOriginalValues((prev) => ({
...prev,
[field.columnName]: generatedCode,
}));
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 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>();
const newActivatedGroups = 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 if (section.type === "table") {
// 테이블 섹션은 필드 초기화 스킵 (TableSectionRenderer에서 처리)
continue;
} 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;
}
// 옵셔널 필드 그룹 처리
if (section.optionalFieldGroups) {
for (const group of section.optionalFieldGroups) {
const key = `${section.id}-${group.id}`;
// 수정 모드: triggerField 값이 triggerValueOnAdd와 일치하면 그룹 자동 활성화
if (effectiveInitialData && group.triggerField && group.triggerValueOnAdd !== undefined) {
const triggerValue = effectiveInitialData[group.triggerField];
if (triggerValue === group.triggerValueOnAdd) {
newActivatedGroups.add(key);
console.log(
`[initializeForm] 옵셔널 그룹 자동 활성화: ${key}, triggerField=${group.triggerField}, value=${triggerValue}`,
);
// 활성화된 그룹의 필드값도 초기화
for (const field of group.fields || []) {
let value = field.defaultValue ?? "";
const parentField = field.parentFieldName || field.columnName;
if (effectiveInitialData[parentField] !== undefined) {
value = effectiveInitialData[parentField];
}
newFormData[field.columnName] = value;
}
}
}
// 신규 등록 모드: triggerValueOnRemove를 기본값으로 설정
if (group.triggerField && group.triggerValueOnRemove !== undefined) {
// effectiveInitialData에 해당 값이 없는 경우에만 기본값 설정
if (!effectiveInitialData || effectiveInitialData[group.triggerField] === undefined) {
newFormData[group.triggerField] = group.triggerValueOnRemove;
}
}
}
}
}
}
// 🆕 테이블 섹션(type: "table") 디테일 데이터 로드 (마스터-디테일 구조)
// 수정 모드일 때 디테일 테이블에서 데이터 가져오기
if (effectiveInitialData) {
console.log("[initializeForm] 테이블 섹션 디테일 로드 시작", {
sectionsCount: config.sections.length,
effectiveInitialDataKeys: Object.keys(effectiveInitialData),
});
for (const section of config.sections) {
if (section.type !== "table" || !section.tableConfig) {
continue;
}
const tableConfig = section.tableConfig;
// editConfig는 타입에 정의되지 않았지만 런타임에 존재할 수 있음
const editConfig = (tableConfig as any).editConfig;
const saveConfig = tableConfig.saveConfig;
console.log(`[initializeForm] 테이블 섹션 ${section.id} 검사:`, {
hasEditConfig: !!editConfig,
loadOnEdit: editConfig?.loadOnEdit,
hasSaveConfig: !!saveConfig,
targetTable: saveConfig?.targetTable,
linkColumn: editConfig?.linkColumn,
});
// 수정 모드 로드 설정 확인 (기본값: true)
if (editConfig?.loadOnEdit === false) {
console.log(`[initializeForm] 테이블 섹션 ${section.id}: loadOnEdit=false, 스킵`);
continue;
}
// 디테일 테이블과 연결 정보 확인
const detailTable = saveConfig?.targetTable;
let linkColumn = editConfig?.linkColumn;
if (!detailTable) {
console.log(`[initializeForm] 테이블 섹션 ${section.id}: saveConfig.targetTable 미설정, 스킵`);
continue;
}
// linkColumn이 설정되지 않았으면, 디테일 테이블 컬럼 정보 조회하여 자동 감지
if (!linkColumn?.masterField || !linkColumn?.detailField) {
try {
// 마스터 테이블명 확인 (saveConfig에서)
// 1. customApiSave.multiTable.mainTable.tableName (다중 테이블 저장)
// 2. saveConfig.tableName (단일 테이블 저장)
const masterTable =
config.saveConfig?.customApiSave?.multiTable?.mainTable?.tableName || config.saveConfig?.tableName;
// 디테일 테이블의 컬럼 목록 조회
const columnsResponse = await apiClient.get(`/table-management/tables/${detailTable}/columns`);
if (columnsResponse.data?.success && columnsResponse.data?.data) {
// API 응답 구조: { success, data: { columns: [...], total, page, ... } }
const columnsArray = columnsResponse.data.data.columns || columnsResponse.data.data || [];
const detailColumnsData = Array.isArray(columnsArray) ? columnsArray : [];
const detailColumns = detailColumnsData.map((col: any) => col.column_name || col.columnName);
const masterKeys = Object.keys(effectiveInitialData);
console.log(`[initializeForm] 테이블 섹션 ${section.id}: 연결 필드 자동 감지`, {
masterTable,
detailTable,
detailColumnsCount: detailColumnsData.length,
});
// 방법 1: 엔티티 관계 기반 감지 (정확)
// 디테일 테이블에서 마스터 테이블을 참조하는 엔티티 컬럼 찾기
if (masterTable) {
for (const col of detailColumnsData) {
const colName = col.column_name || col.columnName;
const inputType = col.input_type || col.inputType;
// 엔티티 타입 컬럼 확인
if (inputType === "entity") {
// reference_table 또는 detail_settings에서 참조 테이블 확인
let refTable = col.reference_table || col.referenceTable;
// detail_settings에서 referenceTable 확인
if (!refTable && col.detail_settings) {
try {
const settings =
typeof col.detail_settings === "string"
? JSON.parse(col.detail_settings)
: col.detail_settings;
refTable = settings.referenceTable;
} catch {
// JSON 파싱 실패 무시
}
}
// 마스터 테이블을 참조하는 컬럼 발견
if (refTable === masterTable) {
// 참조 컬럼 확인 (마스터 테이블의 어떤 컬럼을 참조하는지)
let refColumn = col.reference_column || col.referenceColumn;
if (!refColumn && col.detail_settings) {
try {
const settings =
typeof col.detail_settings === "string"
? JSON.parse(col.detail_settings)
: col.detail_settings;
refColumn = settings.referenceColumn;
} catch {
// JSON 파싱 실패 무시
}
}
// 마스터 데이터에 해당 컬럼 값이 있는지 확인
if (refColumn && effectiveInitialData[refColumn]) {
console.log(
`[initializeForm] 테이블 섹션 ${section.id}: 엔티티 관계 감지 - ${colName}${masterTable}.${refColumn}`,
);
linkColumn = { masterField: refColumn, detailField: colName };
break;
}
}
}
}
}
// 방법 2: 공통 컬럼 패턴 기반 감지 (폴백)
// 엔티티 관계가 없으면 공통 컬럼명 패턴으로 찾기
if (!linkColumn) {
const priorityPatterns = ["_no", "_number", "_code", "_id"];
for (const pattern of priorityPatterns) {
for (const masterKey of masterKeys) {
if (
masterKey.endsWith(pattern) &&
detailColumns.includes(masterKey) &&
effectiveInitialData[masterKey] &&
masterKey !== "id" &&
masterKey !== "company_code"
) {
console.log(`[initializeForm] 테이블 섹션 ${section.id}: 공통 컬럼 패턴 감지 - ${masterKey}`);
linkColumn = { masterField: masterKey, detailField: masterKey };
break;
}
}
if (linkColumn) break;
}
}
// 방법 3: 일반 공통 컬럼 (마지막 폴백)
if (!linkColumn) {
for (const masterKey of masterKeys) {
if (
detailColumns.includes(masterKey) &&
effectiveInitialData[masterKey] &&
masterKey !== "id" &&
masterKey !== "company_code" &&
!masterKey.startsWith("__")
) {
console.log(`[initializeForm] 테이블 섹션 ${section.id}: 공통 컬럼 감지 - ${masterKey}`);
linkColumn = { masterField: masterKey, detailField: masterKey };
break;
}
}
}
}
} catch (error) {
console.warn(`[initializeForm] 테이블 섹션 ${section.id}: 컬럼 정보 조회 실패`, error);
}
}
if (!linkColumn?.masterField || !linkColumn?.detailField) {
console.log(`[initializeForm] 테이블 섹션 ${section.id}: linkColumn 미설정 및 자동 감지 실패, 스킵`);
continue;
}
// 마스터 테이블의 연결 필드 값 가져오기
const masterValue = effectiveInitialData[linkColumn.masterField];
if (!masterValue) {
console.log(
`[initializeForm] 테이블 섹션 ${section.id}: masterField(${linkColumn.masterField}) 값 없음, 스킵`,
);
continue;
}
try {
console.log(`[initializeForm] 테이블 섹션 ${section.id}: 디테일 데이터 로드 시작`, {
detailTable,
linkColumn,
masterValue,
});
// 디테일 테이블에서 데이터 조회
// operator: "equals"를 사용하여 정확히 일치하는 값만 검색 (엔티티 타입 컬럼에서 중요)
const searchCondition: Record<string, any> = {
[linkColumn.detailField]: { value: masterValue, operator: "equals" },
};
console.log(
`[initializeForm] 테이블 섹션 ${section.id}: API 요청 - URL: /table-management/tables/${detailTable}/data`,
);
console.log(
`[initializeForm] 테이블 섹션 ${section.id}: API 요청 - search:`,
JSON.stringify(searchCondition),
);
const response = await apiClient.post(`/table-management/tables/${detailTable}/data`, {
search: searchCondition, // filters가 아닌 search로 전달
page: 1,
size: 1000, // pageSize가 아닌 size로 전달
autoFilter: { enabled: true }, // 멀티테넌시 필터 적용
});
console.log(
`[initializeForm] 테이블 섹션 ${section.id}: API 응답 - success: ${response.data?.success}, total: ${response.data?.data?.total}, dataLength: ${response.data?.data?.data?.length}`,
);
if (response.data?.success) {
// 다양한 응답 구조 처리
let items: any[] = [];
const data = response.data.data;
if (Array.isArray(data)) {
items = data;
} else if (data?.items && Array.isArray(data.items)) {
items = data.items;
} else if (data?.rows && Array.isArray(data.rows)) {
items = data.rows;
} else if (data?.data && Array.isArray(data.data)) {
items = data.data;
}
console.log(`[initializeForm] 테이블 섹션 ${section.id}: ${items.length}건 로드됨`, items);
// 테이블 섹션 데이터를 formData에 저장 (TableSectionRenderer에서 사용)
const tableSectionKey = `__tableSection_${section.id}`;
newFormData[tableSectionKey] = items;
console.log(`[initializeForm] 테이블 섹션 ${section.id}: formData[${tableSectionKey}]에 저장됨`);
}
} catch (error) {
console.error(`[initializeForm] 테이블 섹션 ${section.id}: 디테일 데이터 로드 실패`, error);
}
}
}
setFormData(newFormData);
setRepeatSections(newRepeatSections);
setCollapsedSections(newCollapsed);
setActivatedOptionalFieldGroups(newActivatedGroups);
setOriginalData(effectiveInitialData || {});
// 수정 모드에서 서브 테이블 데이터 로드 (겸직 등)
const multiTable = config.saveConfig?.customApiSave?.multiTable;
if (multiTable && effectiveInitialData) {
const pkColumn = multiTable.mainTable?.primaryKeyColumn;
const pkValue = effectiveInitialData[pkColumn];
// PK 값이 있으면 수정 모드로 판단
if (pkValue) {
console.log("[initializeForm] 수정 모드 - 서브 테이블 데이터 로드 시작");
for (const subTableConfig of multiTable.subTables || []) {
// loadOnEdit 옵션이 활성화된 경우에만 로드
if (!subTableConfig.enabled || !subTableConfig.options?.loadOnEdit) {
continue;
}
const { tableName, linkColumn, repeatSectionId, fieldMappings, options } = subTableConfig;
if (!tableName || !linkColumn?.subColumn || !repeatSectionId) {
continue;
}
try {
// 서브 테이블에서 데이터 조회
const filters: Record<string, any> = {
[linkColumn.subColumn]: pkValue,
};
// 서브 항목만 로드 (메인 항목 제외)
if (options?.loadOnlySubItems && options?.mainMarkerColumn) {
filters[options.mainMarkerColumn] = options.subMarkerValue ?? false;
}
console.log(`[initializeForm] 서브 테이블 ${tableName} 조회:`, filters);
const response = await apiClient.get(`/table-management/tables/${tableName}/data`, {
params: {
filters: JSON.stringify(filters),
page: 1,
pageSize: 100,
},
});
if (response.data?.success && response.data?.data?.items) {
const subItems = response.data.data.items;
console.log(`[initializeForm] 서브 테이블 ${tableName} 데이터 ${subItems.length}건 로드됨`);
// 역매핑: 서브 테이블 데이터 → 반복 섹션 데이터
const repeatItems: RepeatSectionItem[] = subItems.map((item: any, index: number) => {
const repeatItem: RepeatSectionItem = {
_id: generateUniqueId("repeat"),
_index: index,
_originalData: item, // 원본 데이터 보관 (수정 시 필요)
};
// 필드 매핑 역변환 (targetColumn → formField)
for (const mapping of fieldMappings || []) {
if (mapping.formField && mapping.targetColumn) {
repeatItem[mapping.formField] = item[mapping.targetColumn];
}
}
return repeatItem;
});
// 반복 섹션에 데이터 설정
newRepeatSections[repeatSectionId] = repeatItems;
setRepeatSections({ ...newRepeatSections });
console.log(`[initializeForm] 반복 섹션 ${repeatSectionId}${repeatItems.length}건 설정`);
}
} catch (error) {
console.error(`[initializeForm] 서브 테이블 ${tableName} 로드 실패:`, error);
}
}
}
}
// 채번규칙 자동 생성
// 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 handleFieldChange = useCallback(
(columnName: string, value: any) => {
// 채번규칙 필드의 수동 모드 감지
const originalNumberingValue = numberingOriginalValues[columnName];
const ruleIdKey = `${columnName}_numberingRuleId`;
// 해당 필드의 채번규칙 설정 찾기
let fieldConfig: FormFieldConfig | undefined;
for (const section of config.sections) {
if (section.type === "table" || section.repeatable) continue;
fieldConfig = section.fields?.find((f) => f.columnName === columnName);
if (fieldConfig) break;
// 옵셔널 필드 그룹에서도 찾기
for (const group of section.optionalFieldGroups || []) {
fieldConfig = group.fields?.find((f) => f.columnName === columnName);
if (fieldConfig) break;
}
if (fieldConfig) break;
}
setFormData((prev) => {
const newData = { ...prev, [columnName]: value };
// 채번규칙이 활성화된 필드이고, "사용자 수정 가능"이 ON인 경우
if (fieldConfig?.numberingRule?.enabled && fieldConfig?.numberingRule?.editable && originalNumberingValue) {
// 사용자가 값을 수정했으면 (원본과 다르면) ruleId 제거 → 수동 모드
if (value !== originalNumberingValue) {
delete newData[ruleIdKey];
console.log(`[채번 수동 모드] ${columnName}: 사용자가 값 수정 → ruleId 제거`);
} else {
// 원본 값으로 복구하면 ruleId 복구 → 자동 모드
if (fieldConfig.numberingRule.ruleId) {
newData[ruleIdKey] = fieldConfig.numberingRule.ruleId;
console.log(`[채번 자동 모드] ${columnName}: 원본 값 복구 → ruleId 복구`);
}
}
}
// onChange는 렌더링 외부에서 호출해야 함 (setTimeout 사용)
if (onChange) {
setTimeout(() => onChange(newData), 0);
}
return newData;
});
},
[onChange, numberingOriginalValues, config.sections],
);
// 반복 섹션 필드 값 변경 핸들러
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;
});
}, []);
// 옵셔널 필드 그룹 활성화
const activateOptionalFieldGroup = useCallback(
(sectionId: string, groupId: string) => {
const section = config.sections.find((s) => s.id === sectionId);
const group = section?.optionalFieldGroups?.find((g) => g.id === groupId);
if (!group) return;
const key = `${sectionId}-${groupId}`;
setActivatedOptionalFieldGroups((prev) => {
const newSet = new Set(prev);
newSet.add(key);
return newSet;
});
// 연동 필드 값 변경 (추가 시)
if (group.triggerField && group.triggerValueOnAdd !== undefined) {
handleFieldChange(group.triggerField, group.triggerValueOnAdd);
}
},
[config, handleFieldChange],
);
// 옵셔널 필드 그룹 비활성화
const deactivateOptionalFieldGroup = useCallback(
(sectionId: string, groupId: string) => {
const section = config.sections.find((s) => s.id === sectionId);
const group = section?.optionalFieldGroups?.find((g) => g.id === groupId);
if (!group) return;
const key = `${sectionId}-${groupId}`;
setActivatedOptionalFieldGroups((prev) => {
const newSet = new Set(prev);
newSet.delete(key);
return newSet;
});
// 연동 필드 값 변경 (제거 시)
if (group.triggerField && group.triggerValueOnRemove !== undefined) {
handleFieldChange(group.triggerField, group.triggerValueOnRemove);
}
// 옵셔널 필드 그룹 필드 값 초기화
(group.fields || []).forEach((field) => {
handleFieldChange(field.columnName, field.defaultValue || "");
});
},
[config, handleFieldChange],
);
// 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.categoryKey) {
// 공통코드(카테고리 컬럼): table_column_category_values 테이블에서 조회
// categoryKey 형식: "tableName.columnName"
const [categoryTable, categoryColumn] = optionConfig.categoryKey.split(".");
if (categoryTable && categoryColumn) {
const response = await apiClient.get(`/table-categories/${categoryTable}/${categoryColumn}/values`);
if (response.data?.success && response.data?.data) {
// 코드값을 DB에 저장하고 라벨값을 화면에 표시
options = response.data.data.map((item: any) => ({
value: item.valueCode || item.value_code,
label: item.valueLabel || item.value_label,
}));
}
}
}
// 캐시 저장
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 || section.type === "table") 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 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 || section.type === "table") return; // 반복 섹션 및 테이블 타입 제외
(section.fields || []).forEach((field) => {
const value = formData[field.columnName];
if (value !== undefined && value !== null && value !== "") {
mainData[field.columnName] = value;
}
});
});
// 1-0. receiveFromParent 필드 값도 mainData에 추가 (서브 테이블 저장용)
// 이 필드들은 메인 테이블에는 저장되지 않지만, 서브 테이블 저장 시 필요할 수 있음
config.sections.forEach((section) => {
if (section.repeatable || section.type === "table") return;
(section.fields || []).forEach((field) => {
if (field.receiveFromParent && !mainData[field.columnName]) {
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 || section.type === "table") 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 || []) {
// 서브 테이블이 활성화되어 있고 테이블명이 있어야 함
// repeatSectionId는 선택사항 (saveMainAsFirst만 사용하는 경우 없을 수 있음)
if (!subTableConfig.enabled || !subTableConfig.tableName) {
continue;
}
const subItems: Record<string, any>[] = [];
// 반복 섹션이 있는 경우에만 반복 데이터 처리
if (subTableConfig.repeatSectionId) {
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) {
// formData에서 동일한 컬럼명이 있으면 매핑 (receiveFromParent 필드 포함)
const formValue = formData[mapping.targetColumn];
if (formValue !== undefined && formValue !== null && formValue !== "") {
mainFieldMappings.push({
formField: mapping.targetColumn,
targetColumn: mapping.targetColumn,
});
}
// 또는 메인 섹션의 필드 중 같은 이름이 있으면 매핑
else {
config.sections.forEach((section) => {
if (section.repeatable || section.type === "table") return;
const matchingField = (section.fields || []).find((f) => f.columnName === mapping.targetColumn);
if (matchingField) {
const fieldValue = formData[matchingField.columnName];
if (fieldValue !== undefined && fieldValue !== null && fieldValue !== "") {
mainFieldMappings!.push({
formField: matchingField.columnName,
targetColumn: mapping.targetColumn,
});
}
}
});
}
}
}
// 중복 제거
mainFieldMappings = mainFieldMappings.filter(
(m, idx, arr) => arr.findIndex((x) => x.targetColumn === m.targetColumn) === idx,
);
}
// 서브 테이블 데이터 추가 (반복 데이터가 없어도 saveMainAsFirst가 있으면 추가)
if (subItems.length > 0 || subTableConfig.options?.saveMainAsFirst) {
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]);
// 필드 요소 렌더링 (입력 컴포넌트만)
// 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 => {
// 메인 표시 컬럼 (displayColumn)
const mainDisplayVal = row[lfg.displayColumn || ""] || "";
// 서브 표시 컬럼 (subDisplayColumn이 있으면 사용, 없으면 valueColumn 사용)
const subDisplayVal = lfg.subDisplayColumn ? row[lfg.subDisplayColumn] || "" : row[valueColumn] || "";
switch (lfg.displayFormat) {
case "code_name":
// 서브 - 메인 형식
return `${subDisplayVal} - ${mainDisplayVal}`;
case "name_code":
// 메인 (서브) 형식
return `${mainDisplayVal} (${subDisplayVal})`;
case "custom":
// 커스텀 형식: {컬럼명}을 실제 값으로 치환
if (lfg.customDisplayFormat) {
let result = lfg.customDisplayFormat;
// {컬럼명} 패턴을 찾아서 실제 값으로 치환
const matches = result.match(/\{([^}]+)\}/g);
if (matches) {
matches.forEach((match) => {
const columnName = match.slice(1, -1); // { } 제거
const columnValue = row[columnName];
result = result.replace(
match,
columnValue !== undefined && columnValue !== null ? String(columnValue) : "",
);
});
}
return result;
}
return String(mainDisplayVal);
case "name_only":
default:
return String(mainDisplayVal);
}
};
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
.filter(
(row) =>
row[valueColumn] !== null &&
row[valueColumn] !== undefined &&
String(row[valueColumn]) !== "",
)
.map((row, index) => (
<SelectItem key={`${row[valueColumn]}_${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);
}
// 테이블 타입 섹션
if (section.type === "table" && section.tableConfig) {
return (
<Card key={section.id} className="mb-4">
<CardHeader className="pb-3">
<CardTitle className="text-base">{section.title}</CardTitle>
{section.description && <CardDescription className="text-xs">{section.description}</CardDescription>}
</CardHeader>
<CardContent>
<TableSectionRenderer
sectionId={section.id}
tableConfig={section.tableConfig}
formData={formData}
onFormDataChange={handleFieldChange}
onTableDataChange={(data) => {
// 테이블 섹션 데이터를 formData에 저장
handleFieldChange(`_tableSection_${section.id}`, data);
}}
groupedData={_groupedData}
/>
</CardContent>
</Card>
);
}
// 기본 필드 타입 섹션
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>
{/* 옵셔널 필드 그룹 렌더링 */}
{section.optionalFieldGroups && section.optionalFieldGroups.length > 0 && (
<div className="mt-4 space-y-3">
{section.optionalFieldGroups.map((group) => renderOptionalFieldGroup(section, group, sectionColumns))}
</div>
)}
</CardContent>
</>
)}
</Card>
);
};
// 옵셔널 필드 그룹 접힘 상태 관리
const [collapsedOptionalGroups, setCollapsedOptionalGroups] = useState<Set<string>>(() => {
// 초기 접힘 상태 설정
const initialCollapsed = new Set<string>();
config.sections.forEach((section) => {
section.optionalFieldGroups?.forEach((group) => {
if (group.defaultCollapsed) {
initialCollapsed.add(`${section.id}-${group.id}`);
}
});
});
return initialCollapsed;
});
// 옵셔널 필드 그룹 렌더링
const renderOptionalFieldGroup = (
section: FormSectionConfig,
group: OptionalFieldGroupConfig,
sectionColumns: number,
) => {
const key = `${section.id}-${group.id}`;
const isActivated = activatedOptionalFieldGroups.has(key);
const isCollapsed = collapsedOptionalGroups.has(key);
const groupColumns = group.columns || sectionColumns;
const addButtonText = group.addButtonText || `+ ${group.title} 추가`;
const removeButtonText = group.removeButtonText || "제거";
// 비활성화 상태: 추가 버튼만 표시
if (!isActivated) {
return (
<div
key={group.id}
className="hover:border-primary/50 hover:bg-muted/30 border-muted rounded-lg border-2 border-dashed p-3 transition-colors"
>
<div className="flex items-center justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">{group.title}</p>
{group.description && <p className="text-muted-foreground/70 mt-0.5 text-xs">{group.description}</p>}
</div>
<Button
variant="outline"
size="sm"
onClick={() => activateOptionalFieldGroup(section.id, group.id)}
className="h-8 shrink-0 text-xs"
>
<Plus className="mr-1 h-3.5 w-3.5" />
{addButtonText}
</Button>
</div>
</div>
);
}
// 활성화 상태: 필드 그룹 표시
// collapsible 설정에 따라 접기/펼치기 지원
if (group.collapsible) {
return (
<Collapsible
key={group.id}
open={!isCollapsed}
onOpenChange={(open) => {
setCollapsedOptionalGroups((prev) => {
const newSet = new Set(prev);
if (open) {
newSet.delete(key);
} else {
newSet.add(key);
}
return newSet;
});
}}
className="border-primary/30 bg-muted/10 rounded-lg border"
>
<div className="flex items-center justify-between p-3">
<CollapsibleTrigger asChild>
<button className="flex items-center gap-2 text-left hover:opacity-80">
{isCollapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
<div>
<p className="text-sm font-medium">{group.title}</p>
{group.description && <p className="text-muted-foreground mt-0.5 text-xs">{group.description}</p>}
</div>
</button>
</CollapsibleTrigger>
<Button
variant="ghost"
size="sm"
onClick={() => {
if (group.confirmRemove) {
if (confirm(`${group.title}을(를) 제거하시겠습니까?\n입력한 내용이 초기화됩니다.`)) {
deactivateOptionalFieldGroup(section.id, group.id);
}
} else {
deactivateOptionalFieldGroup(section.id, group.id);
}
}}
className="text-muted-foreground hover:text-destructive h-7 shrink-0 text-xs"
>
<Trash2 className="mr-1 h-3 w-3" />
{removeButtonText}
</Button>
</div>
<CollapsibleContent>
<div className="grid gap-3 px-3 pb-3" style={{ gridTemplateColumns: "repeat(12, 1fr)" }}>
{(group.fields || []).map((field) =>
renderFieldWithColumns(
field,
formData[field.columnName],
(value) => handleFieldChange(field.columnName, value),
`${section.id}-${group.id}-${field.id}`,
groupColumns,
),
)}
</div>
</CollapsibleContent>
</Collapsible>
);
}
// 접기 비활성화: 일반 표시
return (
<div key={group.id} className="border-primary/30 bg-muted/10 rounded-lg border p-3">
<div className="mb-3 flex items-center justify-between">
<div>
<p className="text-sm font-medium">{group.title}</p>
{group.description && <p className="text-muted-foreground mt-0.5 text-xs">{group.description}</p>}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
if (group.confirmRemove) {
if (confirm(`${group.title}을(를) 제거하시겠습니까?\n입력한 내용이 초기화됩니다.`)) {
deactivateOptionalFieldGroup(section.id, group.id);
}
} else {
deactivateOptionalFieldGroup(section.id, group.id);
}
}}
className="text-muted-foreground hover:text-destructive h-7 shrink-0 text-xs"
>
<Trash2 className="mr-1 h-3 w-3" />
{removeButtonText}
</Button>
</div>
<div className="grid gap-3" style={{ gridTemplateColumns: "repeat(12, 1fr)" }}>
{(group.fields || []).map((field) =>
renderFieldWithColumns(
field,
formData[field.columnName],
(value) => handleFieldChange(field.columnName, value),
`${section.id}-${group.id}-${field.id}`,
groupColumns,
),
)}
</div>
</div>
);
};
// 반복 섹션 렌더링
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), 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>
{/* 삭제 확인 다이얼로그 */}
<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
.filter((option) => option.value && option.value !== "")
.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
export default UniversalFormModalComponent;