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

View File

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

View File

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

View File

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

View File

@ -245,6 +245,17 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
// 통합된 폼 데이터 // 통합된 폼 데이터
const finalFormData = { ...localFormData, ...externalFormData }; 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 변경 시 자동 평가) // 🆕 조건부 레이어 로직 (formData 변경 시 자동 평가)
useEffect(() => { useEffect(() => {
layers.forEach((layer) => { layers.forEach((layer) => {
@ -1612,8 +1623,11 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
return; return;
} }
// 필수 항목 검증 // 필수 항목 검증 (테이블 타입관리 NOT NULL 기반 + 기존 required 속성 폴백)
const requiredFields = allComponents.filter(c => c.required && (c.columnName || c.id)); const requiredFields = allComponents.filter(c => {
const colName = c.columnName || c.id;
return (c.required || isColumnRequired(colName)) && colName;
});
const missingFields = requiredFields.filter(field => { const missingFields = requiredFields.filter(field => {
const fieldName = field.columnName || field.id; const fieldName = field.columnName || field.id;
const value = currentFormData[fieldName]; const value = currentFormData[fieldName];
@ -2486,7 +2500,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
style={labelStyle} style={labelStyle}
> >
{labelText} {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> </label>
)} )}
@ -2505,7 +2519,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
}} }}
> >
{labelText} {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> </label>
)} )}
</div> </div>

View File

@ -11,7 +11,7 @@ import { ComponentData, WidgetComponent, DataTableComponent, FileComponent, Butt
import { FileUploadComponent } from "@/lib/registry/components/file-upload/FileUploadComponent"; import { FileUploadComponent } from "@/lib/registry/components/file-upload/FileUploadComponent";
import { InteractiveDataTable } from "./InteractiveDataTable"; import { InteractiveDataTable } from "./InteractiveDataTable";
import { DynamicWebTypeRenderer } from "@/lib/registry"; 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 { filterDOMProps } from "@/lib/utils/domPropsFilter";
import { isFileComponent, isDataTableComponent, isButtonComponent } from "@/lib/utils/componentTypeUtils"; import { isFileComponent, isDataTableComponent, isButtonComponent } from "@/lib/utils/componentTypeUtils";
import { FlowButtonGroup } from "./widgets/FlowButtonGroup"; import { FlowButtonGroup } from "./widgets/FlowButtonGroup";
@ -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);
} }
}; };
@ -1294,9 +1306,8 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
...(labelPos === "bottom" ? { marginTop: style?.labelMarginBottom || "4px" } : {}), ...(labelPos === "bottom" ? { marginTop: style?.labelMarginBottom || "4px" } : {}),
}} }}
> >
{labelText} {labelText}{((component as any).required || (component as any).componentConfig?.required || isColumnRequiredByMeta((component as any).tableName, (component as any).columnName)) && (
{((component as any).required || (component as any).componentConfig?.required) && ( <span className="text-orange-500">*</span>
<span className="ml-1 text-destructive">*</span>
)} )}
</label> </label>
) : null; ) : null;
@ -1349,9 +1360,8 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
whiteSpace: "nowrap", whiteSpace: "nowrap",
}} }}
> >
{labelText} {labelText}{((component as any).required || (component as any).componentConfig?.required || isColumnRequiredByMeta((component as any).tableName, (component as any).columnName)) && (
{((component as any).required || (component as any).componentConfig?.required) && ( <span className="text-orange-500">*</span>
<span className="ml-1 text-destructive">*</span>
)} )}
</label> </label>
<div style={{ width: "100%", height: "100%" }}> <div style={{ width: "100%", height: "100%" }}>

View File

