473 lines
12 KiB
TypeScript
473 lines
12 KiB
TypeScript
/**
|
|
* 폼 검증 상태 관리 훅
|
|
* 실시간 검증과 사용자 피드백을 위한 커스텀 훅
|
|
*/
|
|
|
|
import { useState, useCallback, useEffect, useRef } from "react";
|
|
import { ComponentData, ColumnInfo, ScreenDefinition } from "@/types/screen";
|
|
import { validateFormData, ValidationResult, ValidationError, ValidationWarning } from "@/lib/utils/formValidation";
|
|
import { enhancedFormService, SaveContext, EnhancedSaveResult } from "@/lib/services/enhancedFormService";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
|
|
// 검증 상태
|
|
export type ValidationStatus = "idle" | "validating" | "valid" | "invalid";
|
|
|
|
// 필드별 검증 상태
|
|
export interface FieldValidationState {
|
|
status: ValidationStatus;
|
|
error?: ValidationError;
|
|
warning?: ValidationWarning;
|
|
lastValidated?: Date;
|
|
}
|
|
|
|
// 폼 검증 상태
|
|
export interface FormValidationState {
|
|
status: ValidationStatus;
|
|
isValid: boolean;
|
|
errors: ValidationError[];
|
|
warnings: ValidationWarning[];
|
|
fieldStates: Record<string, FieldValidationState>;
|
|
lastValidated?: Date;
|
|
validationCount: number;
|
|
}
|
|
|
|
// 저장 상태
|
|
export interface SaveState {
|
|
status: "idle" | "saving" | "success" | "error";
|
|
message?: string;
|
|
result?: EnhancedSaveResult;
|
|
lastSaved?: Date;
|
|
}
|
|
|
|
// 훅 옵션
|
|
export interface UseFormValidationOptions {
|
|
enableRealTimeValidation?: boolean;
|
|
validationDelay?: number; // debounce 지연시간 (ms)
|
|
enableAutoSave?: boolean;
|
|
autoSaveDelay?: number; // 자동저장 지연시간 (ms)
|
|
showToastMessages?: boolean;
|
|
validateOnMount?: boolean;
|
|
}
|
|
|
|
// 훅 반환값
|
|
export interface UseFormValidationReturn {
|
|
// 상태
|
|
validationState: FormValidationState;
|
|
saveState: SaveState;
|
|
|
|
// 액션
|
|
validateForm: () => Promise<ValidationResult>;
|
|
validateField: (fieldName: string, value: any) => Promise<void>;
|
|
saveForm: () => Promise<boolean>;
|
|
clearValidation: () => void;
|
|
|
|
// 유틸리티
|
|
getFieldError: (fieldName: string) => ValidationError | undefined;
|
|
getFieldWarning: (fieldName: string) => ValidationWarning | undefined;
|
|
hasFieldError: (fieldName: string) => boolean;
|
|
isFieldValid: (fieldName: string) => boolean;
|
|
canSave: boolean;
|
|
}
|
|
|
|
/**
|
|
* 폼 검증 관리 훅
|
|
*/
|
|
export const useFormValidation = (
|
|
formData: Record<string, any>,
|
|
components: ComponentData[],
|
|
tableColumns: ColumnInfo[],
|
|
screenInfo: ScreenDefinition,
|
|
options: UseFormValidationOptions = {},
|
|
): UseFormValidationReturn => {
|
|
const {
|
|
enableRealTimeValidation = true,
|
|
validationDelay = 500,
|
|
enableAutoSave = false,
|
|
autoSaveDelay = 2000,
|
|
showToastMessages = true,
|
|
validateOnMount = false,
|
|
} = options;
|
|
|
|
const { toast } = useToast();
|
|
|
|
// 상태
|
|
const [validationState, setValidationState] = useState<FormValidationState>({
|
|
status: "idle",
|
|
isValid: false,
|
|
errors: [],
|
|
warnings: [],
|
|
fieldStates: {},
|
|
validationCount: 0,
|
|
});
|
|
|
|
const [saveState, setSaveState] = useState<SaveState>({
|
|
status: "idle",
|
|
});
|
|
|
|
// 타이머 참조
|
|
const validationTimer = useRef<NodeJS.Timeout>();
|
|
const autoSaveTimer = useRef<NodeJS.Timeout>();
|
|
const lastValidationData = useRef<string>("");
|
|
|
|
/**
|
|
* 전체 폼 검증
|
|
*/
|
|
const validateForm = useCallback(async (): Promise<ValidationResult> => {
|
|
if (!screenInfo?.tableName) {
|
|
return {
|
|
isValid: false,
|
|
errors: [
|
|
{
|
|
field: "form",
|
|
code: "NO_TABLE",
|
|
message: "테이블명이 설정되지 않았습니다.",
|
|
severity: "error",
|
|
},
|
|
],
|
|
warnings: [],
|
|
};
|
|
}
|
|
|
|
setValidationState((prev) => ({
|
|
...prev,
|
|
status: "validating",
|
|
}));
|
|
|
|
try {
|
|
const result = await validateFormData(formData, components, tableColumns, screenInfo.tableName);
|
|
|
|
// 필드별 상태 업데이트
|
|
const fieldStates: Record<string, FieldValidationState> = {};
|
|
|
|
// 기존 필드 상태 초기화
|
|
Object.keys(formData).forEach((fieldName) => {
|
|
fieldStates[fieldName] = {
|
|
status: "valid",
|
|
lastValidated: new Date(),
|
|
};
|
|
});
|
|
|
|
// 오류가 있는 필드 업데이트
|
|
result.errors.forEach((error) => {
|
|
fieldStates[error.field] = {
|
|
status: "invalid",
|
|
error,
|
|
lastValidated: new Date(),
|
|
};
|
|
});
|
|
|
|
// 경고가 있는 필드 업데이트
|
|
result.warnings.forEach((warning) => {
|
|
if (fieldStates[warning.field]) {
|
|
fieldStates[warning.field].warning = warning;
|
|
} else {
|
|
fieldStates[warning.field] = {
|
|
status: "valid",
|
|
warning,
|
|
lastValidated: new Date(),
|
|
};
|
|
}
|
|
});
|
|
|
|
setValidationState((prev) => ({
|
|
status: result.isValid ? "valid" : "invalid",
|
|
isValid: result.isValid,
|
|
errors: result.errors,
|
|
warnings: result.warnings,
|
|
fieldStates,
|
|
lastValidated: new Date(),
|
|
validationCount: prev.validationCount + 1,
|
|
}));
|
|
|
|
if (showToastMessages) {
|
|
if (result.isValid && result.warnings.length > 0) {
|
|
toast({
|
|
title: "검증 완료",
|
|
description: `${result.warnings.length}개의 경고가 있습니다.`,
|
|
variant: "default",
|
|
});
|
|
} else if (!result.isValid) {
|
|
toast({
|
|
title: "검증 실패",
|
|
description: `${result.errors.length}개의 오류를 수정해주세요.`,
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
}
|
|
|
|
return result;
|
|
} catch (error) {
|
|
console.error("❌ 폼 검증 중 오류:", error);
|
|
|
|
const errorResult: ValidationResult = {
|
|
isValid: false,
|
|
errors: [
|
|
{
|
|
field: "form",
|
|
code: "VALIDATION_ERROR",
|
|
message: `검증 중 오류가 발생했습니다: ${error}`,
|
|
severity: "error",
|
|
},
|
|
],
|
|
warnings: [],
|
|
};
|
|
|
|
setValidationState((prev) => ({
|
|
...prev,
|
|
status: "invalid",
|
|
isValid: false,
|
|
errors: errorResult.errors,
|
|
warnings: [],
|
|
lastValidated: new Date(),
|
|
validationCount: prev.validationCount + 1,
|
|
}));
|
|
|
|
return errorResult;
|
|
}
|
|
}, [formData, components, tableColumns, screenInfo, showToastMessages, toast]);
|
|
|
|
/**
|
|
* 개별 필드 검증
|
|
*/
|
|
const validateField = useCallback(
|
|
async (fieldName: string, value: any): Promise<void> => {
|
|
const component = components.find((c) => (c as any).columnName === fieldName || c.id === fieldName);
|
|
|
|
if (!component || component.type !== "widget") return;
|
|
|
|
setValidationState((prev) => ({
|
|
...prev,
|
|
fieldStates: {
|
|
...prev.fieldStates,
|
|
[fieldName]: {
|
|
...prev.fieldStates[fieldName],
|
|
status: "validating",
|
|
},
|
|
},
|
|
}));
|
|
|
|
// 개별 필드 검증 로직
|
|
// (실제 구현에서는 validateFieldValue 함수 사용)
|
|
|
|
setValidationState((prev) => ({
|
|
...prev,
|
|
fieldStates: {
|
|
...prev.fieldStates,
|
|
[fieldName]: {
|
|
status: "valid",
|
|
lastValidated: new Date(),
|
|
},
|
|
},
|
|
}));
|
|
},
|
|
[components],
|
|
);
|
|
|
|
/**
|
|
* 폼 저장
|
|
*/
|
|
const saveForm = useCallback(async (): Promise<boolean> => {
|
|
if (!validationState.isValid) {
|
|
if (showToastMessages) {
|
|
toast({
|
|
title: "저장 실패",
|
|
description: "검증 오류를 먼저 수정해주세요.",
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
return false;
|
|
}
|
|
|
|
setSaveState({ status: "saving" });
|
|
|
|
try {
|
|
const saveContext: SaveContext = {
|
|
tableName: screenInfo.tableName,
|
|
screenInfo,
|
|
components,
|
|
formData,
|
|
options: {
|
|
transformData: true,
|
|
showProgress: true,
|
|
},
|
|
};
|
|
|
|
const result = await enhancedFormService.saveFormData(saveContext);
|
|
|
|
setSaveState({
|
|
status: result.success ? "success" : "error",
|
|
message: result.message,
|
|
result,
|
|
lastSaved: new Date(),
|
|
});
|
|
|
|
if (showToastMessages) {
|
|
toast({
|
|
title: result.success ? "저장 성공" : "저장 실패",
|
|
description: result.message,
|
|
variant: result.success ? "default" : "destructive",
|
|
});
|
|
}
|
|
|
|
return result.success;
|
|
} catch (error) {
|
|
console.error("❌ 폼 저장 중 오류:", error);
|
|
|
|
setSaveState({
|
|
status: "error",
|
|
message: `저장 중 오류가 발생했습니다: ${error}`,
|
|
lastSaved: new Date(),
|
|
});
|
|
|
|
if (showToastMessages) {
|
|
toast({
|
|
title: "저장 실패",
|
|
description: "저장 중 오류가 발생했습니다.",
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}, [validationState.isValid, screenInfo, components, formData, showToastMessages, toast]);
|
|
|
|
/**
|
|
* 검증 상태 초기화
|
|
*/
|
|
const clearValidation = useCallback(() => {
|
|
setValidationState({
|
|
status: "idle",
|
|
isValid: false,
|
|
errors: [],
|
|
warnings: [],
|
|
fieldStates: {},
|
|
validationCount: 0,
|
|
});
|
|
|
|
setSaveState({ status: "idle" });
|
|
}, []);
|
|
|
|
/**
|
|
* 필드 오류 조회
|
|
*/
|
|
const getFieldError = useCallback(
|
|
(fieldName: string): ValidationError | undefined => {
|
|
return validationState.fieldStates[fieldName]?.error;
|
|
},
|
|
[validationState.fieldStates],
|
|
);
|
|
|
|
/**
|
|
* 필드 경고 조회
|
|
*/
|
|
const getFieldWarning = useCallback(
|
|
(fieldName: string): ValidationWarning | undefined => {
|
|
return validationState.fieldStates[fieldName]?.warning;
|
|
},
|
|
[validationState.fieldStates],
|
|
);
|
|
|
|
/**
|
|
* 필드 오류 여부 확인
|
|
*/
|
|
const hasFieldError = useCallback(
|
|
(fieldName: string): boolean => {
|
|
return validationState.fieldStates[fieldName]?.status === "invalid";
|
|
},
|
|
[validationState.fieldStates],
|
|
);
|
|
|
|
/**
|
|
* 필드 유효성 확인
|
|
*/
|
|
const isFieldValid = useCallback(
|
|
(fieldName: string): boolean => {
|
|
const fieldState = validationState.fieldStates[fieldName];
|
|
return fieldState?.status === "valid" || !fieldState;
|
|
},
|
|
[validationState.fieldStates],
|
|
);
|
|
|
|
// 저장 가능 여부
|
|
const canSave = validationState.isValid && saveState.status !== "saving" && Object.keys(formData).length > 0;
|
|
|
|
// 실시간 검증 (debounced)
|
|
useEffect(() => {
|
|
if (!enableRealTimeValidation) return;
|
|
|
|
const currentDataString = JSON.stringify(formData);
|
|
if (currentDataString === lastValidationData.current) return;
|
|
|
|
// 이전 타이머 클리어
|
|
if (validationTimer.current) {
|
|
clearTimeout(validationTimer.current);
|
|
}
|
|
|
|
// 새 타이머 설정
|
|
validationTimer.current = setTimeout(() => {
|
|
lastValidationData.current = currentDataString;
|
|
validateForm();
|
|
}, validationDelay);
|
|
|
|
return () => {
|
|
if (validationTimer.current) {
|
|
clearTimeout(validationTimer.current);
|
|
}
|
|
};
|
|
}, [formData, enableRealTimeValidation, validationDelay, validateForm]);
|
|
|
|
// 자동 저장
|
|
useEffect(() => {
|
|
if (!enableAutoSave || !validationState.isValid) return;
|
|
|
|
// 이전 타이머 클리어
|
|
if (autoSaveTimer.current) {
|
|
clearTimeout(autoSaveTimer.current);
|
|
}
|
|
|
|
// 새 타이머 설정
|
|
autoSaveTimer.current = setTimeout(() => {
|
|
saveForm();
|
|
}, autoSaveDelay);
|
|
|
|
return () => {
|
|
if (autoSaveTimer.current) {
|
|
clearTimeout(autoSaveTimer.current);
|
|
}
|
|
};
|
|
}, [validationState.isValid, enableAutoSave, autoSaveDelay, saveForm]);
|
|
|
|
// 마운트 시 검증
|
|
useEffect(() => {
|
|
if (validateOnMount && Object.keys(formData).length > 0) {
|
|
validateForm();
|
|
}
|
|
}, [validateOnMount]); // formData는 의존성에서 제외 (무한 루프 방지)
|
|
|
|
// 클린업
|
|
useEffect(() => {
|
|
return () => {
|
|
if (validationTimer.current) {
|
|
clearTimeout(validationTimer.current);
|
|
}
|
|
if (autoSaveTimer.current) {
|
|
clearTimeout(autoSaveTimer.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
return {
|
|
validationState,
|
|
saveState,
|
|
validateForm,
|
|
validateField,
|
|
saveForm,
|
|
clearValidation,
|
|
getFieldError,
|
|
getFieldWarning,
|
|
hasFieldError,
|
|
isFieldValid,
|
|
canSave,
|
|
};
|
|
};
|