Compare commits

..

No commits in common. "d8067f1d94854ce5c2f327ad97c4cd8520c4e98e" and "9db69a83cd380f2f3f27548bbea65c49fd989b88" have entirely different histories.

28 changed files with 179 additions and 330 deletions

View File

@ -3564,7 +3564,6 @@ 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,
@ -3574,23 +3573,19 @@ export async function getTableSchema(
ic.character_maximum_length, ic.character_maximum_length,
ic.numeric_precision, ic.numeric_precision,
ic.numeric_scale, ic.numeric_scale,
COALESCE(ttc_company.column_label, ttc_common.column_label) AS column_label, ttc.column_label,
COALESCE(ttc_company.display_order, ttc_common.display_order) AS display_order ttc.display_order
FROM information_schema.columns ic FROM information_schema.columns ic
LEFT JOIN table_type_columns ttc_common LEFT JOIN table_type_columns ttc
ON ttc_common.table_name = ic.table_name ON ttc.table_name = ic.table_name
AND ttc_common.column_name = ic.column_name AND ttc.column_name = ic.column_name
AND ttc_common.company_code = '*' AND ttc.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_company.display_order, ttc_common.display_order, ic.ordinal_position), ic.ordinal_position ORDER BY COALESCE(ttc.display_order, ic.ordinal_position), ic.ordinal_position
`; `;
const columns = await query<any>(schemaQuery, [tableName, companyCode]); const columns = await query<any>(schemaQuery, [tableName]);
if (columns.length === 0) { if (columns.length === 0) {
res.status(404).json({ res.status(404).json({

View File

@ -2,7 +2,6 @@ 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 (
@ -69,12 +68,9 @@ export const saveFormData = async (
}); });
} catch (error: any) { } catch (error: any) {
console.error("❌ 폼 데이터 저장 실패:", error); console.error("❌ 폼 데이터 저장 실패:", error);
const { companyCode } = req.user as any; res.status(500).json({
const friendlyMsg = await formatPgError(error, companyCode);
const statusCode = error.code?.startsWith("23") ? 400 : 500;
res.status(statusCode).json({
success: false, success: false,
message: friendlyMsg, message: error.message || "데이터 저장에 실패했습니다.",
}); });
} }
}; };
@ -122,12 +118,9 @@ export const saveFormDataEnhanced = async (
res.json(result); res.json(result);
} catch (error: any) { } catch (error: any) {
console.error("❌ 개선된 폼 데이터 저장 실패:", error); console.error("❌ 개선된 폼 데이터 저장 실패:", error);
const { companyCode } = req.user as any; res.status(500).json({
const friendlyMsg = await formatPgError(error, companyCode);
const statusCode = error.code?.startsWith("23") ? 400 : 500;
res.status(statusCode).json({
success: false, success: false,
message: friendlyMsg, message: error.message || "데이터 저장에 실패했습니다.",
}); });
} }
}; };
@ -170,12 +163,9 @@ export const updateFormData = async (
}); });
} catch (error: any) { } catch (error: any) {
console.error("❌ 폼 데이터 업데이트 실패:", error); console.error("❌ 폼 데이터 업데이트 실패:", error);
const { companyCode } = req.user as any; res.status(500).json({
const friendlyMsg = await formatPgError(error, companyCode);
const statusCode = error.code?.startsWith("23") ? 400 : 500;
res.status(statusCode).json({
success: false, success: false,
message: friendlyMsg, message: error.message || "데이터 업데이트에 실패했습니다.",
}); });
} }
}; };
@ -226,12 +216,9 @@ export const updateFormDataPartial = async (
}); });
} catch (error: any) { } catch (error: any) {
console.error("❌ 부분 업데이트 실패:", error); console.error("❌ 부분 업데이트 실패:", error);
const { companyCode } = req.user as any; res.status(500).json({
const friendlyMsg = await formatPgError(error, companyCode);
const statusCode = error.code?.startsWith("23") ? 400 : 500;
res.status(statusCode).json({
success: false, success: false,
message: friendlyMsg, message: error.message || "부분 업데이트에 실패했습니다.",
}); });
} }
}; };

