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.
This commit is contained in:
kjs 2026-03-10 14:47:05 +09:00
parent 43523a0bba
commit 28ef7e1226
16 changed files with 180 additions and 86 deletions

View File

@ -2,6 +2,7 @@ import { Response } from "express";
import { dynamicFormService } from "../services/dynamicFormService";
import { enhancedDynamicFormService } from "../services/enhancedDynamicFormService";
import { AuthenticatedRequest } from "../types/auth";
import { formatPgError } from "../utils/pgErrorUtil";
// 폼 데이터 저장 (기존 버전 - 레거시 지원)
export const saveFormData = async (
@ -68,9 +69,12 @@ export const saveFormData = async (
});
} catch (error: any) {
console.error("❌ 폼 데이터 저장 실패:", error);
res.status(500).json({
const { companyCode } = req.user as any;
const friendlyMsg = await formatPgError(error, companyCode);
const statusCode = error.code?.startsWith("23") ? 400 : 500;
res.status(statusCode).json({
success: false,
message: error.message || "데이터 저장에 실패했습니다.",
message: friendlyMsg,
});
}
};
@ -118,9 +122,12 @@ export const saveFormDataEnhanced = async (
res.json(result);
} catch (error: any) {
console.error("❌ 개선된 폼 데이터 저장 실패:", error);
res.status(500).json({
const { companyCode } = req.user as any;
const friendlyMsg = await formatPgError(error, companyCode);
const statusCode = error.code?.startsWith("23") ? 400 : 500;
res.status(statusCode).json({
success: false,
message: error.message || "데이터 저장에 실패했습니다.",
message: friendlyMsg,
});
}
};
@ -163,9 +170,12 @@ export const updateFormData = async (
});
} catch (error: any) {
console.error("❌ 폼 데이터 업데이트 실패:", error);
res.status(500).json({
const { companyCode } = req.user as any;
const friendlyMsg = await formatPgError(error, companyCode);
const statusCode = error.code?.startsWith("23") ? 400 : 500;
res.status(statusCode).json({
success: false,
message: error.message || "데이터 업데이트에 실패했습니다.",
message: friendlyMsg,
});
}
};
@ -216,9 +226,12 @@ export const updateFormDataPartial = async (
});
} catch (error: any) {
console.error("❌ 부분 업데이트 실패:", error);
res.status(500).json({
const { companyCode } = req.user as any;
const friendlyMsg = await formatPgError(error, companyCode);
const statusCode = error.code?.startsWith("23") ? 400 : 500;
res.status(statusCode).json({
success: false,
message: error.message || "부분 업데이트에 실패했습니다.",
message: friendlyMsg,
});
}
};

View File

@ -47,7 +47,10 @@ export const errorHandler = (
error = new AppError("참조 무결성 제약 조건 위반입니다.", 400);
} else if (pgError.code === "23502") {
// not_null_violation
error = new AppError("필수 입력값이 누락되었습니다.", 400);
const colName = pgError.column || "";
const tableName = pgError.table || "";
const detail = colName ? ` [${tableName}.${colName}]` : "";
error = new AppError(`필수 입력값이 누락되었습니다.${detail}`, 400);
} else if (pgError.code.startsWith("23")) {
// 기타 무결성 제약 조건 위반
error = new AppError("데이터 무결성 제약 조건 위반입니다.", 400);
@ -84,6 +87,7 @@ export const errorHandler = (
// 응답 전송
res.status(statusCode).json({
success: false,
message: message,
error: {
message: message,
...(process.env.NODE_ENV === "development" && { stack: error.stack }),

View File

@ -0,0 +1,50 @@
import { query } from "../database/db";
/**
* PostgreSQL
* table_type_columns의 column_label을
*/
export async function formatPgError(
error: any,
companyCode?: string
): Promise<string> {
if (!error || !error.code) {
return error?.message || "데이터 처리 중 오류가 발생했습니다.";
}
switch (error.code) {
case "23502": {
// not_null_violation
const colName = error.column || "";
const tblName = error.table || "";
if (colName && tblName && companyCode) {
try {
const rows = await query(
`SELECT column_label FROM table_type_columns
WHERE table_name = $1 AND column_name = $2 AND company_code = $3
LIMIT 1`,
[tblName, colName, companyCode]
);
const label = rows[0]?.column_label;
if (label) {
return `필수 입력값이 누락되었습니다: ${label}`;
}
} catch {
// 라벨 조회 실패 시 컬럼명으로 폴백
}
}
const detail = colName ? ` [${colName}]` : "";
return `필수 입력값이 누락되었습니다.${detail}`;
}
case "23505":
return "중복된 데이터가 존재합니다.";
case "23503":
return "참조 무결성 제약 조건 위반입니다.";
default:
if (error.code.startsWith("23")) {
return "데이터 무결성 제약 조건 위반입니다.";
}
return error.message || "데이터 처리 중 오류가 발생했습니다.";
}
}

View File

@ -558,9 +558,13 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
if (onSave) {
try {
await onSave();
} catch (error) {
} catch (error: any) {
console.error("저장 오류:", error);
toast.error("저장 중 오류가 발생했습니다.");
const msg =
error?.response?.data?.message ||
error?.message ||
"저장에 실패했습니다.";
toast.error(msg);
}
return;
}
@ -595,8 +599,12 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
);
toast.success("데이터가 성공적으로 저장되었습니다.");
} catch (error) {
toast.error("저장 중 오류가 발생했습니다.");
} catch (error: any) {
const msg =
error?.response?.data?.message ||
error?.message ||
"저장에 실패했습니다.";
toast.error(msg);
}
return;
}
@ -656,8 +664,12 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
} else {
toast.error(response.message || "저장에 실패했습니다.");
}
} catch (error) {
toast.error("저장 중 오류가 발생했습니다.");
} catch (error: any) {
const msg =
error?.response?.data?.message ||
error?.message ||
"저장에 실패했습니다.";
toast.error(msg);
}
};

