jskim-node #410

Merged
kjs merged 55 commits from jskim-node into main 2026-03-10 16:34:15 +09:00
13 changed files with 138 additions and 76 deletions
Showing only changes of commit 43523a0bba - Show all commits

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";
@ -1294,9 +1294,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 +1348,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);
}
@ -405,7 +425,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,
@ -852,7 +871,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 +891,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

@ -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}
/>
);
}