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:
parent
43523a0bba
commit
28ef7e1226
|
|
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 }),
|
||||||
|
|
|
||||||
|
|
@ -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 || "데이터 처리 중 오류가 발생했습니다.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
// ❌ 저장 실패 - 모달은 닫히지 않음
|
// ❌ 저장 실패 - 모달은 닫히지 않음
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue