Files
HRM-System/resources/js/components/CrudFormModal copy.tsx
2026-04-13 08:16:56 +08:00

544 lines
19 KiB
TypeScript
Executable File

// components/CrudFormModal.tsx
import { 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 { FormField } from '@/types/crud';
import { MultiSelectField } from '@/components/multi-select-field';
import { ScrollArea } from '@/components/ui/scroll-area';
import { cn } from '@/lib/utils';
import { MediaPicker } from '@/components/MediaPicker';
import { useTranslation } from 'react-i18next';
interface CrudFormModalProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (data: any) => void;
formConfig: {
fields: FormField[];
modalSize?: string;
columns?: number;
layout?: 'grid' | 'flex' | 'default';
};
initialData?: any;
title: string;
mode: 'create' | 'edit' | 'view';
description?: string;
}
export function CrudFormModal({
isOpen,
onClose,
onSubmit,
formConfig,
initialData = {},
title,
mode,
description
}: CrudFormModalProps) {
const { t } = useTranslation();
const [formData, setFormData] = useState<Record<string, any>>({});
const [errors, setErrors] = useState<Record<string, string>>({});
const [relationOptions, setRelationOptions] = useState<Record<string, any[]>>({});
// Load initial data when modal opens
useEffect(() => {
if (isOpen) {
// Create a clean copy of the initial data
const cleanData = { ...initialData };
// Process multi-select fields in initial data
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()] : [];
}
}
});
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]);
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<string, string> = {};
formConfig.fields.forEach(field => {
// For file fields in edit mode, they're never required
if (field.type === 'file' && mode === 'edit') {
return;
}
if (field.required && !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) => {
// 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 (
<div className="p-2 border rounded-md bg-gray-50">
{selectedLabels || '-'}
</div>
);
}
// For other field types
return (
<div className="p-2 border rounded-md bg-gray-50">
{field.type === 'select' && field.options ?
field.options.find(opt => opt.value === String(formData[field.name]))?.label || formData[field.name] || '-' :
formData[field.name] || '-'}
</div>
);
}
switch (field.type) {
case 'text':
case 'email':
case 'password':
case 'date':
return (
<Input
id={field.name}
name={field.name}
type={field.type}
placeholder={field.placeholder}
value={formData[field.name] || ''}
onChange={(e) => handleChange(field.name, e.target.value)}
required={field.required}
className={errors[field.name] ? 'border-red-500' : ''}
disabled={mode === 'view'}
/>
);
case 'number':
return (
<Input
id={field.name}
name={field.name}
type="number"
placeholder={field.placeholder}
value={formData[field.name] || ''}
onChange={(e) => handleChange(field.name, e.target.value ? parseFloat(e.target.value) : '')}
required={field.required}
className={errors[field.name] ? 'border-red-500' : ''}
disabled={mode === 'view'}
/>
);
case 'textarea':
return (
<Textarea
id={field.name}
name={field.name}
placeholder={field.placeholder}
value={formData[field.name] || ''}
onChange={(e) => handleChange(field.name, e.target.value)}
required={field.required}
className={errors[field.name] ? 'border-red-500' : ''}
disabled={mode === 'view'}
/>
);
case 'select':
const options = field.relation
? relationOptions[field.name] || []
: field.options || [];
return (
<Select
value={String(formData[field.name] || '')}
onValueChange={(value) => handleChange(field.name, value)}
disabled={mode === 'view'}
>
<SelectTrigger className={errors[field.name] ? 'border-red-500' : ''}>
<SelectValue placeholder={`Select ${field.label}`} />
</SelectTrigger>
<SelectContent>
{field.relation ? (
options.map((option: any) => (
<SelectItem
key={option[field.relation!.valueField]}
value={String(option[field.relation!.valueField])}
>
{option[field.relation!.labelField]}
</SelectItem>
))
) : (
options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))
)}
</SelectContent>
</Select>
);
case 'radio':
return (
<RadioGroup
value={formData[field.name] || ''}
onValueChange={(value) => handleChange(field.name, value)}
disabled={mode === 'view'}
className="flex gap-4"
>
{field.options?.map((option) => (
<div key={option.value} className="flex items-center space-x-2">
<RadioGroupItem value={option.value} id={`${field.name}-${option.value}`} />
<Label htmlFor={`${field.name}-${option.value}`}>{option.label}</Label>
</div>
))}
</RadioGroup>
);
case 'checkbox':
return (
<div className="flex items-center space-x-2">
<Checkbox
id={field.name}
checked={!!formData[field.name]}
onCheckedChange={(checked) => handleChange(field.name, checked)}
disabled={mode === 'view'}
/>
<Label htmlFor={field.name}>{field.placeholder || field.label}</Label>
</div>
);
case 'multi-select':
return (
<MultiSelectField
field={field}
formData={formData}
handleChange={handleChange}
/>
);
case 'media-picker':
return (
<MediaPicker
value={formData[field.name] || ''}
onChange={(value) => handleChange(field.name, value)}
multiple={field.multiple || false}
placeholder={field.placeholder || `Select ${field.label.toLowerCase()}...`}
/>
);
case 'file':
const acceptAttr = field.fileValidation?.accept || '';
const isImageFile = acceptAttr.includes('image') ||
(field.fileValidation?.mimeTypes?.some(type => type.startsWith('image/')) ?? false);
return (
<>
<Input
id={field.name}
name={field.name}
type="file"
accept={acceptAttr}
onChange={(e) => {
if (e.target.files && e.target.files[0]) {
handleChange(field.name, e.target.files[0]);
}
}}
className={errors[field.name] ? 'border-red-500' : ''}
disabled={mode === 'view'}
/>
{mode === 'edit' && initialData[field.name] && (
<div className="text-xs text-gray-500 mt-1">
{t("Current file:")} {initialData.featured_image_original_name || initialData[field.name]}
</div>
)}
{field.fileValidation && (
<div className="text-xs text-gray-500 mt-1">
{field.fileValidation.extensions && (
<span>{t("Allowed extensions:")} {field.fileValidation.extensions.join(', ')} </span>
)}
{field.fileValidation.maxSize && (
<span>{t("Max size:")} {(field.fileValidation.maxSize / (1024 * 1024)).toFixed(1)}MB</span>
)}
</div>
)}
{/* Image preview for image files */}
{isImageFile && (
<div className="mt-2">
{formData[field.name] && formData[field.name] instanceof File ? (
// Preview for newly selected file
<div className="mt-2">
<p className="text-xs text-gray-500 mb-1">Preview:</p>
<img
src={URL.createObjectURL(formData[field.name])}
alt="Preview"
className="h-24 w-auto rounded-md object-cover shadow-sm"
/>
</div>
) : mode === 'edit' && initialData[field.name] && (
// Show existing image in edit mode
<div className="mt-2">
<p className="text-xs text-gray-500 mb-1">{t("Current image:")}</p>
<img
src={typeof initialData[field.name] === 'string' && initialData[field.name].startsWith && initialData[field.name].startsWith('http')
? initialData[field.name]
: `/storage/${initialData[field.name]}`}
alt="Current"
className="h-24 w-auto rounded-md object-cover shadow-sm"
onError={(e) => {
e.currentTarget.src = 'https://placehold.co/200x150?text=Image+Not+Found';
}}
/>
</div>
)}
</div>
)}
</>
);
default:
return null;
}
};
// Map modal size to appropriate width class
const getModalSizeClass = () => {
const sizeMap: Record<string, string> = {
'sm': 'sm:max-w-sm',
'md': 'sm:max-w-md',
'lg': 'sm:max-w-lg',
'xl': 'sm:max-w-xl',
'2xl': 'sm:max-w-2xl',
'3xl': 'sm:max-w-3xl',
'4xl': 'sm:max-w-4xl',
'5xl': 'sm:max-w-5xl',
'full': 'sm:max-w-full'
};
return formConfig.modalSize ? sizeMap[formConfig.modalSize] : 'sm:max-w-md';
};
// Group fields by row if specified
const groupFieldsByRow = () => {
const rows: Record<number, FormField[]> = {};
formConfig.fields.forEach(field => {
const rowNumber = field.row || 0;
if (!rows[rowNumber]) {
rows[rowNumber] = [];
}
rows[rowNumber].push(field);
});
return Object.entries(rows).sort(([a], [b]) => parseInt(a) - parseInt(b));
};
// Determine the layout type
const layout = formConfig.layout || 'default';
const columns = formConfig.columns || 1;
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className={`${getModalSizeClass()} max-h-[90vh]`}>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description && <DialogDescription>{description}</DialogDescription>}
</DialogHeader>
<ScrollArea className="max-h-[70vh] pr-4">
<form onSubmit={handleSubmit} className="space-y-4">
{layout === 'grid' ? (
<div style={{ display: 'grid', gridTemplateColumns: `repeat(${columns}, 1fr)`, gap: '1rem' }}>
{formConfig.fields.map((field) => (
<div
key={field.name}
className="space-y-2"
style={{
gridColumn: field.colSpan ? `span ${field.colSpan}` : 'span 1',
width: '100%'
}}
>
<Label htmlFor={field.name} className="text-sm font-medium">
{field.label} {field.required && !(field.type === 'file' && mode === 'edit') && <span className="text-red-500">*</span>}
</Label>
{renderField(field)}
{errors[field.name] && (
<p className="text-xs text-red-500">{errors[field.name]}</p>
)}
</div>
))}
</div>
) : layout === 'flex' ? (
<div className="flex flex-wrap gap-4">
{formConfig.fields.map((field) => (
<div
key={field.name}
className="space-y-2"
style={{
width: field.width || "100%",
flexGrow: field.width ? 0 : 1
}}
>
<Label htmlFor={field.name} className="text-sm font-medium">
{field.label} {field.required && !(field.type === 'file' && mode === 'edit') && <span className="text-red-500">*</span>}
</Label>
{renderField(field)}
{errors[field.name] && (
<p className="text-xs text-red-500">{errors[field.name]}</p>
)}
</div>
))}
</div>
) : (
// Default layout with row grouping
groupFieldsByRow().map(([rowNumber, fields]) => (
<div key={rowNumber} className="flex flex-wrap gap-4 mb-4">
{fields.map((field) => (
<div
key={field.name}
className="space-y-2"
style={{ width: field.width || "100%" }}
>
<Label htmlFor={field.name} className="text-sm font-medium">
{field.label} {field.required && !(field.type === 'file' && mode === 'edit') && <span className="text-red-500">*</span>}
</Label>
{renderField(field)}
{errors[field.name] && (
<p className="text-xs text-red-500">{errors[field.name]}</p>
)}
</div>
))}
</div>
))
)}
</form>
</ScrollArea>
<DialogFooter className="sm:justify-end">
<Button type="button" variant="outline" onClick={onClose}>
{t("Cancel")}
</Button>
{mode !== 'view' && (
<Button type="button" onClick={handleSubmit}>{t("Save")}</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}