View File

@ -47,10 +47,7 @@ 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
const colName = pgError.column || ""; error = new AppError("필수 입력값이 누락되었습니다.", 400);
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);
@ -87,7 +84,6 @@ 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

@ -1,50 +0,0 @@
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,17 +245,6 @@ 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) => {
@ -1623,11 +1612,8 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
return; return;
} }
// 필수 항목 검증 (테이블 타입관리 NOT NULL 기반 + 기존 required 속성 폴백) // 필수 항목 검증
const requiredFields = allComponents.filter(c => { const requiredFields = allComponents.filter(c => c.required && (c.columnName || c.id));
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];
@ -2500,7 +2486,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
style={labelStyle} style={labelStyle}
> >
{labelText} {labelText}
{(component.required || component.componentConfig?.required || isColumnRequired(component.columnName || component.style?.columnName || "")) && <span className="ml-1 text-destructive">*</span>} {(component.required || component.componentConfig?.required) && <span className="ml-1 text-destructive">*</span>}
</label> </label>
)} )}
@ -2519,7 +2505,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
}} }}
> >
{labelText} {labelText}
{(component.required || component.componentConfig?.required || isColumnRequired(component.columnName || component.style?.columnName || "")) && <span className="ml-1 text-destructive">*</span>} {(component.required || component.componentConfig?.required) && <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, isColumnRequiredByMeta } from "@/lib/registry/DynamicComponentRenderer"; import { DynamicComponentRenderer } 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,13 +558,9 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
if (onSave) { if (onSave) {
try { try {
await onSave(); await onSave();
} catch (error: any) { } catch (error) {
console.error("저장 오류:", error); console.error("저장 오류:", error);
const msg = toast.error("저장 중 오류가 발생했습니다.");
error?.response?.data?.message ||
error?.message ||
"저장에 실패했습니다.";
toast.error(msg);
} }
return; return;
} }
@ -599,12 +595,8 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
); );
toast.success("데이터가 성공적으로 저장되었습니다."); toast.success("데이터가 성공적으로 저장되었습니다.");
} catch (error: any) { } catch (error) {
const msg = toast.error("저장 중 오류가 발생했습니다.");
error?.response?.data?.message ||
error?.message ||
"저장에 실패했습니다.";
toast.error(msg);
} }
return; return;
} }
@ -664,12 +656,8 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
} else { } else {
toast.error(response.message || "저장에 실패했습니다."); toast.error(response.message || "저장에 실패했습니다.");
} }
} catch (error: any) { } catch (error) {
const msg = toast.error("저장 중 오류가 발생했습니다.");
error?.response?.data?.message ||
error?.message ||
"저장에 실패했습니다.";
toast.error(msg);
} }
}; };
@ -1306,8 +1294,9 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
...(labelPos === "bottom" ? { marginTop: style?.labelMarginBottom || "4px" } : {}), ...(labelPos === "bottom" ? { marginTop: style?.labelMarginBottom || "4px" } : {}),
}} }}
> >
{labelText}{((component as any).required || (component as any).componentConfig?.required || isColumnRequiredByMeta((component as any).tableName, (component as any).columnName)) && ( {labelText}
<span className="text-orange-500">*</span> {((component as any).required || (component as any).componentConfig?.required) && (
<span className="ml-1 text-destructive">*</span>
)} )}
</label> </label>
) : null; ) : null;
@ -1360,8 +1349,9 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
whiteSpace: "nowrap", whiteSpace: "nowrap",
}} }}
> >
{labelText}{((component as any).required || (component as any).componentConfig?.required || isColumnRequiredByMeta((component as any).tableName, (component as any).columnName)) && ( {labelText}
<span className="text-orange-500">*</span> {((component as any).required || (component as any).componentConfig?.required) && (
<span className="ml-1 text-destructive">*</span>
)} )}
</label> </label>
<div style={{ width: "100%", height: "100%" }}> <div style={{ width: "100%", height: "100%" }}>

