diff --git a/backend-node/src/controllers/dynamicFormController.ts b/backend-node/src/controllers/dynamicFormController.ts index 48b55d18..4ba6013a 100644 --- a/backend-node/src/controllers/dynamicFormController.ts +++ b/backend-node/src/controllers/dynamicFormController.ts @@ -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, }); } }; diff --git a/backend-node/src/middleware/errorHandler.ts b/backend-node/src/middleware/errorHandler.ts index 54d8f0a2..baa5ce38 100644 --- a/backend-node/src/middleware/errorHandler.ts +++ b/backend-node/src/middleware/errorHandler.ts @@ -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 }), diff --git a/backend-node/src/utils/pgErrorUtil.ts b/backend-node/src/utils/pgErrorUtil.ts new file mode 100644 index 00000000..abec8f4e --- /dev/null +++ b/backend-node/src/utils/pgErrorUtil.ts @@ -0,0 +1,50 @@ +import { query } from "../database/db"; + +/** + * PostgreSQL 에러를 사용자 친절한 메시지로 변환 + * table_type_columns의 column_label을 조회하여 한글 라벨로 표시 + */ +export async function formatPgError( + error: any, + companyCode?: string +): Promise { + 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 || "데이터 처리 중 오류가 발생했습니다."; + } +} diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index a22e0646..39d0961c 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -558,9 +558,13 @@ export const InteractiveScreenViewerDynamic: React.FC = ({ }, 300); // 모달 닫힘 애니메이션 후 실행 } } else { - throw new Error(result.message || "저장에 실패했습니다."); + const errorMsg = result.message || result.error?.message || "저장에 실패했습니다."; + toast.error(errorMsg); } } catch (error: any) { // ❌ 저장 실패 - 모달은 닫히지 않음 diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 3012fa4c..feda15eb 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -757,6 +757,8 @@ export const DynamicComponentRenderer: React.FC = 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, diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index dd820cc3..7a2c8cca 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -614,27 +614,16 @@ export const ButtonPrimaryComponent: React.FC = ({ // 실패한 경우 오류 처리 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; diff --git a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx index 153cebdf..95fac92a 100644 --- a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx @@ -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 테이블 */} ) : ( <> - {col.label} - {col.required && *} + {col.label}{col.required && *} )} diff --git a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx index 6765e6c7..421ba5ee 100644 --- a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx +++ b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx @@ -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 (
{isReadOnly && ( diff --git a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx index ff94b8dc..e4e43f13 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx +++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx @@ -43,6 +43,7 @@ const LUCIDE_ICON_MAP: Record = { }; 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 {renderField(field, item.id, group.id, singleEntry.id, singleEntry)}
@@ -1977,8 +1977,7 @@ export const SelectedItemsDetailInputComponent: React.FC {renderField(field, item.id, group.id, entry.id, entry)} @@ -2353,8 +2352,7 @@ export const SelectedItemsDetailInputComponent: React.FC ( - {field.label} - {field.required && *} + {field.label}{(field.required || isColumnRequiredByMeta(componentConfig.targetTable, field.name)) && *} ))} diff --git a/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableComponent.tsx b/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableComponent.tsx index e7917dd9..1fa75819 100644 --- a/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableComponent.tsx +++ b/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableComponent.tsx @@ -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 && *} + {col.label}{(col.required || isColumnRequiredByMeta(componentTargetTable, col.field)) && *} ))} {!readOnly && allowDelete && ( diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx index d0961021..84a090d0 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -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 (
{fieldElement} diff --git a/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx index 3344d38c..829d9cf0 100644 --- a/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx @@ -612,7 +612,7 @@ export const ButtonPrimaryComponent: React.FC = ({ // 실패한 경우 오류 처리 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; } diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 165e796d..0c9b1327 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -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; } } diff --git a/frontend/lib/utils/formValidation.ts b/frontend/lib/utils/formValidation.ts index c521455c..eda79354 100644 --- a/frontend/lib/utils/formValidation.ts +++ b/frontend/lib/utils/formValidation.ts @@ -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, 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 (