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 { dynamicFormService } from "../services/dynamicFormService";
import { enhancedDynamicFormService } from "../services/enhancedDynamicFormService"; import { enhancedDynamicFormService } from "../services/enhancedDynamicFormService";
import { AuthenticatedRequest } from "../types/auth"; import { AuthenticatedRequest } from "../types/auth";
import { formatPgError } from "../utils/pgErrorUtil";
// 폼 데이터 저장 (기존 버전 - 레거시 지원) // 폼 데이터 저장 (기존 버전 - 레거시 지원)
export const saveFormData = async ( export const saveFormData = async (
@ -68,9 +69,12 @@ export const saveFormData = async (
}); });
} catch (error: any) { } catch (error: any) {
console.error("❌ 폼 데이터 저장 실패:", error); 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, success: false,
message: error.message || "데이터 저장에 실패했습니다.", message: friendlyMsg,
}); });
} }
}; };
@ -118,9 +122,12 @@ export const saveFormDataEnhanced = async (
res.json(result); res.json(result);
} catch (error: any) { } catch (error: any) {
console.error("❌ 개선된 폼 데이터 저장 실패:", error); 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, success: false,
message: error.message || "데이터 저장에 실패했습니다.", message: friendlyMsg,
}); });
} }
}; };
@ -163,9 +170,12 @@ export const updateFormData = async (
}); });
} catch (error: any) { } catch (error: any) {
console.error("❌ 폼 데이터 업데이트 실패:", error); 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, success: false,
message: error.message || "데이터 업데이트에 실패했습니다.", message: friendlyMsg,
}); });
} }
}; };
@ -216,9 +226,12 @@ export const updateFormDataPartial = async (
}); });
} catch (error: any) { } catch (error: any) {
console.error("❌ 부분 업데이트 실패:", error); 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, success: false,
message: error.message || "부분 업데이트에 실패했습니다.", message: friendlyMsg,
}); });
} }
}; };

View File

@ -47,7 +47,10 @@ export const errorHandler = (
error = new AppError("참조 무결성 제약 조건 위반입니다.", 400); error = new AppError("참조 무결성 제약 조건 위반입니다.", 400);
} else if (pgError.code === "23502") { } else if (pgError.code === "23502") {
// not_null_violation // 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")) { } else if (pgError.code.startsWith("23")) {
// 기타 무결성 제약 조건 위반 // 기타 무결성 제약 조건 위반
error = new AppError("데이터 무결성 제약 조건 위반입니다.", 400); error = new AppError("데이터 무결성 제약 조건 위반입니다.", 400);
@ -84,6 +87,7 @@ export const errorHandler = (
// 응답 전송 // 응답 전송
res.status(statusCode).json({ res.status(statusCode).json({
success: false, success: false,
message: message,
error: { error: {
message: message, message: message,
...(process.env.NODE_ENV === "development" && { stack: error.stack }), ...(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) { if (onSave) {
try { try {
await onSave(); await onSave();
} catch (error) { } catch (error: any) {
console.error("저장 오류:", error); console.error("저장 오류:", error);
toast.error("저장 중 오류가 발생했습니다."); const msg =
error?.response?.data?.message ||
error?.message ||
"저장에 실패했습니다.";
toast.error(msg);
} }
return; return;
} }
@ -595,8 +599,12 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
); );
toast.success("데이터가 성공적으로 저장되었습니다."); toast.success("데이터가 성공적으로 저장되었습니다.");
} catch (error) { } catch (error: any) {
toast.error("저장 중 오류가 발생했습니다."); const msg =
error?.response?.data?.message ||
error?.message ||
"저장에 실패했습니다.";
toast.error(msg);
} }
return; return;
} }
@ -656,8 +664,12 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
} else { } else {
toast.error(response.message || "저장에 실패했습니다."); toast.error(response.message || "저장에 실패했습니다.");
} }
} catch (error) { } catch (error: any) {
toast.error("저장 중 오류가 발생했습니다."); const msg =
error?.response?.data?.message ||
error?.message ||
"저장에 실패했습니다.";
toast.error(msg);
} }
}; };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -42,6 +42,7 @@ import {
} from "./types"; } from "./types";
import { defaultConfig, generateUniqueId } from "./config"; import { defaultConfig, generateUniqueId } from "./config";
import { TableSectionRenderer } from "./TableSectionRenderer"; import { TableSectionRenderer } from "./TableSectionRenderer";
import { isColumnRequiredByMeta } from "@/lib/registry/DynamicComponentRenderer";
/** /**
* 🔗 Select * 🔗 Select
@ -1438,15 +1439,17 @@ export function UniversalFormModalComponent({
[linkedFieldDataCache], [linkedFieldDataCache],
); );
// 필수 필드 검증 // 필수 필드 검증 (수동 required + NOT NULL 메타데이터 통합)
const mainTableName = config.saveConfig?.customApiSave?.multiTable?.mainTable?.tableName || config.saveConfig?.tableName;
const validateRequiredFields = useCallback((): { valid: boolean; missingFields: string[] } => { const validateRequiredFields = useCallback((): { valid: boolean; missingFields: string[] } => {
const missingFields: string[] = []; const missingFields: string[] = [];
for (const section of config.sections) { 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 || []) { 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]; const value = formData[field.columnName];
if (value === undefined || value === null || value === "") { if (value === undefined || value === null || value === "") {
missingFields.push(field.label || field.columnName); missingFields.push(field.label || field.columnName);
@ -1456,7 +1459,7 @@ export function UniversalFormModalComponent({
} }
return { valid: missingFields.length === 0, missingFields }; return { valid: missingFields.length === 0, missingFields };
}, [config.sections, formData]); }, [config.sections, formData, mainTableName]);
// 다중 테이블 저장 (범용) // 다중 테이블 저장 (범용)
const saveWithMultiTable = useCallback(async () => { const saveWithMultiTable = useCallback(async () => {
@ -2007,8 +2010,7 @@ export function UniversalFormModalComponent({
return ( return (
<div key={fieldKey} className="space-y-1" style={{ gridColumn: `span ${actualGridSpan}` }}> <div key={fieldKey} className="space-y-1" style={{ gridColumn: `span ${actualGridSpan}` }}>
<Label htmlFor={fieldKey} className="text-sm font-medium"> <Label htmlFor={fieldKey} className="text-sm font-medium">
{field.label} {field.label}{(field.required || isColumnRequiredByMeta(mainTableName, field.columnName)) && <span className="text-orange-500">*</span>}
{field.required && <span className="text-destructive ml-1">*</span>}
{field.numberingRule?.enabled && <span className="text-muted-foreground ml-1 text-xs">()</span>} {field.numberingRule?.enabled && <span className="text-muted-foreground ml-1 text-xs">()</span>}
</Label> </Label>
{fieldElement} {fieldElement}

View File

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

View File

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