View File

@ -10,7 +10,6 @@ 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";
@ -43,7 +42,6 @@ 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);
@ -72,19 +70,6 @@ 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);
@ -121,39 +106,34 @@ 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) => {
const columnName = component.columnName || component.style?.columnName; // 컴포넌트의 required 속성 확인 (여러 위치에서 체크)
const label = component.label || component.style?.label || columnName; const isRequired =
// 기존 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;
const label = component.label || component.style?.label || columnName;
// 테이블 타입관리 NOT NULL 기반 필수 (컬럼 정보가 있을 때만) console.log("🔍 필수 항목 검증:", {
const notNullRequired = columnName ? isColumnRequired(columnName) : false; componentId: component.id,
columnName,
// 둘 중 하나라도 필수이면 검증 label,
const isRequired = manualRequired || notNullRequired; 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);
} }
@ -282,8 +262,7 @@ export const SaveModal: React.FC<SaveModalProps> = ({
}, 300); // 모달 닫힘 애니메이션 후 실행 }, 300); // 모달 닫힘 애니메이션 후 실행
} }
} else { } else {
const errorMsg = result.message || result.error?.message || "저장에 실패했습니다."; throw new Error(result.message || "저장에 실패했습니다.");
toast.error(errorMsg);
} }
} catch (error: any) { } catch (error: any) {
// ❌ 저장 실패 - 모달은 닫히지 않음 // ❌ 저장 실패 - 모달은 닫히지 않음
@ -426,8 +405,7 @@ export const SaveModal: React.FC<SaveModalProps> = ({
})); }));
}} }}
hideLabel={false} hideLabel={false}
menuObjid={menuObjid} menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달 (카테고리 스코프용)
tableColumns={tableColumnsInfo as any}
/> />
) : ( ) : (
<DynamicComponentRenderer <DynamicComponentRenderer

View File

@ -943,31 +943,19 @@ 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) && (
const colName = widget.columnName || selectedComponent?.columnName; <div className="flex items-center space-x-2">
const colMeta = colName ? currentTable?.columns?.find( <Checkbox
(c: any) => (c.columnName || c.column_name || "").toLowerCase() === colName.toLowerCase() checked={widget.required === true || selectedComponent.componentConfig?.required === true}
) : null; onCheckedChange={(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("required", checked);
return ( handleUpdate("componentConfig.required", checked);
<div className="flex items-center space-x-2"> }}
<Checkbox className="h-4 w-4"
checked={isNotNull || widget.required === true || selectedComponent.componentConfig?.required === true} />
onCheckedChange={(checked) => { <Label className="text-xs"></Label>
if (isNotNull) return; </div>
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,7 +724,8 @@ export const V2Date = forwardRef<HTMLDivElement, V2DateProps>((props, ref) => {
}} }}
className="text-sm font-medium whitespace-nowrap" className="text-sm font-medium whitespace-nowrap"
> >
{label}{required && <span className="text-orange-500">*</span>} {label}
{required && <span className="ml-0.5 text-orange-500">*</span>}
</Label> </Label>
) : null; ) : null;

View File

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

View File

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

View File

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

View File