View File

@ -282,7 +282,8 @@ export const SaveModal: React.FC<SaveModalProps> = ({
}, 300); // 모달 닫힘 애니메이션 후 실행
}
} else {
throw new Error(result.message || "저장에 실패했습니다.");
const errorMsg = result.message || result.error?.message || "저장에 실패했습니다.";
toast.error(errorMsg);
}
} catch (error: any) {
// ❌ 저장 실패 - 모달은 닫히지 않음

View File

@ -757,6 +757,8 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
style: mergedStyle,
// 수평 라벨 → 외부에서 처리하므로 label 전달 안 함
label: needsExternalHorizLabel ? undefined : effectiveLabel,
// NOT NULL 메타데이터 포함된 필수 여부 (V2Hierarchy 등 직접 props.required 참조하는 컴포넌트용)
required: effectiveRequired,
// 🆕 V2 레이아웃에서 overrides에서 복원된 상위 레벨 속성들도 전달 (DB 메타데이터 우선)
inputType: (baseColumnName && columnMetaCache[screenTableName || ""]?.[baseColumnName]?.input_type) || (component as any).inputType || mergedComponentConfig?.inputType,
columnName: (component as any).columnName || component.componentConfig?.columnName,

View File

@ -614,27 +614,16 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
// 실패한 경우 오류 처리
if (!success) {
// UI 전환 액션 및 모달 액션은 에러도 조용히 처리 (모달 내부에서 자체 에러 표시)
const silentErrorActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan"];
const silentErrorActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan", "save", "delete", "quickInsert"];
if (silentErrorActions.includes(actionConfig.type)) {
return;
}
// 기본 에러 메시지 결정
const defaultErrorMessage =
actionConfig.type === "save"
? "저장 중 오류가 발생했습니다."
: actionConfig.type === "delete"
? "삭제 중 오류가 발생했습니다."
: actionConfig.type === "submit"
? "제출 중 오류가 발생했습니다."
: "처리 중 오류가 발생했습니다.";
actionConfig.type === "submit"
? "제출 중 오류가 발생했습니다."
: "처리 중 오류가 발생했습니다.";
// 커스텀 메시지 사용 조건:
// 1. 커스텀 메시지가 있고
// 2. (액션 타입이 save이거나 OR 메시지에 "저장"이 포함되지 않은 경우)
const useCustomMessage =
actionConfig.errorMessage && (actionConfig.type === "save" || !actionConfig.errorMessage.includes("저장"));
const errorMessage = useCustomMessage ? actionConfig.errorMessage : defaultErrorMessage;
const errorMessage = actionConfig.errorMessage || defaultErrorMessage;
toast.error(errorMessage);
return;

View File

@ -10,6 +10,7 @@ import { useCalculation } from "./useCalculation";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { ComponentRendererProps } from "@/types/component";
import { isColumnRequiredByMeta } from "@/lib/registry/DynamicComponentRenderer";
// ✅ ComponentRendererProps 상속으로 필수 props 자동 확보
export interface ModalRepeaterTableComponentProps extends ComponentRendererProps {
@ -380,6 +381,15 @@ export function ModalRepeaterTableComponent({
return [];
}, [componentConfig?.columns, propColumns, sourceColumns]);
// NOT NULL 메타데이터 기반 required 보강
const enhancedColumns = React.useMemo((): RepeaterColumnConfig[] => {
if (!targetTable) return columns;
return columns.map((col) => ({
...col,
required: col.required || isColumnRequiredByMeta(targetTable, col.field),
}));
}, [columns, targetTable]);
// 초기 props 검증
useEffect(() => {
if (rawSourceColumns.length !== sourceColumns.length) {
@ -876,7 +886,7 @@ export function ModalRepeaterTableComponent({
{/* Repeater 테이블 */}
<RepeaterTable
columns={columns}
columns={enhancedColumns}
data={localValue}
onDataChange={handleChange}
onRowChange={handleRowChange}

View File

@ -835,8 +835,7 @@ export function RepeaterTable({
</Popover>
) : (
<>
{col.label}
{col.required && <span className="ml-1 text-red-500">*</span>}
{col.label}{col.required && <span className="text-orange-500">*</span>}
</>
)}
</div>

View File

@ -10,6 +10,7 @@ import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Loader2, Save, X, Layers, Table as TableIcon, Plus, Trash2, RotateCcw, Pencil } from "lucide-react";
import { isColumnRequiredByMeta } from "@/lib/registry/DynamicComponentRenderer";
import {
AlertDialog,
AlertDialogAction,
@ -3113,15 +3114,15 @@ function renderTableCell(
}
// 컬럼 렌더링 함수 (Simple 모드)
function renderColumn(col: CardColumnConfig, card: CardData, onChange: (value: any) => void) {
function renderColumn(col: CardColumnConfig, card: CardData, onChange: (value: any) => void, tableName?: string) {
const value = card[col.field];
const isReadOnly = !col.editable;
const effectiveRequired = col.required || isColumnRequiredByMeta(tableName, col.field);
return (
<div className="space-y-1.5">
<Label className="text-xs font-medium">
{col.label}
{col.required && <span className="text-destructive ml-1">*</span>}
{col.label}{effectiveRequired && <span className="text-orange-500">*</span>}
</Label>
{isReadOnly && (

View File

@ -43,6 +43,7 @@ const LUCIDE_ICON_MAP: Record<string, LucideIcon> = {
};
import { commonCodeApi } from "@/lib/api/commonCode";
import { cn } from "@/lib/utils";
import { isColumnRequiredByMeta } from "@/lib/registry/DynamicComponentRenderer";
export interface SelectedItemsDetailInputComponentProps extends ComponentRendererProps {
config?: SelectedItemsDetailInputConfig;
@ -1916,8 +1917,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
field.type === "textarea" && "col-span-2"
)}>
<label className="text-[11px] font-medium leading-none">
{field.label}
{field.required && <span className="text-destructive ml-0.5">*</span>}
{field.label}{(field.required || isColumnRequiredByMeta(componentConfig.targetTable, field.name)) && <span className="text-orange-500">*</span>}
</label>
{renderField(field, item.id, group.id, singleEntry.id, singleEntry)}
</div>
@ -1977,8 +1977,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
field.type === "textarea" && "col-span-2"
)}>
<label className="text-[11px] font-medium leading-none">
{field.label}
{field.required && <span className="text-destructive ml-0.5">*</span>}
{field.label}{(field.required || isColumnRequiredByMeta(componentConfig.targetTable, field.name)) && <span className="text-orange-500">*</span>}
</label>
{renderField(field, item.id, group.id, entry.id, entry)}
</div>
@ -2353,8 +2352,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
{/* 추가 입력 필드 컬럼 */}
{componentConfig.additionalFields?.map((field) => (
<TableHead key={field.name} className="h-12 px-4 py-3 text-xs font-semibold sm:text-sm">
{field.label}
{field.required && <span className="text-destructive ml-1">*</span>}
{field.label}{(field.required || isColumnRequiredByMeta(componentConfig.targetTable, field.name)) && <span className="text-orange-500">*</span>}
</TableHead>
))}

View File

@ -10,6 +10,7 @@ import { cn } from "@/lib/utils";
import { ComponentRendererProps } from "@/types/component";
import { useCalculation } from "./useCalculation";
import { apiClient } from "@/lib/api/client";
import { isColumnRequiredByMeta } from "@/lib/registry/DynamicComponentRenderer";
export interface SimpleRepeaterTableComponentProps extends ComponentRendererProps {
config?: SimpleRepeaterTableProps;
@ -674,8 +675,7 @@ export function SimpleRepeaterTableComponent({
className="text-muted-foreground px-4 py-2 text-left font-medium"
style={{ width: col.width }}
>
{col.label}
{col.required && <span className="text-destructive ml-1">*</span>}
{col.label}{(col.required || isColumnRequiredByMeta(componentTargetTable, col.field)) && <span className="text-orange-500">*</span>}
</th>
))}
{!readOnly && allowDelete && (

View File

@ -42,6 +42,7 @@ import {
} from "./types";
import { defaultConfig, generateUniqueId } from "./config";
import { TableSectionRenderer } from "./TableSectionRenderer";
import { isColumnRequiredByMeta } from "@/lib/registry/DynamicComponentRenderer";
/**
* 🔗 Select
@ -1438,15 +1439,17 @@ export function UniversalFormModalComponent({
[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; // 반복 섹션 및 테이블 섹션은 별도 검증
if (section.repeatable || section.type === "table") continue;
for (const field of section.fields || []) {
if (field.required && !field.hidden && !field.numberingRule?.hidden) {
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);
@ -1456,7 +1459,7 @@ export function UniversalFormModalComponent({
}
return { valid: missingFields.length === 0, missingFields };
}, [config.sections, formData]);
}, [config.sections, formData, mainTableName]);
// 다중 테이블 저장 (범용)
const saveWithMultiTable = useCallback(async () => {
@ -2007,8 +2010,7 @@ export function UniversalFormModalComponent({
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.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}

View File

@ -612,7 +612,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
// 실패한 경우 오류 처리
if (!success) {
// UI 전환 액션 및 모달 액션은 에러도 조용히 처리 (모달 내부에서 자체 에러 표시)
const silentErrorActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan"];
const silentErrorActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan", "save", "delete", "quickInsert"];
if (silentErrorActions.includes(actionConfig.type)) {
return;
}

View File

@ -8,6 +8,7 @@ import { DynamicFormApi } from "@/lib/api/dynamicForm";
import { ImprovedButtonActionExecutor } from "@/lib/utils/improvedButtonActionExecutor";
import { apiClient } from "@/lib/api/client";
import type { ExtendedControlContext } from "@/types/control-management";
import { isColumnRequiredByMeta } from "@/lib/registry/DynamicComponentRenderer";
/**
* 🔧 formData
@ -463,13 +464,15 @@ export class ButtonActionExecutor {
console.warn(`지원되지 않는 액션 타입: ${config.type}`);
return false;
}
} catch (error) {
} catch (error: any) {
console.error("버튼 액션 실행 오류:", error);
showErrorToast(
config.errorMessage || `'${config.label || config.type}' 버튼 실행에 실패했습니다`,
error,
{ guidance: "설정을 확인하거나 잠시 후 다시 시도해 주세요." }
);
const actualMsg =
error?.response?.data?.message ||
error?.response?.data?.error?.message ||
error?.message ||
"";
const title = actualMsg || config.errorMessage || `'${config.label || config.type}' 실행에 실패했습니다`;
toast.error(title);
return false;
}
}
@ -479,7 +482,7 @@ export class ButtonActionExecutor {
*/
private static validateRequiredFields(context: ButtonActionContext): { isValid: boolean; missingFields: string[] } {
const missingFields: string[] = [];
const { formData, allComponents } = context;
const { formData, allComponents, tableName } = context;
if (!allComponents || allComponents.length === 0) {
console.log("⚠️ [validateRequiredFields] allComponents 없음 - 검증 스킵");
@ -487,18 +490,20 @@ export class ButtonActionExecutor {
}
allComponents.forEach((component: any) => {
// 컴포넌트의 required 속성 확인 (여러 위치에서 체크)
const isRequired =
const columnName = component.columnName || component.style?.columnName;
// 수동 required 속성 + NOT NULL 메타데이터 기반 필수 여부 통합 체크
const manualRequired =
component.required === true ||
component.style?.required === true ||
component.componentConfig?.required === true;
const notNullRequired = isColumnRequiredByMeta(tableName, columnName);
const isRequired = manualRequired || notNullRequired;
const columnName = component.columnName || component.style?.columnName;
const label = component.label || component.style?.label || columnName;
if (isRequired && columnName) {
const value = formData[columnName];
// 값이 비어있는지 확인 (null, undefined, 빈 문자열, 공백만 있는 문자열)
if (value === null || value === undefined || (typeof value === "string" && value.trim() === "")) {
missingFields.push(label || columnName);
}
@ -601,22 +606,15 @@ export class ButtonActionExecutor {
try {
await onSave();
return true;
} catch (error) {
} catch (error: any) {
console.error("❌ [handleSave] onSave 콜백 실행 오류:", error);
throw error;
}
}
// 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용
// 단, _tableSection_ 데이터가 있으면 건너뛰기 (handleUniversalFormModalTableSectionSave가 처리)
// EditModal이 내부에서 직접 repeaterSave 이벤트를 발행하고 완료를 기다림
if (onSave && !hasTableSectionData) {
try {
await onSave();
return true;
} catch (error) {
console.error("❌ [handleSave] onSave 콜백 실행 오류:", error);
throw error;
const msg =
error?.response?.data?.message ||
error?.response?.data?.error?.message ||
error?.message ||
"저장에 실패했습니다.";
toast.error(msg);
return false;
}
}
@ -1737,7 +1735,12 @@ export class ButtonActionExecutor {
}
if (!saveResult.success) {
throw new Error(saveResult.message || "저장에 실패했습니다.");
const errorMsg =
saveResult.message ||
saveResult.error?.message ||
"저장에 실패했습니다.";
toast.error(errorMsg);
return false;
}
// 🔥 저장 성공 후 연결된 제어 실행 (dataflowTiming이 'after'인 경우)
@ -1926,9 +1929,15 @@ export class ButtonActionExecutor {
window.dispatchEvent(new CustomEvent("saveSuccessInModal"));
return true;
} catch (error) {
} catch (error: any) {
console.error("저장 오류:", error);
throw error; // 에러를 다시 던져서 컴포넌트에서 처리하도록 함
const msg =
error?.response?.data?.message ||
error?.response?.data?.error?.message ||
error?.message ||
"저장에 실패했습니다.";
toast.error(msg);
return false;
}
}

View File

@ -5,6 +5,7 @@
import { WebType, DynamicWebType, isValidWebType, normalizeWebType } from "@/types/v2-web-types";
import { ColumnInfo, ComponentData, WidgetComponent } from "@/types/screen";
import { isColumnRequiredByMeta } from "@/lib/registry/DynamicComponentRenderer";
// 검증 결과 타입
export interface ValidationResult {
@ -78,8 +79,8 @@ export const validateFormData = async (
);
}
// 2. 필수 필드 검증
const requiredValidation = validateRequiredFields(formData, components);
// 2. 필수 필드 검증 (NOT NULL 메타데이터 포함)
const requiredValidation = validateRequiredFields(formData, components, tableName);
errors.push(...requiredValidation);
// 3. 데이터 타입 검증 및 변환
@ -192,14 +193,17 @@ export const validateFormSchema = (
export const validateRequiredFields = (
formData: Record<string, any>,
components: ComponentData[],
tableName?: string,
): ValidationError[] => {
const errors: ValidationError[] = [];
const widgetComponents = components.filter((c) => c.type === "widget") as WidgetComponent[];
for (const component of widgetComponents) {
if (!component.required) continue;
const fieldName = component.columnName || component.id;
// 수동 required + NOT NULL 메타데이터 기반 통합 체크
const isRequired = component.required || isColumnRequiredByMeta(tableName, fieldName);
if (!isRequired) continue;
const value = formData[fieldName];
if (