feat(universal-form-modal): 필수 필드 검증 및 섹션 레이아웃 열 수 설정 기능 추가
- validateRequiredFields 함수 추가로 필수 필드 미입력 시 저장 차단 - 섹션별 열 수 설정 (1열/2열/3열/4열) 및 gridSpan 자동 계산 - 버튼 이벤트 버블링 방지 (type=button, preventDefault, stopPropagation) - onChange 콜백 렌더링 사이클 분리 (setTimeout) - 다중 행 저장 시 빈 객체 건너뛰기 로직 추가
This commit is contained in:
parent
6c751eb489
commit
0e4ecef336
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue