Compare commits

...

4 Commits

Author SHA1 Message Date
kjs d8067f1d94 Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-03-10 14:47:07 +09:00
kjs 28ef7e1226 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.
2026-03-10 14:47:05 +09:00
kjs 43523a0bba feat: Implement NOT NULL validation for form fields based on table metadata
- Added a new function `isColumnRequired` to determine if a column is required based on its NOT NULL status from the table schema.
- Updated the `SaveModal` and `InteractiveScreenViewer` components to incorporate this validation, ensuring that required fields are accurately assessed during form submission.
- Enhanced the `DynamicComponentRenderer` to reflect the NOT NULL requirement in the component's required state.
- Improved user feedback by marking required fields with an asterisk based on both manual settings and database constraints.

These changes enhance the form validation process, ensuring that users are prompted for all necessary information based on the underlying data structure.
2026-03-10 14:16:02 +09:00
kjs c0eab878a1 refactor: Update table schema retrieval to prioritize company-specific labels
- Modified the `getTableSchema` function in `adminController.ts` to use company-specific column labels when available, falling back to common labels if not.
- Adjusted the SQL query to join `table_type_columns` for both company-specific and common labels, ensuring the correct display order is maintained.
- Removed unnecessary component count display in the `TabsDesignEditor` to streamline the UI.

These changes enhance the accuracy of the table schema representation based on company context and improve the overall user interface by simplifying tab displays.
2026-03-10 11:49:02 +09:00
28 changed files with 331 additions and 180 deletions

View File

