jskim-node #410
|
|
@ -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