diff --git a/frontend/components/screen/InteractiveScreenViewer.tsx b/frontend/components/screen/InteractiveScreenViewer.tsx index 4d8a6fec..56a7da33 100644 --- a/frontend/components/screen/InteractiveScreenViewer.tsx +++ b/frontend/components/screen/InteractiveScreenViewer.tsx @@ -245,6 +245,17 @@ export const InteractiveScreenViewer: React.FC = ( // 통합된 폼 데이터 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 = ( 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 = ( style={labelStyle} > {labelText} - {(component.required || component.componentConfig?.required) && *} + {(component.required || component.componentConfig?.required || isColumnRequired(component.columnName || component.style?.columnName || "")) && *} )} @@ -2505,7 +2519,7 @@ export const InteractiveScreenViewer: React.FC = ( }} > {labelText} - {(component.required || component.componentConfig?.required) && *} + {(component.required || component.componentConfig?.required || isColumnRequired(component.columnName || component.style?.columnName || "")) && *} )} diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index 1bb04e97..a22e0646 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -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"; @@ -1294,9 +1294,8 @@ export const InteractiveScreenViewerDynamic: React.FC - {labelText} - {((component as any).required || (component as any).componentConfig?.required) && ( - * + {labelText}{((component as any).required || (component as any).componentConfig?.required || isColumnRequiredByMeta((component as any).tableName, (component as any).columnName)) && ( + * )} ) : null; @@ -1349,9 +1348,8 @@ export const InteractiveScreenViewerDynamic: React.FC - {labelText} - {((component as any).required || (component as any).componentConfig?.required) && ( - * + {labelText}{((component as any).required || (component as any).componentConfig?.required || isColumnRequiredByMeta((component as any).tableName, (component as any).columnName)) && ( + * )}
diff --git a/frontend/components/screen/SaveModal.tsx b/frontend/components/screen/SaveModal.tsx index 259bf238..6a4e858f 100644 --- a/frontend/components/screen/SaveModal.tsx +++ b/frontend/components/screen/SaveModal.tsx @@ -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 = ({ const [originalData, setOriginalData] = useState>(initialData || {}); const [screenData, setScreenData] = useState(null); const [components, setComponents] = useState([]); + const [tableColumnsInfo, setTableColumnsInfo] = useState([]); const [loading, setLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); @@ -70,6 +72,19 @@ export const SaveModal: React.FC = ({ 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 = ({ }; }, [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); } @@ -405,7 +425,8 @@ export const SaveModal: React.FC = ({ })); }} hideLabel={false} - menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달 (카테고리 스코프용) + menuObjid={menuObjid} + tableColumns={tableColumnsInfo as any} /> ) : ( = ({ {/* 옵션 - 입력 필드에서는 항상 표시, 기타 컴포넌트는 속성이 정의된 경우만 표시 */}
- {(isInputField || widget.required !== undefined) && ( -
- { - handleUpdate("required", checked); - handleUpdate("componentConfig.required", checked); - }} - className="h-4 w-4" - /> - -
- )} + {(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 ( +
+ { + if (isNotNull) return; + handleUpdate("required", checked); + handleUpdate("componentConfig.required", checked); + }} + disabled={!!isNotNull} + className="h-4 w-4" + /> + +
+ ); + })()} {(isInputField || widget.readonly !== undefined) && (
((props, ref) => { }} className="text-sm font-medium whitespace-nowrap" > - {label} - {required && *} + {label}{required && *} ) : null; diff --git a/frontend/components/v2/V2Hierarchy.tsx b/frontend/components/v2/V2Hierarchy.tsx index 28f51fee..f0a371fa 100644 --- a/frontend/components/v2/V2Hierarchy.tsx +++ b/frontend/components/v2/V2Hierarchy.tsx @@ -491,8 +491,7 @@ export const V2Hierarchy = forwardRef( }} className="text-sm font-medium whitespace-nowrap" > - {label} - {required && *} + {label}{required && *} )}
diff --git a/frontend/components/v2/V2Input.tsx b/frontend/components/v2/V2Input.tsx index 6bd19c5d..6708a42b 100644 --- a/frontend/components/v2/V2Input.tsx +++ b/frontend/components/v2/V2Input.tsx @@ -994,8 +994,7 @@ export const V2Input = forwardRef((props, ref) => }} className="text-sm font-medium whitespace-nowrap" > - {actualLabel} - {required && *} + {actualLabel}{required && *} ) : null; diff --git a/frontend/components/v2/V2Media.tsx b/frontend/components/v2/V2Media.tsx index 0a4faaae..e9f152fb 100644 --- a/frontend/components/v2/V2Media.tsx +++ b/frontend/components/v2/V2Media.tsx @@ -840,8 +840,7 @@ export const V2Media = forwardRef((props, ref) => }} className="shrink-0 text-sm font-medium" > - {label} - {required && *} + {label}{required && *} )} diff --git a/frontend/components/v2/V2Select.tsx b/frontend/components/v2/V2Select.tsx index 538d33be..0b8720f8 100644 --- a/frontend/components/v2/V2Select.tsx +++ b/frontend/components/v2/V2Select.tsx @@ -1181,8 +1181,7 @@ export const V2Select = forwardRef( }} className="text-sm font-medium whitespace-nowrap" > - {label} - {required && *} + {label}{required && *} ) : null; diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 50c4bee4..3012fa4c 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -15,7 +15,13 @@ const columnMetaCache: Record> = {}; const columnMetaLoading: Record> = {}; async function loadColumnMeta(tableName: string): Promise { - 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 { 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 { 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 = 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 = 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 (
@@ -454,8 +470,7 @@ export const DynamicComponentRenderer: React.FC = }} className="text-sm font-medium" > - {catLabelText} - {isRequired && *} + {catLabelText}{isRequired && *}
{renderedCatSelect} @@ -715,10 +730,14 @@ export const DynamicComponentRenderer: React.FC = 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, @@ -852,7 +871,7 @@ export const DynamicComponentRenderer: React.FC = 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 +891,7 @@ export const DynamicComponentRenderer: React.FC = }} className="text-sm font-medium" > - {effectiveLabel} - {isRequired && *} + {effectiveLabel}{isRequired && *}
{renderedElement} diff --git a/frontend/lib/registry/components/v2-date/V2DateRenderer.tsx b/frontend/lib/registry/components/v2-date/V2DateRenderer.tsx index 1550bbe3..46fe4d7e 100644 --- a/frontend/lib/registry/components/v2-date/V2DateRenderer.tsx +++ b/frontend/lib/registry/components/v2-date/V2DateRenderer.tsx @@ -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 ( ); } diff --git a/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx b/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx index eba973e4..90c4f801 100644 --- a/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx +++ b/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx @@ -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 ( ); } diff --git a/frontend/lib/registry/components/v2-select/V2SelectRenderer.tsx b/frontend/lib/registry/components/v2-select/V2SelectRenderer.tsx index b389cff7..36e35eac 100644 --- a/frontend/lib/registry/components/v2-select/V2SelectRenderer.tsx +++ b/frontend/lib/registry/components/v2-select/V2SelectRenderer.tsx @@ -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 ( ); }