@ -3564,6 +3564,7 @@ export async function getTableSchema(
logger.info("테이블 스키마 조회", { tableName, companyCode });
// information_schema와 table_type_columns를 JOIN하여 컬럼 정보와 라벨 정보 함께 가져오기
// 회사별 라벨 우선, 없으면 공통(*) 라벨 사용
const schemaQuery = `
SELECT
ic.column_name,
@ -3573,19 +3574,23 @@ export async function getTableSchema(
ic.character_maximum_length,
ic.numeric_precision,
ic.numeric_scale,
ttc.column_label,
ttc.display_order
COALESCE(ttc_company.column_label, ttc_common.column_label) AS column_label,
COALESCE(ttc_company.display_order, ttc_common.display_order) AS display_order
FROM information_schema.columns ic
LEFT JOIN table_type_columns ttc
ON ttc.table_name = ic.table_name
AND ttc.column_name = ic.column_name
AND ttc.company_code = '*'
LEFT JOIN table_type_columns ttc_common
ON ttc_common.table_name = ic.table_name
AND ttc_common.column_name = ic.column_name
AND ttc_common.company_code = '*'
LEFT JOIN table_type_columns ttc_company
ON ttc_company.table_name = ic.table_name
AND ttc_company.column_name = ic.column_name
AND ttc_company.company_code = $2
WHERE ic.table_schema = 'public'
AND ic.table_name = $1
ORDER BY COALESCE(ttc.display_order, ic.ordinal_position), ic.ordinal_position
ORDER BY COALESCE(ttc_company.display_order, ttc_common.display_order, ic.ordinal_position), ic.ordinal_position
`;
const columns = await query<any>(schemaQuery, [tableName]);
const columns = await query<any>(schemaQuery, [tableName, companyCode]);
if (columns.length === 0) {
res.status(404).json({

View File

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

View File

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

View File

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

View File

@ -245,6 +245,17 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
// 통합된 폼 데이터
const finalFormData = { ...localFormData, ...externalFormData };
// 테이블 타입관리 NOT NULL 기반 필수 여부 판단
const isColumnRequired = useCallback((columnName: string): boolean => {
if (!columnName || tableColumns.length === 0) return false;
const colInfo = tableColumns.find(
(c: any) => (c.columnName || c.column_name || "").toLowerCase() === columnName.toLowerCase()
);
if (!colInfo) return false;
const nullable = (colInfo as any).isNullable || (colInfo as any).is_nullable;
return nullable === "NO" || nullable === "N";
}, [tableColumns]);
// 🆕 조건부 레이어 로직 (formData 변경 시 자동 평가)
useEffect(() => {
layers.forEach((layer) => {
@ -1612,8 +1623,11 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
return;
}
// 필수 항목 검증
const requiredFields = allComponents.filter(c => c.required && (c.columnName || c.id));
// 필수 항목 검증 (테이블 타입관리 NOT NULL 기반 + 기존 required 속성 폴백)
const requiredFields = allComponents.filter(c => {
const colName = c.columnName || c.id;
return (c.required || isColumnRequired(colName)) && colName;
});
const missingFields = requiredFields.filter(field => {
const fieldName = field.columnName || field.id;
const value = currentFormData[fieldName];
@ -2486,7 +2500,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
style={labelStyle}
>
{labelText}
{(component.required || component.componentConfig?.required) && <span className="ml-1 text-destructive">*</span>}
{(component.required || component.componentConfig?.required || isColumnRequired(component.columnName || component.style?.columnName || "")) && <span className="ml-1 text-destructive">*</span>}
</label>
)}
@ -2505,7 +2519,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
}}
>
{labelText}
{(component.required || component.componentConfig?.required) && <span className="ml-1 text-destructive">*</span>}
{(component.required || component.componentConfig?.required || isColumnRequired(component.columnName || component.style?.columnName || "")) && <span className="ml-1 text-destructive">*</span>}
</label>
)}
</div>

View File

@ -11,7 +11,7 @@ import { ComponentData, WidgetComponent, DataTableComponent, FileComponent, Butt
import { FileUploadComponent } from "@/lib/registry/components/file-upload/FileUploadComponent";
import { InteractiveDataTable } from "./InteractiveDataTable";
import { DynamicWebTypeRenderer } from "@/lib/registry";
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
import { DynamicComponentRenderer, isColumnRequiredByMeta } from "@/lib/registry/DynamicComponentRenderer";
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
import { isFileComponent, isDataTableComponent, isButtonComponent } from "@/lib/utils/componentTypeUtils";
import { FlowButtonGroup } from "./widgets/FlowButtonGroup";
@ -558,9 +558,13 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
if (onSave) {
try {
await onSave();
} catch (error) {
} catch (error: any) {
console.error("저장 오류:", error);
toast.error("저장 중 오류가 발생했습니다.");
const msg =
error?.response?.data?.message ||
error?.message ||
"저장에 실패했습니다.";
toast.error(msg);
}
return;
}
@ -595,8 +599,12 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
);
toast.success("데이터가 성공적으로 저장되었습니다.");
} catch (error) {
toast.error("저장 중 오류가 발생했습니다.");
} catch (error: any) {
const msg =
error?.response?.data?.message ||
error?.message ||
"저장에 실패했습니다.";
toast.error(msg);
}
return;
}
@ -656,8 +664,12 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
} else {
toast.error(response.message || "저장에 실패했습니다.");
}
} catch (error) {
toast.error("저장 중 오류가 발생했습니다.");
} catch (error: any) {
const msg =
error?.response?.data?.message ||
error?.message ||
"저장에 실패했습니다.";
toast.error(msg);
}
};
@ -1294,9 +1306,8 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
...(labelPos === "bottom" ? { marginTop: style?.labelMarginBottom || "4px" } : {}),
}}
>
{labelText}
{((component as any).required || (component as any).componentConfig?.required) && (
<span className="ml-1 text-destructive">*</span>
{labelText}{((component as any).required || (component as any).componentConfig?.required || isColumnRequiredByMeta((component as any).tableName, (component as any).columnName)) && (
<span className="text-orange-500">*</span>
)}
</label>
) : null;
@ -1349,9 +1360,8 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
whiteSpace: "nowrap",
}}
>
{labelText}
{((component as any).required || (component as any).componentConfig?.required) && (
<span className="ml-1 text-destructive">*</span>
{labelText}{((component as any).required || (component as any).componentConfig?.required || isColumnRequiredByMeta((component as any).tableName, (component as any).columnName)) && (
<span className="text-orange-500">*</span>
)}
</label>
<div style={{ width: "100%", height: "100%" }}>

View File

@ -10,6 +10,7 @@ import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRendere
import { InteractiveScreenViewer } from "./InteractiveScreenViewer";
import { screenApi } from "@/lib/api/screen";
import { dynamicFormApi, DynamicFormData } from "@/lib/api/dynamicForm";
import { getTableColumns, ColumnTypeInfo } from "@/lib/api/tableManagement";
import { ComponentData } from "@/lib/types/screen";
import { useAuth } from "@/hooks/useAuth";
@ -42,6 +43,7 @@ export const SaveModal: React.FC<SaveModalProps> = ({
const [originalData, setOriginalData] = useState<Record<string, any>>(initialData || {});
const [screenData, setScreenData] = useState<any>(null);
const [components, setComponents] = useState<ComponentData[]>([]);
const [tableColumnsInfo, setTableColumnsInfo] = useState<ColumnTypeInfo[]>([]);
const [loading, setLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
@ -70,6 +72,19 @@ export const SaveModal: React.FC<SaveModalProps> = ({
const layout = await screenApi.getLayout(screenId);
setComponents(layout.components || []);
// 테이블 컬럼 정보 로드 (NOT NULL 필수값 자동 인식용)
const tblName = screen?.tableName || layout.components?.find((c: any) => c.columnName)?.tableName;
if (tblName) {
try {
const colResult = await getTableColumns(tblName);
if (colResult.success && colResult.data?.columns) {
setTableColumnsInfo(colResult.data.columns);
}
} catch (colErr) {
console.warn("테이블 컬럼 정보 로드 실패 (필수값 검증 시 기존 방식 사용):", colErr);
}
}
// initialData가 있으면 폼에 채우기
if (initialData) {
setFormData(initialData);
@ -106,34 +121,39 @@ export const SaveModal: React.FC<SaveModalProps> = ({
};
}, [onClose]);
// 필수 항목 검증
// 테이블 타입관리의 NOT NULL 설정 기반으로 필수 여부 판단
const isColumnRequired = (columnName: string): boolean => {
if (!columnName || tableColumnsInfo.length === 0) return false;
const colInfo = tableColumnsInfo.find(
(c) => c.columnName.toLowerCase() === columnName.toLowerCase()
);
if (!colInfo) return false;
// is_nullable가 "NO"이면 필수
return colInfo.isNullable === "NO" || colInfo.isNullable === "N";
};
// 필수 항목 검증 (테이블 타입관리 NOT NULL + 기존 required 속성 병합)
const validateRequiredFields = (): { isValid: boolean; missingFields: string[] } => {
const missingFields: string[] = [];
components.forEach((component) => {
// 컴포넌트의 required 속성 확인 (여러 위치에서 체크)
const isRequired =
component.required === true ||
component.style?.required === true ||
component.componentConfig?.required === true;
const columnName = component.columnName || component.style?.columnName;
const label = component.label || component.style?.label || columnName;
console.log("🔍 필수 항목 검증:", {
componentId: component.id,
columnName,
label,
isRequired,
"component.required": component.required,
"style.required": component.style?.required,
"componentConfig.required": component.componentConfig?.required,
value: formData[columnName || ""],
});
// 기존 required 속성 (화면 디자이너에서 수동 설정한 것)
const manualRequired =
component.required === true ||
component.style?.required === true ||
component.componentConfig?.required === true;
// 테이블 타입관리 NOT NULL 기반 필수 (컬럼 정보가 있을 때만)
const notNullRequired = columnName ? isColumnRequired(columnName) : false;
// 둘 중 하나라도 필수이면 검증
const isRequired = manualRequired || notNullRequired;
if (isRequired && columnName) {
const value = formData[columnName];
// 값이 비어있는지 확인 (null, undefined, 빈 문자열, 공백만 있는 문자열)
if (value === null || value === undefined || (typeof value === "string" && value.trim() === "")) {
missingFields.push(label || columnName);
}
@ -262,7 +282,8 @@ export const SaveModal: React.FC<SaveModalProps> = ({
}, 300); // 모달 닫힘 애니메이션 후 실행
}
} else {
throw new Error(result.message || "저장에 실패했습니다.");
const errorMsg = result.message || result.error?.message || "저장에 실패했습니다.";
toast.error(errorMsg);
}
} catch (error: any) {
// ❌ 저장 실패 - 모달은 닫히지 않음
@ -405,7 +426,8 @@ export const SaveModal: React.FC<SaveModalProps> = ({
}));
}}
hideLabel={false}
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달 (카테고리 스코프용)
menuObjid={menuObjid}
tableColumns={tableColumnsInfo as any}
/>
) : (
<DynamicComponentRenderer

View File

@ -943,19 +943,31 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
{/* 옵션 - 입력 필드에서는 항상 표시, 기타 컴포넌트는 속성이 정의된 경우만 표시 */}
<div className="grid grid-cols-2 gap-2">
{(isInputField || widget.required !== undefined) && (
<div className="flex items-center space-x-2">
<Checkbox
checked={widget.required === true || selectedComponent.componentConfig?.required === true}
onCheckedChange={(checked) => {
handleUpdate("required", checked);
handleUpdate("componentConfig.required", checked);
}}
className="h-4 w-4"
/>
<Label className="text-xs"></Label>
</div>
)}
{(isInputField || widget.required !== undefined) && (() => {
const colName = widget.columnName || selectedComponent?.columnName;
const colMeta = colName ? currentTable?.columns?.find(
(c: any) => (c.columnName || c.column_name || "").toLowerCase() === colName.toLowerCase()
) : null;
const isNotNull = colMeta && ((colMeta as any).isNullable === "NO" || (colMeta as any).isNullable === "N" || (colMeta as any).is_nullable === "NO" || (colMeta as any).is_nullable === "N");
return (
<div className="flex items-center space-x-2">
<Checkbox
checked={isNotNull || widget.required === true || selectedComponent.componentConfig?.required === true}
onCheckedChange={(checked) => {
if (isNotNull) return;
handleUpdate("required", checked);
handleUpdate("componentConfig.required", checked);
}}
disabled={!!isNotNull}
className="h-4 w-4"
/>
<Label className="text-xs">
{isNotNull && <span className="text-muted-foreground ml-1">(NOT NULL)</span>}
</Label>
</div>
);
})()}
{(isInputField || widget.readonly !== undefined) && (
<div className="flex items-center space-x-2">
<Checkbox

View File

@ -724,8 +724,7 @@ export const V2Date = forwardRef<HTMLDivElement, V2DateProps>((props, ref) => {
}}
className="text-sm font-medium whitespace-nowrap"
>
{label}
{required && <span className="ml-0.5 text-orange-500">*</span>}
{label}{required && <span className="text-orange-500">*</span>}
</Label>
) : null;

View File

@ -491,8 +491,7 @@ export const V2Hierarchy = forwardRef<HTMLDivElement, V2HierarchyProps>(
}}
className="text-sm font-medium whitespace-nowrap"
>
{label}
{required && <span className="text-orange-500 ml-0.5">*</span>}
{label}{required && <span className="text-orange-500">*</span>}
</Label>
)}
<div className="h-full w-full">

View File

@ -994,8 +994,7 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
}}
className="text-sm font-medium whitespace-nowrap"
>
{actualLabel}
{required && <span className="ml-0.5 text-orange-500">*</span>}
{actualLabel}{required && <span className="text-orange-500">*</span>}
</Label>
) : null;

View File

@ -840,8 +840,7 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>((props, ref) =>
}}
className="shrink-0 text-sm font-medium"
>
{label}
{required && <span className="ml-0.5 text-orange-500">*</span>}
{label}{required && <span className="text-orange-500">*</span>}
</Label>
)}

