feat(universal-form-modal): 필수 필드 검증 및 섹션 레이아웃 열 수 설정 기능 추가

- validateRequiredFields 함수 추가로 필수 필드 미입력 시 저장 차단
- 섹션별 열 수 설정 (1열/2열/3열/4열) 및 gridSpan 자동 계산
- 버튼 이벤트 버블링 방지 (type=button, preventDefault, stopPropagation)
- onChange 콜백 렌더링 사이클 분리 (setTimeout)
- 다중 행 저장 시 빈 객체 건너뛰기 로직 추가
This commit is contained in:
SeongHyun Kim 2025-12-04 18:15:48 +09:00
parent 6c751eb489
commit 0e4ecef336
2 changed files with 142 additions and 23 deletions

View File

@ -219,7 +219,10 @@ export function UniversalFormModalComponent({
(columnName: string, value: any) => {
setFormData((prev) => {
const newData = { ...prev, [columnName]: value };
onChange?.(newData);
// onChange는 렌더링 외부에서 호출해야 함 (setTimeout 사용)
if (onChange) {
setTimeout(() => onChange(newData), 0);
}
return newData;
});
},
@ -339,6 +342,26 @@ export function UniversalFormModalComponent({
[selectOptionsCache],
);
// 필수 필드 검증
const validateRequiredFields = useCallback((): { valid: boolean; missingFields: string[] } => {
const missingFields: string[] = [];
for (const section of config.sections) {
if (section.repeatable) continue; // 반복 섹션은 별도 검증
for (const field of section.fields) {
if (field.required && !field.hidden && !field.numberingRule?.hidden) {
const value = formData[field.columnName];
if (value === undefined || value === null || value === "") {
missingFields.push(field.label || field.columnName);
}
}
}
}
return { valid: missingFields.length === 0, missingFields };
}, [config.sections, formData]);
// 저장 처리
const handleSave = useCallback(async () => {
if (!config.saveConfig.tableName) {
@ -346,6 +369,13 @@ export function UniversalFormModalComponent({
return;
}
// 필수 필드 검증
const { valid, missingFields } = validateRequiredFields();
if (!valid) {
toast.error(`필수 항목을 입력해주세요: ${missingFields.join(", ")}`);
return;
}
setSaving(true);
try {
@ -375,7 +405,7 @@ export function UniversalFormModalComponent({
} finally {
setSaving(false);
}
}, [config, formData, repeatSections, onSave]);
}, [config, formData, repeatSections, onSave, validateRequiredFields]);
// 단일 행 저장
const saveSingleRow = async () => {
@ -492,11 +522,23 @@ export function UniversalFormModalComponent({
}
// 모든 행 저장
for (const row of rowsToSave) {
console.log("[UniversalFormModal] 저장할 행들:", rowsToSave);
console.log("[UniversalFormModal] 저장 테이블:", config.saveConfig.tableName);
for (let i = 0; i < rowsToSave.length; i++) {
const row = rowsToSave[i];
console.log(`[UniversalFormModal] ${i + 1}번째 행 저장 시도:`, row);
// 빈 객체 체크
if (Object.keys(row).length === 0) {
console.warn(`[UniversalFormModal] ${i + 1}번째 행이 비어있습니다. 건너뜁니다.`);
continue;
}
const response = await apiClient.post(`/table-management/tables/${config.saveConfig.tableName}/add`, row);
if (!response.data?.success) {
throw new Error(response.data?.message || "저장 실패");
throw new Error(response.data?.message || `${i + 1}번째 행 저장 실패`);
}
}
@ -509,16 +551,15 @@ export function UniversalFormModalComponent({
toast.info("폼이 초기화되었습니다.");
}, [initializeForm]);
// 필드 렌더링
const renderField = (field: FormFieldConfig, value: any, onChangeHandler: (value: any) => void, fieldKey: string) => {
const isDisabled = field.disabled || (field.numberingRule?.enabled && !field.numberingRule?.editable);
const isHidden = field.numberingRule?.hidden;
if (isHidden) {
return null;
}
const fieldElement = (() => {
// 필드 요소 렌더링 (입력 컴포넌트만)
const renderFieldElement = (
field: FormFieldConfig,
value: any,
onChangeHandler: (value: any) => void,
fieldKey: string,
isDisabled: boolean,
) => {
return (() => {
switch (field.fieldType) {
case "textarea":
return (
@ -651,18 +692,46 @@ export function UniversalFormModalComponent({
);
}
})();
};
// 섹션의 열 수에 따른 기본 gridSpan 계산
const getDefaultGridSpan = (sectionColumns: number = 2): number => {
// 12칸 그리드 기준: 1열=12, 2열=6, 3열=4, 4열=3
return Math.floor(12 / sectionColumns);
};
// 필드 렌더링 (섹션 열 수 적용)
const renderFieldWithColumns = (
field: FormFieldConfig,
value: any,
onChangeHandler: (value: any) => void,
fieldKey: string,
sectionColumns: number = 2,
) => {
// 섹션 열 수에 따른 기본 gridSpan 계산 (섹션 열 수가 우선)
const defaultSpan = getDefaultGridSpan(sectionColumns);
// 섹션이 1열이면 무조건 12(전체 너비), 그 외에는 필드 설정 또는 기본값 사용
const actualGridSpan = sectionColumns === 1 ? 12 : field.gridSpan || defaultSpan;
const isDisabled = !!(field.disabled || (field.numberingRule?.enabled && !field.numberingRule?.editable));
const isHidden = field.hidden || field.numberingRule?.hidden;
if (isHidden) {
return null;
}
const fieldElement = renderFieldElement(field, value, onChangeHandler, fieldKey, isDisabled);
// 체크박스는 라벨이 옆에 있으므로 별도 처리
if (field.fieldType === "checkbox") {
return (
<div key={fieldKey} className="space-y-1" style={{ gridColumn: `span ${field.gridSpan || 6}` }}>
<div key={fieldKey} className="space-y-1" style={{ gridColumn: `span ${actualGridSpan}` }}>
{fieldElement}
</div>
);
}
return (
<div key={fieldKey} className="space-y-1" style={{ gridColumn: `span ${field.gridSpan || 6}` }}>
<div key={fieldKey} className="space-y-1" style={{ gridColumn: `span ${actualGridSpan}` }}>
<Label htmlFor={fieldKey} className="text-sm font-medium">
{field.label}
{field.required && <span className="text-destructive ml-1">*</span>}
@ -676,6 +745,7 @@ export function UniversalFormModalComponent({
// 섹션 렌더링
const renderSection = (section: FormSectionConfig) => {
const isCollapsed = collapsedSections.has(section.id);
const sectionColumns = section.columns || 2;
if (section.repeatable) {
return renderRepeatableSection(section, isCollapsed);
@ -702,11 +772,12 @@ export function UniversalFormModalComponent({
<CardContent>
<div className="grid gap-4" style={{ gridTemplateColumns: `repeat(12, 1fr)` }}>
{section.fields.map((field) =>
renderField(
renderFieldWithColumns(
field,
formData[field.columnName],
(value) => handleFieldChange(field.columnName, value),
`${section.id}-${field.id}`,
sectionColumns,
),
)}
</div>
@ -722,11 +793,12 @@ export function UniversalFormModalComponent({
<CardContent>
<div className="grid gap-4" style={{ gridTemplateColumns: `repeat(12, 1fr)` }}>
{section.fields.map((field) =>
renderField(
renderFieldWithColumns(
field,
formData[field.columnName],
(value) => handleFieldChange(field.columnName, value),
`${section.id}-${field.id}`,
sectionColumns,
),
)}
</div>
@ -742,6 +814,7 @@ export function UniversalFormModalComponent({
const items = repeatSections[section.id] || [];
const maxItems = section.repeatConfig?.maxItems || 10;
const canAdd = items.length < maxItems;
const sectionColumns = section.columns || 2;
const content = (
<>
@ -778,11 +851,12 @@ export function UniversalFormModalComponent({
</div>
<div className="grid gap-4" style={{ gridTemplateColumns: `repeat(12, 1fr)` }}>
{section.fields.map((field) =>
renderField(
renderFieldWithColumns(
field,
item[field.columnName],
(value) => handleRepeatFieldChange(section.id, item._id, field.columnName, value),
`${section.id}-${item._id}-${field.id}`,
sectionColumns,
),
)}
</div>
@ -870,15 +944,41 @@ export function UniversalFormModalComponent({
{/* 버튼 영역 */}
<div className="mt-6 flex justify-end gap-2 border-t pt-4">
{config.modal.showResetButton && (
<Button variant="outline" onClick={handleReset} disabled={saving}>
<Button
type="button"
variant="outline"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleReset();
}}
disabled={saving}
>
<RefreshCw className="mr-1 h-4 w-4" />
{config.modal.resetButtonText || "초기화"}
</Button>
)}
<Button variant="outline" onClick={onCancel} disabled={saving}>
<Button
type="button"
variant="outline"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onCancel?.();
}}
disabled={saving}
>
{config.modal.cancelButtonText || "취소"}
</Button>
<Button onClick={handleSave} disabled={saving || !config.saveConfig.tableName}>
<Button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleSave();
}}
disabled={saving || !config.saveConfig.tableName}
>
{saving ? "저장 중..." : config.modal.saveButtonText || "저장"}
</Button>
</div>

View File

@ -670,6 +670,25 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
/>
</div>
<div>
<Label className="text-[10px]"> ()</Label>
<Select
value={String(selectedSection.columns || 2)}
onValueChange={(value) => updateSection(selectedSection.id, { columns: parseInt(value) })}
>
<SelectTrigger className="h-7 text-xs mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 ( )</SelectItem>
<SelectItem value="2">2 ()</SelectItem>
<SelectItem value="3">3</SelectItem>
<SelectItem value="4">4</SelectItem>
</SelectContent>
</Select>
<HelpText> .</HelpText>
</div>
<div className="border rounded-md p-2 space-y-2">
<div className="flex items-center justify-between">
<span className="text-[10px] font-medium"> </span>