181 lines
5.6 KiB
TypeScript
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>
|
|
);
|
|
}
|