Files
invyone/frontend/components/fc/FcForm.tsx
T
2026-04-10 13:33:37 +09:00

181 lines
5.6 KiB
TypeScript

'use client';
import { useMemo, useEffect, useState, useCallback } from 'react';
import { FieldRenderer } from './fields/FieldRenderer';
import type { FieldConfig, FormConfig } from '@/types/invyone-component';
const DEFAULT_CONFIG: FormConfig = {
columns: 2,
saveAction: {
method: 'UPSERT',
refreshAfterSave: true,
},
};
interface FcFormProps {
fields: FieldConfig[];
config?: Partial<FormConfig>;
initialData?: Record<string, any>;
onSubmit?: (data: Record<string, any>) => void;
onSaved?: (data: Record<string, any>) => void;
loadRow?: Record<string, any>;
}
/** required 검증: null, undefined, '' 만 empty. 0, false는 유효. */
function isFieldEmpty(value: any): boolean {
return value === null || value === undefined || value === '';
}
export function FcForm({
fields,
config: configOverride,
initialData,
onSubmit,
loadRow,
}: FcFormProps) {
const config = useMemo(() => ({ ...DEFAULT_CONFIG, ...configOverride }), [configOverride]);
// 표시할 필드: system이 아니고 visible인 것, order 순
const formFields = useMemo(
() => fields
.filter((f) => !f.system && f.visible)
.sort((a, b) => a.order - b.order),
[fields],
);
// 폼 데이터 상태
const [formData, setFormData] = useState<Record<string, any>>({});
const [errors, setErrors] = useState<Record<string, string>>({});
const [isModified, setIsModified] = useState(false);
// initialData 또는 loadRow가 변경되면 폼 데이터 갱신
useEffect(() => {
const source = loadRow ?? initialData ?? {};
setFormData({ ...source });
setErrors({});
setIsModified(false);
}, [loadRow, initialData]);
// 필드 값 변경
const handleChange = useCallback((column: string, value: any) => {
setFormData((prev) => ({ ...prev, [column]: value }));
setErrors((prev) => {
const next = { ...prev };
delete next[column];
return next;
});
setIsModified(true);
}, []);
// 제출
const handleSubmit = useCallback((e: React.FormEvent) => {
e.preventDefault();
// required 검증
const newErrors: Record<string, string> = {};
for (const field of formFields) {
if (field.required && isFieldEmpty(formData[field.column])) {
newErrors[field.column] = `${field.label}은(는) 필수입니다`;
}
}
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
onSubmit?.(formData);
}, [formData, formFields, onSubmit]);
// 초기화
const handleReset = useCallback(() => {
const source = loadRow ?? initialData ?? {};
setFormData({ ...source });
setErrors({});
setIsModified(false);
}, [loadRow, initialData]);
const gridCols = config.columns === 3 ? 'grid-cols-3' : config.columns === 1 ? 'grid-cols-1' : 'grid-cols-2';
// 섹션이 있으면 섹션별로 그룹핑
const sections = config.sections;
const renderField = (field: FieldConfig) => {
const isDisabled = !field.editable || (field.pk && field.type === 'code');
return (
<div key={field.column} className="space-y-0.5">
<label className="flex items-center gap-0.5 text-xs font-medium text-[var(--v5-text-sec)]">
{field.label}
{field.required && <span className="text-[var(--v5-red)]">*</span>}
</label>
<FieldRenderer
field={field}
value={formData[field.column]}
onChange={(v) => handleChange(field.column, v)}
mode="form"
disabled={isDisabled}
error={errors[field.column]}
/>
{errors[field.column] && (
<p className="text-[0.65rem] text-[var(--v5-red)]">{errors[field.column]}</p>
)}
</div>
);
};
return (
<form
onSubmit={handleSubmit}
className="fc-form rounded-md border border-[var(--v5-glass-border)] p-3
bg-[var(--v5-glass)] backdrop-blur-[20px]"
>
{sections && sections.length > 0 ? (
// 섹션별 렌더링
sections.map((section) => {
const sectionFields = formFields.filter((f) => section.fields.includes(f.column));
if (sectionFields.length === 0) return null;
return (
<div key={section.label} className="mb-3">
<h4 className="text-xs font-semibold text-[var(--v5-primary)] mb-2 pb-1
border-b border-[var(--v5-border-subtle)]">
{section.label}
</h4>
<div className={`grid ${gridCols} gap-x-3 gap-y-2`}>
{sectionFields.map(renderField)}
</div>
</div>
);
})
) : (
// 단일 섹션
<div className={`grid ${gridCols} gap-x-3 gap-y-2`}>
{formFields.map(renderField)}
</div>
)}
{/* 버튼 영역 */}
<div className="flex items-center justify-end gap-2 mt-3 pt-2 border-t border-[var(--v5-border-subtle)]">
<button
type="button"
onClick={handleReset}
disabled={!isModified}
className="px-3 py-1 rounded text-xs border border-[var(--v5-border)]
text-[var(--v5-text-sec)] bg-[var(--v5-surface)]
hover:border-[var(--v5-primary)] hover:text-[var(--v5-primary)]
disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
</button>
<button
type="submit"
className="px-3 py-1 rounded text-xs font-medium text-white
bg-[var(--v5-primary)] hover:opacity-90
shadow-[var(--v5-glow-sm)] transition-all"
>
{config.saveAction.method === 'INSERT' ? '등록' : '저장'}
</button>
</div>
</form>
);
}