@ -15,13 +15,7 @@ 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]) return; if (columnMetaCache[tableName] || columnMetaLoading[tableName]) return;
// 이미 로딩 중이면 해당 Promise를 대기 (race condition 방지)
if (columnMetaLoading[tableName]) {
await columnMetaLoading[tableName];
return;
}
columnMetaLoading[tableName] = (async () => { columnMetaLoading[tableName] = (async () => {
try { try {
@ -34,8 +28,7 @@ async function loadColumnMeta(tableName: string): Promise<void> {
if (name) map[name] = col; if (name) map[name] = col;
} }
columnMetaCache[tableName] = map; columnMetaCache[tableName] = map;
} catch (e) { } catch {
console.error(`[columnMeta] ${tableName} 로드 실패:`, e);
columnMetaCache[tableName] = {}; columnMetaCache[tableName] = {};
} finally { } finally {
delete columnMetaLoading[tableName]; delete columnMetaLoading[tableName];
@ -45,15 +38,6 @@ 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;
@ -265,7 +249,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) { if (screenTableName && !columnMetaCache[screenTableName]) {
loadColumnMeta(screenTableName).then(() => forceUpdate((v) => v + 1)); loadColumnMeta(screenTableName).then(() => forceUpdate((v) => v + 1));
} }
}, [screenTableName]); }, [screenTableName]);
@ -451,7 +435,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 || isColumnRequiredByMeta(tableName, columnName); const isRequired = component.required || (component as any).required;
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%" }}>
@ -470,7 +454,8 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
}} }}
className="text-sm font-medium" className="text-sm font-medium"
> >
{catLabelText}{isRequired && <span className="text-orange-500">*</span>} {catLabelText}
{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}
@ -730,14 +715,10 @@ 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, required: effectiveRequired } ? { ...component, componentConfig: mergedComponentConfig, readonly: false }
: { ...component, componentConfig: mergedComponentConfig, required: effectiveRequired }; : { ...component, componentConfig: mergedComponentConfig };
const rendererProps = { const rendererProps = {
component: effectiveComponent, component: effectiveComponent,
@ -757,8 +738,6 @@ 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,
@ -873,7 +852,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 = effectiveComponent.required || isColumnRequiredByMeta(screenTableName, baseColumnName); const isRequired = component.required || (component as any).required;
const isLeft = labelPosition === "left"; const isLeft = labelPosition === "left";
return ( return (
@ -893,7 +872,8 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
}} }}
className="text-sm font-medium" className="text-sm font-medium"
> >
{effectiveLabel}{isRequired && <span className="text-orange-500">*</span>} {effectiveLabel}
{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,16 +614,27 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
// 실패한 경우 오류 처리 // 실패한 경우 오류 처리
if (!success) { if (!success) {
// UI 전환 액션 및 모달 액션은 에러도 조용히 처리 (모달 내부에서 자체 에러 표시) // UI 전환 액션 및 모달 액션은 에러도 조용히 처리 (모달 내부에서 자체 에러 표시)
const silentErrorActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan", "save", "delete", "quickInsert"]; const silentErrorActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan"];
if (silentErrorActions.includes(actionConfig.type)) { if (silentErrorActions.includes(actionConfig.type)) {
return; return;
} }
// 기본 에러 메시지 결정
const defaultErrorMessage = const defaultErrorMessage =
actionConfig.type === "submit" actionConfig.type === "save"
? "제출 중 오류가 발생했습니다." ? "저장 중 오류가 발생했습니다."
: "처리 중 오류가 발생했습니다."; : 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,7 +10,6 @@ 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 {
@ -381,15 +380,6 @@ 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) {
@ -886,7 +876,7 @@ export function ModalRepeaterTableComponent({
{/* Repeater 테이블 */} {/* Repeater 테이블 */}
<RepeaterTable <RepeaterTable
columns={enhancedColumns} columns={columns}
data={localValue} data={localValue}
onDataChange={handleChange} onDataChange={handleChange}
onRowChange={handleRowChange} onRowChange={handleRowChange}

View File

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

View File

@ -10,7 +10,6 @@ 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,
@ -3114,15 +3113,15 @@ function renderTableCell(
} }
// 컬럼 렌더링 함수 (Simple 모드) // 컬럼 렌더링 함수 (Simple 모드)
function renderColumn(col: CardColumnConfig, card: CardData, onChange: (value: any) => void, tableName?: string) { function renderColumn(col: CardColumnConfig, card: CardData, onChange: (value: any) => void) {
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}{effectiveRequired && <span className="text-orange-500">*</span>} {col.label}
{col.required && <span className="text-destructive ml-1">*</span>}
</Label> </Label>
{isReadOnly && ( {isReadOnly && (

View File

@ -43,7 +43,6 @@ 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;
@ -1917,7 +1916,8 @@ 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.required || isColumnRequiredByMeta(componentConfig.targetTable, field.name)) && <span className="text-orange-500">*</span>} {field.label}
{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,7 +1977,8 @@ 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.required || isColumnRequiredByMeta(componentConfig.targetTable, field.name)) && <span className="text-orange-500">*</span>} {field.label}
{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>
@ -2352,7 +2353,8 @@ 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.required || isColumnRequiredByMeta(componentConfig.targetTable, field.name)) && <span className="text-orange-500">*</span>} {field.label}
{field.required && <span className="text-destructive ml-1">*</span>}
</TableHead> </TableHead>
))} ))}