@ -10,6 +10,7 @@ import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRendere
import { InteractiveScreenViewer } from "./InteractiveScreenViewer"; import { InteractiveScreenViewer } from "./InteractiveScreenViewer";
import { screenApi } from "@/lib/api/screen"; import { screenApi } from "@/lib/api/screen";
import { dynamicFormApi, DynamicFormData } from "@/lib/api/dynamicForm"; import { dynamicFormApi, DynamicFormData } from "@/lib/api/dynamicForm";
import { getTableColumns, ColumnTypeInfo } from "@/lib/api/tableManagement";
import { ComponentData } from "@/lib/types/screen"; import { ComponentData } from "@/lib/types/screen";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
@ -42,6 +43,7 @@ export const SaveModal: React.FC<SaveModalProps> = ({
const [originalData, setOriginalData] = useState<Record<string, any>>(initialData || {}); const [originalData, setOriginalData] = useState<Record<string, any>>(initialData || {});
const [screenData, setScreenData] = useState<any>(null); const [screenData, setScreenData] = useState<any>(null);
const [components, setComponents] = useState<ComponentData[]>([]); const [components, setComponents] = useState<ComponentData[]>([]);
const [tableColumnsInfo, setTableColumnsInfo] = useState<ColumnTypeInfo[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
@ -70,6 +72,19 @@ export const SaveModal: React.FC<SaveModalProps> = ({
const layout = await screenApi.getLayout(screenId); const layout = await screenApi.getLayout(screenId);
setComponents(layout.components || []); 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가 있으면 폼에 채우기 // initialData가 있으면 폼에 채우기
if (initialData) { if (initialData) {
setFormData(initialData); setFormData(initialData);
@ -106,34 +121,39 @@ export const SaveModal: React.FC<SaveModalProps> = ({
}; };
}, [onClose]); }, [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 validateRequiredFields = (): { isValid: boolean; missingFields: string[] } => {
const missingFields: string[] = []; const missingFields: string[] = [];
components.forEach((component) => { components.forEach((component) => {
// 컴포넌트의 required 속성 확인 (여러 위치에서 체크) const columnName = component.columnName || component.style?.columnName;
const isRequired = const label = component.label || component.style?.label || columnName;
// 기존 required 속성 (화면 디자이너에서 수동 설정한 것)
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 columnName = component.columnName || component.style?.columnName; // 테이블 타입관리 NOT NULL 기반 필수 (컬럼 정보가 있을 때만)
const label = component.label || component.style?.label || columnName; const notNullRequired = columnName ? isColumnRequired(columnName) : false;
console.log("🔍 필수 항목 검증:", { // 둘 중 하나라도 필수이면 검증
componentId: component.id, const isRequired = manualRequired || notNullRequired;
columnName,
label,
isRequired,
"component.required": component.required,
"style.required": component.style?.required,
"componentConfig.required": component.componentConfig?.required,
value: formData[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);
} }
@ -262,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) {
// ❌ 저장 실패 - 모달은 닫히지 않음 // ❌ 저장 실패 - 모달은 닫히지 않음
@ -405,7 +426,8 @@ export const SaveModal: React.FC<SaveModalProps> = ({
})); }));
}} }}
hideLabel={false} hideLabel={false}
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달 (카테고리 스코프용) menuObjid={menuObjid}
tableColumns={tableColumnsInfo as any}
/> />
) : ( ) : (
<DynamicComponentRenderer <DynamicComponentRenderer

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -15,7 +15,13 @@ const columnMetaCache: Record<string, Record<string, any>> = {};
const columnMetaLoading: Record<string, Promise<void>> = {}; const columnMetaLoading: Record<string, Promise<void>> = {};
async function loadColumnMeta(tableName: 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 () => { columnMetaLoading[tableName] = (async () => {
try { try {
@ -28,7 +34,8 @@ async function loadColumnMeta(tableName: string): Promise<void> {
if (name) map[name] = col; if (name) map[name] = col;
} }
columnMetaCache[tableName] = map; columnMetaCache[tableName] = map;
} catch { } catch (e) {
console.error(`[columnMeta] ${tableName} 로드 실패:`, e);
columnMetaCache[tableName] = {}; columnMetaCache[tableName] = {};
} finally { } finally {
delete columnMetaLoading[tableName]; delete columnMetaLoading[tableName];
@ -38,6 +45,15 @@ async function loadColumnMeta(tableName: string): Promise<void> {
await columnMetaLoading[tableName]; 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 메타데이터로 보완) // table_type_columns 기반 componentConfig 병합 (기존 설정이 없을 때만 DB 메타데이터로 보완)
function mergeColumnMeta(tableName: string | undefined, columnName: string | undefined, componentConfig: any): any { function mergeColumnMeta(tableName: string | undefined, columnName: string | undefined, componentConfig: any): any {
if (!tableName || !columnName) return componentConfig; if (!tableName || !columnName) return componentConfig;
@ -249,7 +265,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
const screenTableName = props.tableName || (component as any).tableName; const screenTableName = props.tableName || (component as any).tableName;
const [, forceUpdate] = React.useState(0); const [, forceUpdate] = React.useState(0);
React.useEffect(() => { React.useEffect(() => {
if (screenTableName && !columnMetaCache[screenTableName]) { if (screenTableName) {
loadColumnMeta(screenTableName).then(() => forceUpdate((v) => v + 1)); loadColumnMeta(screenTableName).then(() => forceUpdate((v) => v + 1));
} }
}, [screenTableName]); }, [screenTableName]);
@ -435,7 +451,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
const labelFontSize = component.style?.labelFontSize || "14px"; const labelFontSize = component.style?.labelFontSize || "14px";
const labelColor = component.style?.labelColor || "#64748b"; const labelColor = component.style?.labelColor || "#64748b";
const labelFontWeight = component.style?.labelFontWeight || "500"; 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"; const isLeft = catLabelPosition === "left";
return ( return (
<div style={{ position: "relative", width: "100%", height: "100%" }}> <div style={{ position: "relative", width: "100%", height: "100%" }}>
@ -454,8 +470,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
}} }}
className="text-sm font-medium" className="text-sm font-medium"
> >
{catLabelText} {catLabelText}{isRequired && <span className="text-orange-500">*</span>}
{isRequired && <span className="text-orange-500 ml-0.5">*</span>}
</label> </label>
<div style={{ width: "100%", height: "100%" }}> <div style={{ width: "100%", height: "100%" }}>
{renderedCatSelect} {renderedCatSelect}
@ -715,10 +730,14 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
const baseColumnName = isEntityJoinColumn ? undefined : fieldName; const baseColumnName = isEntityJoinColumn ? undefined : fieldName;
const mergedComponentConfig = mergeColumnMeta(screenTableName, baseColumnName, component.componentConfig || {}); const mergedComponentConfig = mergeColumnMeta(screenTableName, baseColumnName, component.componentConfig || {});
// NOT NULL 기반 필수 여부를 component.required에 반영
const notNullRequired = isColumnRequiredByMeta(screenTableName, baseColumnName);
const effectiveRequired = component.required || notNullRequired;
// 엔티티 조인 컬럼은 런타임에서 readonly/disabled 강제 해제 // 엔티티 조인 컬럼은 런타임에서 readonly/disabled 강제 해제
const effectiveComponent = isEntityJoinColumn const effectiveComponent = isEntityJoinColumn
? { ...component, componentConfig: mergedComponentConfig, readonly: false } ? { ...component, componentConfig: mergedComponentConfig, readonly: false, required: effectiveRequired }
: { ...component, componentConfig: mergedComponentConfig }; : { ...component, componentConfig: mergedComponentConfig, required: effectiveRequired };
const rendererProps = { const rendererProps = {
component: effectiveComponent, component: effectiveComponent,
@ -738,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,
@ -852,7 +873,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
const labelFontSize = component.style?.labelFontSize || "14px"; const labelFontSize = component.style?.labelFontSize || "14px";
const labelColor = component.style?.labelColor || "#64748b"; const labelColor = component.style?.labelColor || "#64748b";
const labelFontWeight = component.style?.labelFontWeight || "500"; 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"; const isLeft = labelPosition === "left";
return ( return (
@ -872,8 +893,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
}} }}
className="text-sm font-medium" className="text-sm font-medium"
> >
{effectiveLabel} {effectiveLabel}{isRequired && <span className="text-orange-500">*</span>}
{isRequired && <span className="text-orange-500 ml-0.5">*</span>}
</label> </label>
<div style={{ width: "100%", height: "100%" }}> <div style={{ width: "100%", height: "100%" }}>
{renderedElement} {renderedElement}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,6 +4,7 @@ import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { V2DateDefinition } from "./index"; import { V2DateDefinition } from "./index";
import { V2Date } from "@/components/v2/V2Date"; import { V2Date } from "@/components/v2/V2Date";
import { isColumnRequiredByMeta } from "../../DynamicComponentRenderer";
/** /**
* V2Date * V2Date
@ -18,6 +19,7 @@ export class V2DateRenderer extends AutoRegisteringComponentRenderer {
// 컴포넌트 설정 추출 // 컴포넌트 설정 추출
const config = component.componentConfig || component.config || {}; const config = component.componentConfig || component.config || {};
const columnName = component.columnName; const columnName = component.columnName;
const tableName = component.tableName || this.props.tableName;
// formData에서 현재 값 가져오기 // formData에서 현재 값 가져오기
const currentValue = formData?.[columnName] ?? component.value ?? ""; const currentValue = formData?.[columnName] ?? component.value ?? "";
@ -37,10 +39,6 @@ export class V2DateRenderer extends AutoRegisteringComponentRenderer {
return ( return (
<V2Date <V2Date
id={component.id} id={component.id}
label={effectiveLabel}
required={component.required}
readonly={config.readonly || component.readonly}
disabled={config.disabled || component.disabled}
value={currentValue} value={currentValue}
onChange={handleChange} onChange={handleChange}
config={{ config={{
@ -55,6 +53,10 @@ export class V2DateRenderer extends AutoRegisteringComponentRenderer {
style={component.style} style={component.style}
size={component.size} size={component.size}
{...restProps} {...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 { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { V2InputDefinition } from "./index"; import { V2InputDefinition } from "./index";
import { V2Input } from "@/components/v2/V2Input"; import { V2Input } from "@/components/v2/V2Input";
import { isColumnRequiredByMeta } from "../../DynamicComponentRenderer";
/** /**
* V2Input * V2Input
@ -52,10 +53,6 @@ export class V2InputRenderer extends AutoRegisteringComponentRenderer {
return ( return (
<V2Input <V2Input
id={component.id} id={component.id}
label={effectiveLabel}
required={component.required}
readonly={config.readonly || component.readonly}
disabled={config.disabled || component.disabled}
value={currentValue} value={currentValue}
onChange={handleChange} onChange={handleChange}
onFormDataChange={onFormDataChange} onFormDataChange={onFormDataChange}
@ -78,6 +75,10 @@ export class V2InputRenderer extends AutoRegisteringComponentRenderer {
autoGeneration={config.autoGeneration || component.autoGeneration} autoGeneration={config.autoGeneration || component.autoGeneration}
originalData={(this.props as any).originalData} originalData={(this.props as any).originalData}
{...restProps} {...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 { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { V2SelectDefinition } from "./index"; import { V2SelectDefinition } from "./index";
import { V2Select } from "@/components/v2/V2Select"; import { V2Select } from "@/components/v2/V2Select";
import { isColumnRequiredByMeta } from "../../DynamicComponentRenderer";
/** /**
* V2Select * V2Select
@ -112,10 +113,6 @@ export class V2SelectRenderer extends AutoRegisteringComponentRenderer {
return ( return (
<V2Select <V2Select
id={component.id} id={component.id}
label={component.label}
required={component.required}
readonly={config.readonly || component.readonly}
disabled={config.disabled || component.disabled}
value={currentValue} value={currentValue}
onChange={handleChange} onChange={handleChange}
onFormDataChange={isInteractive ? onFormDataChange : undefined} onFormDataChange={isInteractive ? onFormDataChange : undefined}
@ -141,6 +138,10 @@ export class V2SelectRenderer extends AutoRegisteringComponentRenderer {
{...restPropsClean} {...restPropsClean}
style={effectiveStyle} style={effectiveStyle}
size={effectiveSize} 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.label || "탭"}
{tab.components && tab.components.length > 0 && (
<span className="ml-1 text-xs text-muted-foreground">
({tab.components.length})
</span>
)}
</div> </div>
)) ))
) : ( ) : (
@ -649,11 +644,6 @@ ComponentRegistry.registerComponent({
}} }}
> >
{tab.label || "탭"} {tab.label || "탭"}
{tab.components && tab.components.length > 0 && (
<span className="ml-1 text-xs text-muted-foreground">
({tab.components.length})
</span>
)}
</div> </div>
)) ))
) : ( ) : (

View File

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

View File

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