Files
vexplor/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx
kjs 28ef7e1226 fix: Enhance error handling and validation messages in form data operations
- Integrated `formatPgError` utility to provide user-friendly error messages based on PostgreSQL error codes during form data operations.
- Updated error responses in `saveFormData`, `saveFormDataEnhanced`, `updateFormData`, and `updateFormDataPartial` to include specific messages based on the company context.
- Improved error handling in the frontend components to display relevant error messages from the server response, ensuring users receive clear feedback on save operations.
- Enhanced the required field validation by incorporating NOT NULL metadata checks across various components, improving the accuracy of form submissions.

These changes improve the overall user experience by providing clearer error messages and ensuring that required fields are properly validated based on both manual settings and database constraints.
2026-03-10 14:47:05 +09:00

2562 lines
103 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, Check, ChevronsUpDown } from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
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 { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
import {
UniversalFormModalComponentProps,
UniversalFormModalConfig,
FormSectionConfig,
FormFieldConfig,
FormDataState,
RepeatSectionItem,
SelectOptionConfig,
OptionalFieldGroupConfig,
} from "./types";
import { defaultConfig, generateUniqueId } from "./config";
import { TableSectionRenderer } from "./TableSectionRenderer";
import { isColumnRequiredByMeta } from "@/lib/registry/DynamicComponentRenderer";
/**
* 🔗 연쇄 드롭다운 Select 필드 컴포넌트
* allowCustomInput이 true이면 Combobox 형태로 직접 입력 가능
*/
interface CascadingSelectFieldProps {
fieldId: string;
config: CascadingDropdownConfig;
parentValue?: string | number | null;
value?: string;
onChange: (value: string) => void;
placeholder?: string;
disabled?: boolean;
allowCustomInput?: boolean;
}
const CascadingSelectField: React.FC<CascadingSelectFieldProps> = ({
fieldId,
config,
parentValue,
value,
onChange,
placeholder,
disabled,
allowCustomInput = false,
}) => {
const [open, setOpen] = useState(false);
const [inputValue, setInputValue] = useState(value || "");
const { options, loading } = useCascadingDropdown({
config,
parentValue,
});
// value가 외부에서 변경되면 inputValue도 동기화
useEffect(() => {
setInputValue(value || "");
}, [value]);
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;
// Combobox 형태 (직접 입력 허용)
if (allowCustomInput) {
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<div className="relative w-full">
<Input
id={fieldId}
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value);
onChange(e.target.value);
}}
placeholder={getPlaceholder()}
disabled={isDisabled}
className="w-full pr-8"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute top-0 right-0 h-full px-2 hover:bg-transparent"
onClick={() => !isDisabled && setOpen(!open)}
disabled={isDisabled}
>
{loading ? (
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
) : (
<ChevronsUpDown className="text-muted-foreground h-4 w-4" />
)}
</Button>
</div>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
<Command>
<CommandInput placeholder="검색..." className="h-9" />
<CommandList>
<CommandEmpty>
{!parentValue
? config.emptyParentMessage || "상위 항목을 먼저 선택하세요"
: config.noOptionsMessage || "선택 가능한 항목이 없습니다"}
</CommandEmpty>
<CommandGroup>
{options
.filter((option) => option.value && option.value !== "")
.map((option) => (
<CommandItem
key={option.value}
value={option.label}
onSelect={() => {
setInputValue(option.label);
onChange(option.value);
setOpen(false);
}}
>
<Check className={cn("mr-2 h-4 w-4", value === option.value ? "opacity-100" : "opacity-0")} />
{option.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
// 기본 Select 형태 (목록에서만 선택)
return (
<Select value={value || ""} onValueChange={onChange} disabled={isDisabled}>
<SelectTrigger id={fieldId} className="w-full" size="default">
{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 || {};
// V2 레이아웃에서 overrides 전체가 config로 전달되는 경우
// 실제 설정이 propConfig.componentConfig에 이중 중첩되어 있을 수 있음
const nestedPropConfig = propConfig?.componentConfig;
const hasFlatPropConfig = propConfig?.modal !== undefined || propConfig?.sections !== undefined;
const effectivePropConfig = hasFlatPropConfig
? propConfig
: (nestedPropConfig?.modal ? nestedPropConfig : propConfig);
const nestedCompConfig = componentConfig?.componentConfig;
const hasFlatCompConfig = componentConfig?.modal !== undefined || componentConfig?.sections !== undefined;
const effectiveCompConfig = hasFlatCompConfig
? componentConfig
: (nestedCompConfig?.modal ? nestedCompConfig : componentConfig);
return {
...defaultConfig,
...effectivePropConfig,
...effectiveCompConfig,
modal: {
...defaultConfig.modal,
...effectivePropConfig?.modal,
...effectiveCompConfig?.modal,
},
saveConfig: {
...defaultConfig.saveConfig,
...effectivePropConfig?.saveConfig,
...effectiveCompConfig?.saveConfig,
afterSave: {
...defaultConfig.saveConfig.afterSave,
...effectivePropConfig?.saveConfig?.afterSave,
...effectiveCompConfig?.saveConfig?.afterSave,
},
},
};
}, [component?.config, propConfig]);
// 폼 데이터 상태
const [formData, setFormData] = useState<FormDataState>({});
// formDataRef: 항상 최신 formData를 유지하는 ref
// React 상태 업데이트는 비동기적이므로, handleBeforeFormSave 등에서
// 클로저의 formData가 오래된 값을 참조하는 문제를 방지
const formDataRef = useRef<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);
const tableSectionLoadedRef = useRef(false);
// 초기화 - 최초 마운트 시 또는 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] 초기화 스킵 - 이미 초기화됨", { currentIdString });
// 🆕 채번 플래그가 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 호출 예정", { currentIdString });
hasInitialized.current = true;
tableSectionLoadedRef.current = false;
initializeForm();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialData]); // initialData 전체 변경 시 재초기화
// 컴포넌트 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;
// 필수값 검증 실행
const validation = validateRequiredFields();
if (!validation.valid) {
event.detail.validationFailed = true;
event.detail.validationErrors = validation.missingFields;
toast.error(`필수 항목을 입력해주세요: ${validation.missingFields.join(", ")}`);
console.log("[UniversalFormModal] 필수값 검증 실패:", validation.missingFields);
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));
// formDataRef.current 사용: blur+click 동시 처리 시 클로저의 formData가 오래된 문제 방지
const latestFormData = formDataRef.current;
// 🆕 시스템 필드 병합: id는 설정 여부와 관계없이 항상 전달 (UPDATE/INSERT 판단용)
if (latestFormData.id !== undefined && latestFormData.id !== null && latestFormData.id !== "") {
event.detail.formData.id = latestFormData.id;
console.log(`[UniversalFormModal] 시스템 필드 병합: id =`, latestFormData.id);
}
// UniversalFormModal에 설정된 필드만 병합 (채번 규칙 포함)
// 외부 formData에 이미 값이 있어도 UniversalFormModal 값으로 덮어씀
// (UniversalFormModal이 해당 필드의 주인이므로)
for (const [key, value] of Object.entries(latestFormData)) {
// 설정에 정의된 필드 또는 채번 규칙 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);
}
}
// 🆕 테이블 섹션 데이터 병합 (품목 리스트 등)
// formDataRef.current(= latestFormData) 사용: React 상태 커밋 전에도 최신 데이터 보장
for (const [key, value] of Object.entries(latestFormData)) {
// _tableSection_ 과 __tableSection_ 모두 원본 키 그대로 전달
// buttonActions.ts에서 DB데이터(__tableSection_)와 수정데이터(_tableSection_)를 구분하여 병합
if ((key.startsWith("_tableSection_") || key.startsWith("__tableSection_")) && Array.isArray(value)) {
event.detail.formData[key] = value;
console.log(`[UniversalFormModal] 테이블 섹션 병합: ${key}, ${value.length}개 항목`);
}
// 🆕 원본 테이블 섹션 데이터도 병합 (삭제 추적용)
if (key.startsWith("_originalTableSectionData_") && 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}`);
}
// 분할패널에서 전달한 메인 레코드 ID 전달
if (latestFormData._mainRecordId) {
event.detail.formData._mainRecordId = latestFormData._mainRecordId;
}
// 🆕 부모 formData의 중첩 객체(modalKey)도 최신 데이터로 업데이트
// onChange(setTimeout)가 아직 부모에 전파되지 않았을 수 있으므로 직접 업데이트
for (const parentKey of Object.keys(event.detail.formData)) {
const parentValue = event.detail.formData[parentKey];
if (parentValue && typeof parentValue === "object" && !Array.isArray(parentValue)) {
const hasTableSection = Object.keys(parentValue).some(
(k) => k.startsWith("_tableSection_") || k.startsWith("__tableSection_"),
);
if (hasTableSection) {
event.detail.formData[parentKey] = { ...latestFormData };
console.log(`[UniversalFormModal] 부모 중첩 객체 업데이트: ${parentKey}`);
break;
}
}
}
};
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) => {
const newData = { ...prev, [tableSectionKey]: _groupedData };
formDataRef.current = newData;
return newData;
});
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 });
console.warn("[initializeForm] 테이블 섹션 순회 시작:", {
sectionCount: config.sections.length,
tableSections: config.sections.filter(s => s.type === "table").map(s => s.id),
hasInitialData: !!effectiveInitialData,
initialDataKeys: effectiveInitialData ? Object.keys(effectiveInitialData).slice(0, 10) : [],
});
for (const section of config.sections) {
if (section.type !== "table" || !section.tableConfig) {
continue;
}
const tableConfig = section.tableConfig;
const editConfig = (tableConfig as any).editConfig;
const saveConfig = tableConfig.saveConfig;
console.warn(`[initializeForm] 테이블 섹션 ${section.id}:`, {
editConfig,
targetTable: saveConfig?.targetTable,
masterField: editConfig?.linkColumn?.masterField,
masterValue: effectiveInitialData?.[editConfig?.linkColumn?.masterField],
});
// 수정 모드 로드 설정 확인 (기본값: 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}]에 저장됨`);
// 🆕 테이블 섹션 원본 데이터 저장 (삭제 추적용)
// 각 테이블 섹션별로 별도의 키에 원본 데이터 저장 (groupedDataInitializedRef와 무관하게 항상 저장)
const originalTableSectionKey = `_originalTableSectionData_${section.id}`;
newFormData[originalTableSectionKey] = JSON.parse(JSON.stringify(items));
console.log(
`[initializeForm] 테이블 섹션 ${section.id}: formData[${originalTableSectionKey}]에 원본 ${items.length}건 저장`,
);
// 기존 originalGroupedData에도 추가 (하위 호환성)
if (!groupedDataInitializedRef.current) {
setOriginalGroupedData((prev) => {
const newOriginal = [...prev, ...JSON.parse(JSON.stringify(items))];
console.log(
`[initializeForm] 테이블 섹션 ${section.id}: originalGroupedData에 ${items.length}건 추가 (총 ${newOriginal.length}건)`,
);
return newOriginal;
});
}
}
} catch (error) {
console.error(`[initializeForm] 테이블 섹션 ${section.id}: 디테일 데이터 로드 실패`, error);
}
}
}
// 분할패널에서 전달한 메인 레코드 ID 보존
if (effectiveInitialData?._mainRecordId) {
newFormData._mainRecordId = effectiveInitialData._mainRecordId;
}
setFormData(newFormData);
formDataRef.current = 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 사용)
// config 변경 시 테이블 섹션 데이터 로드 보완
// initializeForm은 initialData useEffect에서 호출되지만, config(화면 설정)이
// 비동기 로드로 늦게 도착하면 테이블 섹션 로드를 놓칠 수 있음
useEffect(() => {
if (!hasInitialized.current) return;
const hasTableSection = config.sections.some(s => s.type === "table" && s.tableConfig?.saveConfig?.targetTable);
if (!hasTableSection) return;
const editData = capturedInitialData.current || initialData;
if (!editData || Object.keys(editData).length === 0) return;
if (tableSectionLoadedRef.current) return;
tableSectionLoadedRef.current = true;
initializeForm();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config.sections, initializeForm]);
// 반복 섹션 아이템 생성
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;
}
// 🆕 연쇄 드롭다운: 부모 필드 변경 시 자식 필드 초기화
const childFieldsToReset: string[] = [];
for (const section of config.sections) {
if (section.type === "table" || section.repeatable) continue;
for (const field of section.fields || []) {
// field.cascading 방식 체크
if (field.cascading?.enabled && field.cascading?.parentField === columnName) {
if (field.cascading.clearOnParentChange !== false) {
childFieldsToReset.push(field.columnName);
}
}
// selectOptions.cascading 방식 체크
if (field.selectOptions?.type === "cascading" && field.selectOptions?.cascading?.parentField === columnName) {
if (field.selectOptions.cascading.clearOnParentChange !== false) {
childFieldsToReset.push(field.columnName);
}
}
}
}
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 복구`);
}
}
}
// 🆕 연쇄 드롭다운 자식 필드 초기화
for (const childField of childFieldsToReset) {
newData[childField] = "";
console.log(`[연쇄 드롭다운] 부모 ${columnName} 변경 → 자식 ${childField} 초기화`);
}
// ref 즉시 업데이트 (React 상태 커밋 전에도 최신 데이터 접근 가능)
formDataRef.current = newData;
// 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) {
// 공통코드(카테고리 컬럼): 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],
);
// 필수 필드 검증 (수동 required + NOT NULL 메타데이터 통합)
const mainTableName = config.saveConfig?.customApiSave?.multiTable?.mainTable?.tableName || config.saveConfig?.tableName;
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 || []) {
const isRequired = field.required || isColumnRequiredByMeta(mainTableName, field.columnName);
if (isRequired && !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, mainTableName]);
// 다중 테이블 저장 (범용)
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 {
// 🆕 사용자가 편집한 값을 전달 (수동 입력 부분 추출용)
const userInputCode = mainData[field.columnName] as string;
const response = await allocateNumberingCode(field.numberingRule.ruleId, userInputCode, mainData);
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": {
// 🆕 연쇄 드롭다운 처리 (기존 field.cascading 방식)
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}
allowCustomInput={field.selectOptions?.allowCustomInput}
/>
);
}
// 🆕 연쇄 드롭다운 처리 (selectOptions.type === "cascading" 방식)
if (field.selectOptions?.type === "cascading" && field.selectOptions?.cascading?.parentField) {
const cascadingOpts = field.selectOptions.cascading;
const parentValue = formData[cascadingOpts.parentField];
// selectOptions 기반 cascading config를 CascadingDropdownConfig 형태로 변환
const cascadingConfig: CascadingDropdownConfig = {
enabled: true,
parentField: cascadingOpts.parentField,
sourceTable: cascadingOpts.sourceTable || field.selectOptions.tableName || "",
parentKeyColumn: cascadingOpts.parentKeyColumn || "",
valueColumn: field.selectOptions.valueColumn || "",
labelColumn: field.selectOptions.labelColumn || "",
emptyParentMessage: cascadingOpts.emptyParentMessage,
noOptionsMessage: cascadingOpts.noOptionsMessage,
clearOnParentChange: cascadingOpts.clearOnParentChange !== false,
};
return (
<CascadingSelectField
fieldId={fieldKey}
config={cascadingConfig}
parentValue={parentValue}
value={value}
onChange={onChangeHandler}
placeholder={field.placeholder || "선택하세요"}
disabled={isDisabled}
allowCustomInput={field.selectOptions?.allowCustomInput}
/>
);
}
// 다중 컬럼 저장이 활성화된 경우
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 (
<FormDatePicker
id={fieldKey}
value={value || ""}
onChange={onChangeHandler}
placeholder={field.placeholder || "날짜를 선택하세요"}
disabled={isDisabled}
readOnly={field.readOnly}
/>
);
case "datetime":
return (
<FormDatePicker
id={fieldKey}
value={value || ""}
onChange={onChangeHandler}
placeholder={field.placeholder || "날짜/시간을 선택하세요"}
disabled={isDisabled}
readOnly={field.readOnly}
includeTime
/>
);
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 || isColumnRequiredByMeta(mainTableName, field.columnName)) && <span className="text-orange-500">*</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 필드 컴포넌트 (옵션 로딩 포함)
// allowCustomInput이 true이면 Combobox 형태로 직접 입력 가능
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);
const [open, setOpen] = useState(false);
const [inputValue, setInputValue] = useState(value || "");
const allowCustomInput = optionConfig?.allowCustomInput || false;
useEffect(() => {
if (optionConfig) {
setLoading(true);
loadOptions(fieldId, optionConfig)
.then(setOptions)
.finally(() => setLoading(false));
}
}, [fieldId, optionConfig, loadOptions]);
// value가 외부에서 변경되면 inputValue도 동기화
useEffect(() => {
// 선택된 값이 있으면 해당 라벨을 표시, 없으면 value 그대로 표시
const selectedOption = options.find((opt) => opt.value === value);
setInputValue(selectedOption ? selectedOption.label : value || "");
}, [value, options]);
// Combobox 형태 (직접 입력 허용)
if (allowCustomInput) {
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<div className="relative w-full">
<Input
id={fieldId}
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value);
onChange(e.target.value);
}}
placeholder={loading ? "로딩 중..." : placeholder}
disabled={disabled || loading}
className="w-full pr-8"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute top-0 right-0 h-full px-2 hover:bg-transparent"
onClick={() => !disabled && !loading && setOpen(!open)}
disabled={disabled || loading}
>
{loading ? (
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
) : (
<ChevronsUpDown className="text-muted-foreground h-4 w-4" />
)}
</Button>
</div>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
<Command>
<CommandInput placeholder="검색..." className="h-9" />
<CommandList>
<CommandEmpty> </CommandEmpty>
<CommandGroup>
{options
.filter((option) => option.value && option.value !== "")
.map((option) => (
<CommandItem
key={option.value}
value={option.label}
onSelect={() => {
setInputValue(option.label);
onChange(option.value);
setOpen(false);
}}
>
<Check className={cn("mr-2 h-4 w-4", value === option.value ? "opacity-100" : "opacity-0")} />
{option.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
// 기본 Select 형태 (목록에서만 선택)
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;