// components/CrudFormModal.tsx import React, { useEffect, useState } from 'react'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { Checkbox } from '@/components/ui/checkbox'; import { Switch } from '@/components/ui/switch'; import { FormField } from '@/types/crud'; import { MultiSelectField } from '@/components/multi-select-field'; import { ScrollArea } from '@/components/ui/scroll-area'; import { cn } from '@/lib/utils'; import { useTranslation } from 'react-i18next'; import MediaPicker from '@/components/MediaPicker'; import DependentDropdown from '@/components/DependentDropdown'; interface CrudFormModalProps { isOpen: boolean; onClose: () => void; onSubmit: (data: any) => void; formConfig: { fields: FormField[]; modalSize?: string; columns?: number; layout?: 'grid' | 'flex' | 'default'; priceSummary?: { unitPrice: number; quantity: number; quantityFieldName?: string; }; }; initialData?: any; title: string; mode: 'create' | 'edit' | 'view'; description?: string; errors?: Record; submitButtonText?: string; } // Standalone date input that opens picker on any click function DateInputField({ field, dateValue, handleChange, errors, mode }: { field: FormField; dateValue: string; handleChange: (name: string, value: any) => void; errors: Record; mode: string; }) { const inputRef = React.useRef(null); const openPicker = () => { if (!field.disabled && mode !== 'view' && inputRef.current) { try { inputRef.current.showPicker?.(); } catch { inputRef.current.focus(); } } }; return (
handleChange(field.name, e.target.value)} required={!!field.required} className={`border-input flex h-9 w-full rounded-md border bg-transparent px-3 py-1 text-base shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-primary md:text-sm cursor-pointer disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 ${errors[field.name] ? 'border-red-500' : ''}`} disabled={field.disabled || mode === 'view'} readOnly={field.readOnly} />
); } export function CrudFormModal({ isOpen, onClose, onSubmit, formConfig, initialData = {}, title, mode, description, errors: backendErrors = {}, submitButtonText }: CrudFormModalProps) { const { t } = useTranslation(); const [formData, setFormData] = useState>({}); const [errors, setErrors] = useState>({}); const [relationOptions, setRelationOptions] = useState>({}); // Calculate total price for price summary const calculateTotal = () => { if (!formConfig.priceSummary) return 0; const quantity = formData[formConfig.priceSummary.quantityFieldName || 'quantity'] || formConfig.priceSummary.quantity || 1; return formConfig.priceSummary.unitPrice * quantity; }; // Load initial data when modal opens useEffect(() => { if (isOpen) { // Create a clean copy of the initial data const cleanData = { ...initialData }; // Process fields and set default values formConfig.fields.forEach(field => { if (field.type === 'multi-select') { if (cleanData[field.name] && !Array.isArray(cleanData[field.name])) { // Convert to array if it's not already cleanData[field.name] = Array.isArray(cleanData[field.name]) ? cleanData[field.name] : cleanData[field.name] ? [cleanData[field.name].toString()] : []; } } // Set default values for fields that don't have values yet (create mode) if (mode === 'create' && (cleanData[field.name] === undefined || cleanData[field.name] === null)) { if (field.defaultValue !== undefined) { cleanData[field.name] = field.defaultValue; } } }); setFormData(cleanData || {}); setErrors({}); // Load relation data for select fields formConfig.fields.forEach(field => { if (field.relation && field.relation.endpoint) { fetch(field.relation.endpoint) .then(res => res.json()) .then(data => { setRelationOptions(prev => ({ ...prev, [field.name]: Array.isArray(data) ? data : data.data || [] })); }) .catch(err => { // Silent error handling }); } }); } }, [isOpen, initialData, formConfig.fields, mode]); // Update errors when backend errors change useEffect(() => { if (backendErrors && Object.keys(backendErrors).length > 0) { const processedErrors: Record = {}; Object.entries(backendErrors).forEach(([key, value]) => { processedErrors[key] = Array.isArray(value) ? value[0] : value; }); setErrors(processedErrors); } }, [backendErrors]); const handleChange = (name: string, value: any) => { setFormData(prev => ({ ...prev, [name]: value })); // Clear error when field is changed if (errors[name]) { setErrors(prev => { const newErrors = { ...prev }; delete newErrors[name]; return newErrors; }); } }; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); // Process form data before validation const processedData = { ...formData }; // Ensure multi-select fields are properly formatted formConfig.fields.forEach(field => { if (field.type === 'multi-select' && processedData[field.name]) { // Make sure it's an array of strings if (!Array.isArray(processedData[field.name])) { processedData[field.name] = [processedData[field.name].toString()]; } } }); setFormData(processedData); // Basic validation const newErrors: Record = {}; formConfig.fields.forEach(field => { // For file fields in edit mode, they're never required if (field.type === 'file' && mode === 'edit') { return; } // Check if field is conditionally required based on other field values const isConditionallyRequired = field.conditional ? field.conditional(mode, formData) : true; if (field.required && isConditionallyRequired && !formData[field.name]) { newErrors[field.name] = `${field.label} is required`; } // File validation if (field.type === 'file' && formData[field.name] && field.fileValidation) { const file = formData[field.name]; // Check file size if (field.fileValidation.maxSize && file.size > field.fileValidation.maxSize) { const maxSizeMB = field.fileValidation.maxSize / (1024 * 1024); newErrors[field.name] = `File size must be less than ${maxSizeMB}MB`; } // Check mime type if (field.fileValidation.mimeTypes && field.fileValidation.mimeTypes.length > 0) { if (!field.fileValidation.mimeTypes.includes(file.type)) { newErrors[field.name] = `File type must be one of: ${field.fileValidation.mimeTypes.join(', ')}`; } } // Check extension if (field.fileValidation.extensions && field.fileValidation.extensions.length > 0) { const fileName = file.name; const fileExt = fileName.substring(fileName.lastIndexOf('.')).toLowerCase(); if (!field.fileValidation.extensions.includes(fileExt)) { newErrors[field.name] = `File extension must be one of: ${field.fileValidation.extensions.join(', ')}`; } } } }); if (Object.keys(newErrors).length > 0) { setErrors(newErrors); return; } // Create a clean copy without any unexpected properties const cleanData = { ...formData }; // Process multi-select fields before submission formConfig.fields.forEach(field => { if (field.type === 'multi-select' && cleanData[field.name]) { // Ensure it's an array of strings if (!Array.isArray(cleanData[field.name])) { cleanData[field.name] = [cleanData[field.name].toString()]; } } }); onSubmit(cleanData); }; const renderField = (field: FormField) => { // Check if field should be conditionally rendered if (field.conditional && !field.conditional(mode, formData)) { return null; } // If field has custom render function, use it if (field.render) { return field.render(field, formData, handleChange); } // If in view mode, render as read-only if (mode === 'view') { // Special handling for multi-select fields if (field.type === 'multi-select') { const selectedValues = Array.isArray(formData[field.name]) ? formData[field.name] : []; const selectedLabels = selectedValues .map((value: string) => { const option = field.options?.find(opt => opt.value === value); return option ? option.label : value; }) .join(', '); return (
{selectedLabels || '-'}
); } // For other field types return (
{field.type === 'select' && field.options ? field.options.find(opt => opt.value === String(formData[field.name]))?.label || formData[field.name] || '-' : field.type === 'checkbox' ? (formData[field.name] === true || formData[field.name] === 1 || formData[field.name] === '1' ? t('Yes') : t('No')) : formData[field.name] || '-'}
); } switch (field.type) { case 'text': case 'email': case 'password': case 'color': return ( handleChange(field.name, e.target.value)} required={field.required} className={errors[field.name] ? 'border-red-500' : ''} disabled={field.disabled || mode === 'view'} readOnly={field.readOnly} /> ); case 'time': return (
{ const input = (e.currentTarget as HTMLElement).querySelector('input'); try { (input as any)?.showPicker?.(); } catch { input?.focus(); } }} > handleChange(field.name, e.target.value)} required={field.required} className={`cursor-pointer${errors[field.name] ? ' border-red-500' : ''}`} disabled={field.disabled || mode === 'view'} readOnly={field.readOnly} />
); case 'date': // Format date value for input (YYYY-MM-DD format) const dateValue = formData[field.name] ? (formData[field.name] instanceof Date ? formData[field.name].toISOString().split('T')[0] : (typeof formData[field.name] === 'string' && formData[field.name].includes('T') ? formData[field.name].split('T')[0] : formData[field.name])) : ''; return ( ); case 'number': return ( handleChange(field.name, e.target.value ? parseFloat(e.target.value) : '')} required={field.required} className={errors[field.name] ? 'border-red-500' : ''} disabled={field.disabled || mode === 'view'} readOnly={field.readOnly} /> ); case 'textarea': return (