View File

@ -1181,8 +1181,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
}}
className="text-sm font-medium whitespace-nowrap"
>
{label}
{required && <span className="text-orange-500 ml-0.5">*</span>}
{label}{required && <span className="text-orange-500">*</span>}
</Label>
) : null;

View File

@ -15,7 +15,13 @@ const columnMetaCache: Record<string, Record<string, any>> = {};
const columnMetaLoading: Record<string, Promise<void>> = {};
async function loadColumnMeta(tableName: string): Promise<void> {
if (columnMetaCache[tableName] || columnMetaLoading[tableName]) return;
if (columnMetaCache[tableName]) return;
// 이미 로딩 중이면 해당 Promise를 대기 (race condition 방지)
if (columnMetaLoading[tableName]) {
await columnMetaLoading[tableName];
return;
}
columnMetaLoading[tableName] = (async () => {
try {
@ -28,7 +34,8 @@ async function loadColumnMeta(tableName: string): Promise<void> {
if (name) map[name] = col;
}
columnMetaCache[tableName] = map;
} catch {
} catch (e) {
console.error(`[columnMeta] ${tableName} 로드 실패:`, e);
columnMetaCache[tableName] = {};
} finally {
delete columnMetaLoading[tableName];
@ -38,6 +45,15 @@ async function loadColumnMeta(tableName: string): Promise<void> {
await columnMetaLoading[tableName];
}
// 테이블 타입관리 NOT NULL 기반 필수 여부 판단
export function isColumnRequiredByMeta(tableName?: string, columnName?: string): boolean {
if (!tableName || !columnName) return false;
const meta = columnMetaCache[tableName]?.[columnName];
if (!meta) return false;
const nullable = meta.is_nullable || meta.isNullable;
return nullable === "NO" || nullable === "N";
}
// table_type_columns 기반 componentConfig 병합 (기존 설정이 없을 때만 DB 메타데이터로 보완)
function mergeColumnMeta(tableName: string | undefined, columnName: string | undefined, componentConfig: any): any {
if (!tableName || !columnName) return componentConfig;
@ -249,7 +265,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
const screenTableName = props.tableName || (component as any).tableName;
const [, forceUpdate] = React.useState(0);
React.useEffect(() => {
if (screenTableName && !columnMetaCache[screenTableName]) {
if (screenTableName) {
loadColumnMeta(screenTableName).then(() => forceUpdate((v) => v + 1));
}
}, [screenTableName]);
@ -435,7 +451,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
const labelFontSize = component.style?.labelFontSize || "14px";
const labelColor = component.style?.labelColor || "#64748b";
const labelFontWeight = component.style?.labelFontWeight || "500";
const isRequired = component.required || (component as any).required;
const isRequired = component.required || (component as any).required || isColumnRequiredByMeta(tableName, columnName);
const isLeft = catLabelPosition === "left";
return (
<div style={{ position: "relative", width: "100%", height: "100%" }}>
@ -454,8 +470,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
}}
className="text-sm font-medium"
>
{catLabelText}
{isRequired && <span className="text-orange-500 ml-0.5">*</span>}
{catLabelText}{isRequired && <span className="text-orange-500">*</span>}
</label>
<div style={{ width: "100%", height: "100%" }}>
{renderedCatSelect}
@ -715,10 +730,14 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
const baseColumnName = isEntityJoinColumn ? undefined : fieldName;
const mergedComponentConfig = mergeColumnMeta(screenTableName, baseColumnName, component.componentConfig || {});
// NOT NULL 기반 필수 여부를 component.required에 반영
const notNullRequired = isColumnRequiredByMeta(screenTableName, baseColumnName);
const effectiveRequired = component.required || notNullRequired;
// 엔티티 조인 컬럼은 런타임에서 readonly/disabled 강제 해제
const effectiveComponent = isEntityJoinColumn
? { ...component, componentConfig: mergedComponentConfig, readonly: false }
: { ...component, componentConfig: mergedComponentConfig };
? { ...component, componentConfig: mergedComponentConfig, readonly: false, required: effectiveRequired }
: { ...component, componentConfig: mergedComponentConfig, required: effectiveRequired };
const rendererProps = {
component: effectiveComponent,
@ -738,6 +757,8 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
style: mergedStyle,
// 수평 라벨 → 외부에서 처리하므로 label 전달 안 함
label: needsExternalHorizLabel ? undefined : effectiveLabel,
// NOT NULL 메타데이터 포함된 필수 여부 (V2Hierarchy 등 직접 props.required 참조하는 컴포넌트용)
required: effectiveRequired,
// 🆕 V2 레이아웃에서 overrides에서 복원된 상위 레벨 속성들도 전달 (DB 메타데이터 우선)
inputType: (baseColumnName && columnMetaCache[screenTableName || ""]?.[baseColumnName]?.input_type) || (component as any).inputType || mergedComponentConfig?.inputType,
columnName: (component as any).columnName || component.componentConfig?.columnName,
@ -852,7 +873,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
const labelFontSize = component.style?.labelFontSize || "14px";
const labelColor = component.style?.labelColor || "#64748b";
const labelFontWeight = component.style?.labelFontWeight || "500";
const isRequired = component.required || (component as any).required;
const isRequired = effectiveComponent.required || isColumnRequiredByMeta(screenTableName, baseColumnName);
const isLeft = labelPosition === "left";
return (
@ -872,8 +893,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
}}
className="text-sm font-medium"
>
{effectiveLabel}
{isRequired && <span className="text-orange-500 ml-0.5">*</span>}
{effectiveLabel}{isRequired && <span className="text-orange-500">*</span>}
</label>
<div style={{ width: "100%", height: "100%" }}>
{renderedElement}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,6 +4,7 @@ import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { V2DateDefinition } from "./index";
import { V2Date } from "@/components/v2/V2Date";
import { isColumnRequiredByMeta } from "../../DynamicComponentRenderer";
/**
* V2Date
@ -18,6 +19,7 @@ export class V2DateRenderer extends AutoRegisteringComponentRenderer {
// 컴포넌트 설정 추출
const config = component.componentConfig || component.config || {};
const columnName = component.columnName;
const tableName = component.tableName || this.props.tableName;
// formData에서 현재 값 가져오기
const currentValue = formData?.[columnName] ?? component.value ?? "";
@ -37,10 +39,6 @@ export class V2DateRenderer extends AutoRegisteringComponentRenderer {
return (
<V2Date
id={component.id}
label={effectiveLabel}
required={component.required}
readonly={config.readonly || component.readonly}
disabled={config.disabled || component.disabled}
value={currentValue}
onChange={handleChange}
config={{
@ -55,6 +53,10 @@ export class V2DateRenderer extends AutoRegisteringComponentRenderer {
style={component.style}
size={component.size}
{...restProps}
label={effectiveLabel}
required={component.required || isColumnRequiredByMeta(tableName, columnName)}
readonly={config.readonly || component.readonly}
disabled={config.disabled || component.disabled}
/>
);
}

View File

@ -4,6 +4,7 @@ import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { V2InputDefinition } from "./index";
import { V2Input } from "@/components/v2/V2Input";
import { isColumnRequiredByMeta } from "../../DynamicComponentRenderer";
/**
* V2Input
@ -52,10 +53,6 @@ export class V2InputRenderer extends AutoRegisteringComponentRenderer {
return (
<V2Input
id={component.id}
label={effectiveLabel}
required={component.required}
readonly={config.readonly || component.readonly}
disabled={config.disabled || component.disabled}
value={currentValue}
onChange={handleChange}
onFormDataChange={onFormDataChange}
@ -78,6 +75,10 @@ export class V2InputRenderer extends AutoRegisteringComponentRenderer {
autoGeneration={config.autoGeneration || component.autoGeneration}
originalData={(this.props as any).originalData}
{...restProps}
label={effectiveLabel}
required={component.required || isColumnRequiredByMeta(tableName, columnName)}
readonly={config.readonly || component.readonly}
disabled={config.disabled || component.disabled}
/>
);
}

View File

@ -4,6 +4,7 @@ import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { V2SelectDefinition } from "./index";
import { V2Select } from "@/components/v2/V2Select";
import { isColumnRequiredByMeta } from "../../DynamicComponentRenderer";
/**
* V2Select
@ -112,10 +113,6 @@ export class V2SelectRenderer extends AutoRegisteringComponentRenderer {
return (
<V2Select
id={component.id}
label={component.label}
required={component.required}
readonly={config.readonly || component.readonly}
disabled={config.disabled || component.disabled}
value={currentValue}
onChange={handleChange}
onFormDataChange={isInteractive ? onFormDataChange : undefined}
@ -141,6 +138,10 @@ export class V2SelectRenderer extends AutoRegisteringComponentRenderer {
{...restPropsClean}
style={effectiveStyle}
size={effectiveSize}
label={component.label}
required={component.required || isColumnRequiredByMeta(tableName, columnName)}
readonly={config.readonly || component.readonly}
disabled={config.disabled || component.disabled}
/>
);
}

View File

@ -295,11 +295,6 @@ const TabsDesignEditor: React.FC<{
}}
>
{tab.label || "탭"}
{tab.components && tab.components.length > 0 && (
<span className="ml-1 text-xs text-muted-foreground">
({tab.components.length})
</span>
)}
</div>
))
) : (
@ -649,11 +644,6 @@ ComponentRegistry.registerComponent({
}}
>
{tab.label || "탭"}
{tab.components && tab.components.length > 0 && (
<span className="ml-1 text-xs text-muted-foreground">
({tab.components.length})
</span>
)}
</div>
))
) : (

View File

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

View File

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