View File

@ -10,7 +10,6 @@ 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;
@ -675,7 +674,8 @@ 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.required || isColumnRequiredByMeta(componentTargetTable, col.field)) && <span className="text-orange-500">*</span>} {col.label}
{col.required && <span className="text-destructive ml-1">*</span>}
</th> </th>
))} ))}
{!readOnly && allowDelete && ( {!readOnly && allowDelete && (

View File

@ -42,7 +42,6 @@ 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
@ -1439,17 +1438,15 @@ 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 || []) {
const isRequired = field.required || isColumnRequiredByMeta(mainTableName, field.columnName); if (field.required && !field.hidden && !field.numberingRule?.hidden) {
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);
@ -1459,7 +1456,7 @@ export function UniversalFormModalComponent({
} }
return { valid: missingFields.length === 0, missingFields }; return { valid: missingFields.length === 0, missingFields };
}, [config.sections, formData, mainTableName]); }, [config.sections, formData]);
// 다중 테이블 저장 (범용) // 다중 테이블 저장 (범용)
const saveWithMultiTable = useCallback(async () => { const saveWithMultiTable = useCallback(async () => {
@ -2010,7 +2007,8 @@ 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.required || isColumnRequiredByMeta(mainTableName, field.columnName)) && <span className="text-orange-500">*</span>} {field.label}
{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", "save", "delete", "quickInsert"]; const silentErrorActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan"];
if (silentErrorActions.includes(actionConfig.type)) { if (silentErrorActions.includes(actionConfig.type)) {
return; return;
} }

View File

@ -4,7 +4,6 @@ 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
@ -19,7 +18,6 @@ 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 ?? "";
@ -39,6 +37,10 @@ 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={{
@ -53,10 +55,6 @@ 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,7 +4,6 @@ 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
@ -53,6 +52,10 @@ 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}
@ -75,10 +78,6 @@ 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,7 +4,6 @@ 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
@ -113,6 +112,10 @@ 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}
@ -138,10 +141,6 @@ 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,6 +295,11 @@ 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>
)) ))
) : ( ) : (
@ -644,6 +649,11 @@ 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,7 +8,6 @@ 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
@ -464,15 +463,13 @@ export class ButtonActionExecutor {
console.warn(`지원되지 않는 액션 타입: ${config.type}`); console.warn(`지원되지 않는 액션 타입: ${config.type}`);
return false; return false;
} }
} catch (error: any) { } catch (error) {
console.error("버튼 액션 실행 오류:", error); console.error("버튼 액션 실행 오류:", error);
const actualMsg = showErrorToast(
error?.response?.data?.message || config.errorMessage || `'${config.label || config.type}' 버튼 실행에 실패했습니다`,
error?.response?.data?.error?.message || error,
error?.message || { guidance: "설정을 확인하거나 잠시 후 다시 시도해 주세요." }
""; );
const title = actualMsg || config.errorMessage || `'${config.label || config.type}' 실행에 실패했습니다`;
toast.error(title);
return false; return false;
} }
} }
@ -482,7 +479,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, tableName } = context; const { formData, allComponents } = context;
if (!allComponents || allComponents.length === 0) { if (!allComponents || allComponents.length === 0) {
console.log("⚠️ [validateRequiredFields] allComponents 없음 - 검증 스킵"); console.log("⚠️ [validateRequiredFields] allComponents 없음 - 검증 스킵");
@ -490,20 +487,18 @@ export class ButtonActionExecutor {
} }
allComponents.forEach((component: any) => { allComponents.forEach((component: any) => {
const columnName = component.columnName || component.style?.columnName; // 컴포넌트의 required 속성 확인 (여러 위치에서 체크)
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);
} }
@ -606,15 +601,22 @@ export class ButtonActionExecutor {
try { try {
await onSave(); await onSave();
return true; return true;
} catch (error: any) { } catch (error) {
console.error("❌ [handleSave] onSave 콜백 실행 오류:", error); console.error("❌ [handleSave] onSave 콜백 실행 오류:", error);
const msg = throw error;
error?.response?.data?.message || }
error?.response?.data?.error?.message || }
error?.message ||
"저장에 실패했습니다."; // 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용
toast.error(msg); // 단, _tableSection_ 데이터가 있으면 건너뛰기 (handleUniversalFormModalTableSectionSave가 처리)
return false; // EditModal이 내부에서 직접 repeaterSave 이벤트를 발행하고 완료를 기다림
if (onSave && !hasTableSectionData) {
try {
await onSave();
return true;
} catch (error) {
console.error("❌ [handleSave] onSave 콜백 실행 오류:", error);
throw error;
} }
} }
@ -1735,12 +1737,7 @@ export class ButtonActionExecutor {
} }
if (!saveResult.success) { if (!saveResult.success) {
const errorMsg = throw new Error(saveResult.message || "저장에 실패했습니다.");
saveResult.message ||
saveResult.error?.message ||
"저장에 실패했습니다.";
toast.error(errorMsg);
return false;
} }
// 🔥 저장 성공 후 연결된 제어 실행 (dataflowTiming이 'after'인 경우) // 🔥 저장 성공 후 연결된 제어 실행 (dataflowTiming이 'after'인 경우)
@ -1929,15 +1926,9 @@ export class ButtonActionExecutor {
window.dispatchEvent(new CustomEvent("saveSuccessInModal")); window.dispatchEvent(new CustomEvent("saveSuccessInModal"));
return true; return true;
} catch (error: any) { } catch (error) {
console.error("저장 오류:", error); console.error("저장 오류:", error);
const msg = throw error; // 에러를 다시 던져서 컴포넌트에서 처리하도록 함
error?.response?.data?.message ||
error?.response?.data?.error?.message ||
error?.message ||
"저장에 실패했습니다.";
toast.error(msg);
return false;
} }
} }

View File

@ -5,7 +5,6 @@
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 {
@ -79,8 +78,8 @@ export const validateFormData = async (
); );
} }
// 2. 필수 필드 검증 (NOT NULL 메타데이터 포함) // 2. 필수 필드 검증
const requiredValidation = validateRequiredFields(formData, components, tableName); const requiredValidation = validateRequiredFields(formData, components);
errors.push(...requiredValidation); errors.push(...requiredValidation);
// 3. 데이터 타입 검증 및 변환 // 3. 데이터 타입 검증 및 변환
@ -193,17 +192,14 @@ 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) {
const fieldName = component.columnName || component.id; if (!component.required) continue;
// 수동 required + NOT NULL 메타데이터 기반 통합 체크
const isRequired = component.required || isColumnRequiredByMeta(tableName, fieldName);
if (!isRequired) continue;
const fieldName = component.columnName || component.id;
const value = formData[fieldName]; const value = formData[fieldName];
if ( if (