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

793 lines
29 KiB
TypeScript
Executable File

// 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<string, string | string[]>;
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<string, string>;
mode: string;
}) {
const inputRef = React.useRef<HTMLInputElement>(null);
const openPicker = () => {
if (!field.disabled && mode !== 'view' && inputRef.current) {
try {
inputRef.current.showPicker?.();
} catch {
inputRef.current.focus();
}
}
};
return (
<div className="relative cursor-pointer" onClick={openPicker}>
<input
ref={inputRef}
id={field.name}
name={field.name}
type="date"
placeholder={field.placeholder}
value={dateValue}
onChange={(e) => 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}
/>
</div>
);
}
export function CrudFormModal({
isOpen,
onClose,
onSubmit,
formConfig,
initialData = {},
title,
mode,
description,
errors: backendErrors = {},
submitButtonText
}: 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[]>>({});
// 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<string, string> = {};
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<string, string> = {};
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 (
<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] || '-' :
field.type === 'checkbox' ?
(formData[field.name] === true || formData[field.name] === 1 || formData[field.name] === '1' ? t('Yes') : t('No')) :
formData[field.name] || '-'}
</div>
);
}
switch (field.type) {
case 'text':
case 'email':
case 'password':
case 'color':
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={field.disabled || mode === 'view'}
readOnly={field.readOnly}
/>
);
case 'time':
return (
<div
className="cursor-pointer"
onClick={(e) => {
const input = (e.currentTarget as HTMLElement).querySelector('input');
try { (input as any)?.showPicker?.(); } catch { input?.focus(); }
}}
>
<Input
id={field.name}
name={field.name}
type="time"
placeholder={field.placeholder}
value={formData[field.name] || ''}
onChange={(e) => 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}
/>
</div>
);
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 (
<DateInputField
field={field}
dateValue={dateValue}
handleChange={handleChange}
errors={errors}
mode={mode}
/>
);
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={field.disabled || mode === 'view'}
readOnly={field.readOnly}
/>
);
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={field.disabled || mode === 'view'}
readOnly={field.readOnly}
/>
);
case 'select':
const options = field.relation
? relationOptions[field.name] || []
: field.options || [];
const currentValue = String(formData[field.name] || '');
const selectedOption = field.relation
? options.find((opt: any) => String(opt[field.relation!.valueField]) === currentValue)
: options.find((opt) => String(opt.value) === currentValue);
const displayText = selectedOption
? (field.relation ? selectedOption[field.relation!.labelField] : selectedOption.label)
: '';
return (
<Select
value={currentValue}
onValueChange={(value) => handleChange(field.name, value)}
disabled={mode === 'view'}
>
<SelectTrigger className={errors[field.name] ? 'border-red-500' : ''}>
<SelectValue placeholder={field.placeholder || `Select ${field.label}`}>
{displayText || (field.placeholder || `Select ${field.label}`)}
</SelectValue>
</SelectTrigger>
<SelectContent className="z-[60000]" searchable={field.searchable}>
{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={String(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 'switch':
// Don't render any label here, it will be handled by the parent component
return (
<Switch
id={field.name}
checked={!!formData[field.name]}
onCheckedChange={(checked) => handleChange(field.name, checked)}
disabled={mode === 'view'}
/>
);
case 'multi-select':
return (
<MultiSelectField
field={field}
formData={formData}
handleChange={handleChange}
/>
);
case 'media-picker':
const currentImageUrl = formData[field.name] ||
(mode === 'edit' && initialData[field.name] ?
(initialData[field.name].startsWith('http') ? initialData[field.name] : `/storage/${initialData[field.name]}`) :
'');
return (
<MediaPicker
value={currentImageUrl}
onChange={(value) => handleChange(field.name, value)}
placeholder={field.placeholder || `Select ${field.label}`}
showPreview={true}
/>
);
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">
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">{t("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>
)}
</>
);
case 'dependent-dropdown':
// Create values object dynamically based on field names
const dependentValues: Record<string, string> = {};
field.dependentConfig?.forEach((depField) => {
dependentValues[depField.name] = formData[depField.name] || '';
});
return (
<DependentDropdown
fields={field.dependentConfig || []}
values={dependentValues}
onChange={(fieldName, value, additionalData) => {
setFormData((prev) => {
const newData = { ...prev, [fieldName]: value };
// Reset dependent fields when parent changes
const fieldIndex = field.dependentConfig?.findIndex((f) => f.name === fieldName) ?? -1;
if (fieldIndex !== -1 && field.dependentConfig) {
field.dependentConfig.slice(fieldIndex + 1).forEach((depField) => {
newData[depField.name] = '';
});
}
return newData;
});
// Call custom onChange if provided with parent info
if (field.onDependentChange) {
field.onDependentChange(fieldName, value, formData, additionalData);
}
}}
/>
);
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;
const modalId = `crud-modal-${mode}-${title.replace(/\s+/g, '-').toLowerCase()}-${Date.now()}`;
// Check if ChatGPT modal is open
const [isChatGptOpen, setIsChatGptOpen] = useState(false);
useEffect(() => {
const checkChatGpt = () => {
const chatGptModal = document.querySelector('[data-chatgpt-modal]');
setIsChatGptOpen(!!chatGptModal);
};
const observer = new MutationObserver(checkChatGpt);
observer.observe(document.body, { childList: true, subtree: true });
return () => observer.disconnect();
}, []);
return (
<Dialog open={isOpen} onOpenChange={(open) => { if (!open) onClose(); }} modal={!isChatGptOpen}>
<DialogContent className={`${getModalSizeClass()} max-h-[90vh]`} modalId={modalId}>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description || " "}</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[70vh] pr-4">
<form onSubmit={handleSubmit} className="space-y-4">
{/* Price Summary Section */}
{formConfig.priceSummary && (
<div className="bg-gray-50 p-4 rounded-lg mb-4">
<div className="flex justify-between items-center mb-2">
<span className="text-sm text-gray-600">{t("Unit Price")}:</span>
<span className="font-medium">${formConfig.priceSummary.unitPrice.toFixed(2)}</span>
</div>
<div className="flex justify-between items-center mb-2">
<span className="text-sm text-gray-600">{t("Quantity")}:</span>
<span className="font-medium">{formData[formConfig.priceSummary.quantityFieldName || 'quantity'] || formConfig.priceSummary.quantity || 1}</span>
</div>
<div className="border-t pt-2">
<div className="flex justify-between items-center">
<span className="font-semibold">{t("Total Price")}:</span>
<span className="font-bold text-lg text-primary">
${calculateTotal().toFixed(2)}
</span>
</div>
</div>
</div>
)}
{layout === 'grid' ? (
<div style={{ display: 'grid', gridTemplateColumns: `repeat(${columns}, 1fr)`, gap: '1rem' }}>
{formConfig.fields.map((field) => {
if (field.conditional && !field.conditional(mode, formData)) {
return null;
}
return (
<div
key={field.name}
className="space-y-2"
style={{
gridColumn: field.colSpan ? `span ${field.colSpan}` : 'span 1',
width: '100%'
}}
>
{(field.type !== 'checkbox' && field.type !== 'switch') || mode === 'view' ? (
<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>
) : null}
{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) => {
if (field.conditional && !field.conditional(mode, formData)) {
return null;
}
return (
<div
key={field.name}
className="space-y-2"
style={{
width: field.width || "100%",
flexGrow: field.width ? 0 : 1
}}
>
{(field.type !== 'checkbox' && field.type !== 'switch') || mode === 'view' ? (
<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>
) : null}
{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) => {
if (field.conditional && !field.conditional(mode, formData)) {
return null;
}
return (
<div
key={field.name}
className="space-y-2"
style={{ width: field.width || "100%" }}
>
{(field.type !== 'checkbox') || mode === 'view' ? (
<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>
) : null}
{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}>{submitButtonText || t("Save")}</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}