Spaces:
Sleeping
Sleeping
| import React, { useState, useRef, useEffect } from 'react'; | |
| import { useForm } from 'react-hook-form'; | |
| import { zodResolver } from '@hookform/resolvers/zod'; | |
| import { format } from 'date-fns'; | |
| import { Button } from '@/components/ui/button'; | |
| import { Textarea } from '@/components/ui/textarea'; | |
| import { Calendar } from '@/components/ui/calendar'; | |
| import { | |
| Form, | |
| FormControl, | |
| FormField, | |
| FormItem, | |
| FormLabel, | |
| FormMessage, | |
| FormDescription, | |
| } from '@/components/ui/form'; | |
| import { SearchableDropdown } from './shared/SearchableDropdown'; | |
| import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; | |
| import { Loader2, Save, X, Calendar as CalendarIcon, User } from 'lucide-react'; | |
| import { | |
| AssetAssignment, | |
| AssetAssignmentCreateRequest, | |
| AssetAssignmentUpdateRequest, | |
| AssetAssignmentFormSchema, | |
| } from '@/types/asset-management'; | |
| import { Employee } from '@/types'; | |
| import { useAssets } from '@/hooks/asset-management/useAssets'; | |
| // Custom DatePicker component that works in modals | |
| interface CustomDatePickerProps { | |
| value: Date | null; | |
| onChange: (date: Date | null) => void; | |
| placeholder?: string; | |
| disabled?: (date: Date) => boolean; | |
| } | |
| const CustomDatePicker: React.FC<CustomDatePickerProps> = ({ | |
| value, | |
| onChange, | |
| placeholder = "Pick a date", | |
| disabled | |
| }) => { | |
| const [isOpen, setIsOpen] = useState(false); | |
| const datePickerRef = useRef<HTMLDivElement>(null); | |
| // Close date picker when clicking outside | |
| useEffect(() => { | |
| const handleClickOutside = (event: MouseEvent) => { | |
| if (datePickerRef.current && !datePickerRef.current.contains(event.target as Node)) { | |
| setIsOpen(false); | |
| } | |
| }; | |
| if (isOpen) { | |
| document.addEventListener("mousedown", handleClickOutside); | |
| return () => { | |
| document.removeEventListener("mousedown", handleClickOutside); | |
| }; | |
| } | |
| }, [isOpen]); | |
| return ( | |
| <div className="relative w-full" ref={datePickerRef}> | |
| <button | |
| type="button" | |
| onClick={() => setIsOpen(!isOpen)} | |
| className="flex w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 min-h-[44px] md:min-h-[40px] touch-manipulation" | |
| aria-label={value ? `Selected date: ${format(value, "PPP")}` : placeholder} | |
| > | |
| <span className={value ? "" : "text-muted-foreground"}> | |
| {value ? format(value, "PPP") : placeholder} | |
| </span> | |
| <CalendarIcon className="h-4 w-4 opacity-50" /> | |
| </button> | |
| {isOpen && ( | |
| <div className="fixed inset-0 z-[99999] flex justify-center items-center bg-black/50 overflow-auto"> | |
| <div className="bg-background border rounded-lg p-4 shadow-lg max-w-sm w-full mx-4"> | |
| <div className="flex justify-between items-center mb-2"> | |
| <h3 className="text-base font-medium">Select Date</h3> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| className="h-8 w-8 p-0" | |
| onClick={() => setIsOpen(false)} | |
| > | |
| <X className="h-4 w-4" /> | |
| </Button> | |
| </div> | |
| <Calendar | |
| mode="single" | |
| selected={value || undefined} | |
| onSelect={(date) => { | |
| onChange(date || null); | |
| setIsOpen(false); | |
| }} | |
| disabled={disabled} | |
| initialFocus | |
| className="rounded border" | |
| /> | |
| <div className="mt-2 flex justify-end gap-2"> | |
| <Button | |
| size="sm" | |
| variant="outline" | |
| onClick={() => { | |
| onChange(null); | |
| setIsOpen(false); | |
| }} | |
| className="min-h-[44px] md:min-h-0 touch-manipulation" | |
| aria-label="Clear date selection" | |
| > | |
| Clear | |
| </Button> | |
| <Button | |
| size="sm" | |
| variant="outline" | |
| onClick={() => setIsOpen(false)} | |
| className="min-h-[44px] md:min-h-0 touch-manipulation" | |
| aria-label="Close date picker" | |
| > | |
| Close | |
| </Button> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| interface AssignmentFormProps { | |
| assignment?: AssetAssignment; | |
| preselectedAssetId?: number; | |
| preselectedEmployeeId?: number; | |
| employees?: Employee[]; | |
| onSubmit: (data: AssetAssignmentCreateRequest | AssetAssignmentUpdateRequest) => Promise<void>; | |
| onCancel: () => void; | |
| isSubmitting?: boolean; | |
| title?: string; | |
| } | |
| type AssignmentFormValues = { | |
| assetID: number; | |
| employeeID: number; | |
| employeeName: string; | |
| assetCode: string; | |
| assignedDate: Date; | |
| returnedDate: Date | null; | |
| remarks: string; | |
| }; | |
| const AssignmentForm: React.FC<AssignmentFormProps> = ({ | |
| assignment, | |
| preselectedAssetId, | |
| preselectedEmployeeId, | |
| employees = [], | |
| onSubmit, | |
| onCancel, | |
| isSubmitting = false, | |
| title | |
| }) => { | |
| const isEditing = !!assignment; | |
| const formTitle = title || (isEditing ? 'Edit Assignment' : 'Create New Assignment'); | |
| const { data: assets = [], isLoading: isLoadingAssets } = useAssets(); | |
| const employeeFieldRef = useRef<HTMLButtonElement>(null); | |
| const form = useForm<AssignmentFormValues>({ | |
| resolver: zodResolver(AssetAssignmentFormSchema), | |
| defaultValues: { | |
| assetID: assignment?.assetID || preselectedAssetId || 0, | |
| employeeID: assignment?.employeeID || preselectedEmployeeId || 0, | |
| employeeName: assignment?.employeeName || '', | |
| assetCode: assignment?.assetCode || '', | |
| assignedDate: assignment ? new Date(assignment.assignedDate) : new Date(), | |
| returnedDate: assignment?.returnedDate ? new Date(assignment.returnedDate) : null, | |
| remarks: assignment?.remarks || '', | |
| }, | |
| }); | |
| // Get preselected asset details | |
| const preselectedAsset = preselectedAssetId | |
| ? assets.find(a => a.assetID === preselectedAssetId) | |
| : null; | |
| // Auto-populate asset field when preselectedAssetId is provided | |
| useEffect(() => { | |
| if (preselectedAssetId && assets.length > 0 && !assignment) { | |
| const asset = assets.find(a => a.assetID === preselectedAssetId); | |
| if (asset) { | |
| form.setValue('assetID', asset.assetID); | |
| form.setValue('assetCode', asset.assetCode); | |
| } | |
| } | |
| }, [preselectedAssetId, assets, assignment, form]); | |
| // Auto-focus employee selection field when asset is pre-selected | |
| useEffect(() => { | |
| if (preselectedAssetId && !assignment && employeeFieldRef.current) { | |
| // Small delay to ensure the form is fully rendered | |
| const timer = setTimeout(() => { | |
| employeeFieldRef.current?.focus(); | |
| }, 100); | |
| return () => clearTimeout(timer); | |
| } | |
| }, [preselectedAssetId, assignment]); | |
| const handleSubmit = async (values: AssignmentFormValues) => { | |
| try { | |
| // Validate dates before submission | |
| if (values.returnedDate && values.returnedDate <= values.assignedDate) { | |
| form.setError('returnedDate', { | |
| type: 'manual', | |
| message: 'Returned date must be after the assigned date.' | |
| }); | |
| return; | |
| } | |
| // Convert dates to ISO strings | |
| const assignedDateISO = values.assignedDate.toISOString(); | |
| const returnedDateISO = values.returnedDate ? values.returnedDate.toISOString() : null; | |
| if (isEditing && assignment) { | |
| const updateData: AssetAssignmentUpdateRequest = { | |
| assignmentID: assignment.assignmentID, | |
| assetID: values.assetID, | |
| employeeID: values.employeeID, | |
| employeeName: values.employeeName, | |
| assetCode: values.assetCode, | |
| assignedDate: assignedDateISO, | |
| returnedDate: returnedDateISO, | |
| remarks: values.remarks, | |
| }; | |
| await onSubmit(updateData); | |
| } else { | |
| const createData: AssetAssignmentCreateRequest = { | |
| assetID: values.assetID, | |
| employeeID: values.employeeID, | |
| employeeName: values.employeeName, | |
| assetCode: values.assetCode, | |
| assignedDate: assignedDateISO, | |
| returnedDate: returnedDateISO, | |
| remarks: values.remarks, | |
| }; | |
| await onSubmit(createData); | |
| } | |
| } catch (error) { | |
| console.error('Form submission error:', error); | |
| // Set form errors if validation failed | |
| if (error instanceof Error) { | |
| if (error.message.includes('Asset with ID') && error.message.includes('does not exist')) { | |
| form.setError('assetID', { | |
| type: 'manual', | |
| message: 'The selected asset does not exist. Please select a valid asset.' | |
| }); | |
| } else if (error.message.includes('Employee with ID') && error.message.includes('does not exist')) { | |
| form.setError('employeeID', { | |
| type: 'manual', | |
| message: 'The selected employee does not exist. Please select a valid employee.' | |
| }); | |
| } else if (error.message.includes('already assigned')) { | |
| form.setError('assetID', { | |
| type: 'manual', | |
| message: 'This asset is already assigned to another employee. Please select a different asset or return the current assignment first.' | |
| }); | |
| } else if (error.message.includes('date')) { | |
| form.setError('assignedDate', { | |
| type: 'manual', | |
| message: 'Invalid date format. Please select a valid date.' | |
| }); | |
| } else { | |
| // Set a general form error | |
| form.setError('root', { | |
| type: 'manual', | |
| message: error.message | |
| }); | |
| } | |
| } | |
| } | |
| }; | |
| const getEmployeeDisplayName = (employee: Employee) => { | |
| return `${employee.firstName} ${employee.lastName}`; | |
| }; | |
| return ( | |
| <Card className="w-full mx-auto"> | |
| <CardHeader> | |
| <CardTitle className="flex items-center gap-2"> | |
| <User className="h-5 w-5" /> | |
| {formTitle} | |
| </CardTitle> | |
| </CardHeader> | |
| <CardContent> | |
| <Form {...form}> | |
| <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6"> | |
| {/* Display form-level errors */} | |
| {form.formState.errors.root && ( | |
| <div className="bg-destructive/15 border border-destructive/20 rounded-md p-3"> | |
| <p className="text-sm text-destructive font-medium"> | |
| {form.formState.errors.root.message} | |
| </p> | |
| </div> | |
| )} | |
| {/* Display pre-selected asset information prominently */} | |
| {preselectedAsset && !assignment && ( | |
| <div className="bg-primary/5 border border-primary/20 rounded-lg p-4"> | |
| <div className="flex items-start gap-3"> | |
| <div className="flex-shrink-0 w-10 h-10 bg-primary/10 rounded-full flex items-center justify-center"> | |
| <User className="h-5 w-5 text-primary" /> | |
| </div> | |
| <div className="flex-1 min-w-0"> | |
| <h4 className="text-sm font-semibold text-foreground mb-1"> | |
| Assigning Asset | |
| </h4> | |
| <div className="space-y-1"> | |
| <p className="text-sm text-muted-foreground"> | |
| <span className="font-medium text-foreground">Code:</span> {preselectedAsset.assetCode} | |
| </p> | |
| <p className="text-sm text-muted-foreground"> | |
| <span className="font-medium text-foreground">Type:</span> {preselectedAsset.assetType} | |
| </p> | |
| <p className="text-sm text-muted-foreground"> | |
| <span className="font-medium text-foreground">Model:</span> {preselectedAsset.brandModel} | |
| </p> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> | |
| {/* Asset Selection */} | |
| <FormField | |
| control={form.control} | |
| name="assetID" | |
| render={({ field }) => ( | |
| <FormItem> | |
| <FormLabel>Asset *</FormLabel> | |
| <FormDescription> | |
| {preselectedAssetId && !assignment | |
| ? 'Asset is pre-selected for this assignment.' | |
| : 'Select the asset to assign to the employee.'} | |
| </FormDescription> | |
| <FormControl> | |
| <SearchableDropdown | |
| options={assets.map(asset => ({ | |
| value: asset.assetID.toString(), | |
| label: `${asset.assetCode} - ${asset.assetType} - ${asset.brandModel}` | |
| }))} | |
| value={field.value?.toString() || ''} | |
| onChange={(value) => { | |
| const assetId = parseInt(value); | |
| field.onChange(assetId); | |
| // Auto-populate asset code when asset is selected | |
| const selectedAsset = assets.find(asset => asset.assetID === assetId); | |
| if (selectedAsset) { | |
| form.setValue('assetCode', selectedAsset.assetCode); | |
| } | |
| }} | |
| placeholder="Select an asset" | |
| searchPlaceholder="Search assets by code, type, or model..." | |
| disabled={isLoadingAssets || (!!preselectedAssetId && !assignment)} | |
| error={!!form.formState.errors.assetID} | |
| aria-label="Asset selection" | |
| aria-describedby={form.formState.errors.assetID ? 'assetID-error' : undefined} | |
| emptyMessage="No assets found matching your search" | |
| /> | |
| </FormControl> | |
| <FormMessage id="assetID-error" /> | |
| </FormItem> | |
| )} | |
| /> | |
| {/* Employee Selection */} | |
| <FormField | |
| control={form.control} | |
| name="employeeID" | |
| render={({ field }) => ( | |
| <FormItem> | |
| <FormLabel>Employee *</FormLabel> | |
| <FormDescription> | |
| Select the employee to assign the asset to. | |
| </FormDescription> | |
| <FormControl> | |
| <div ref={(el) => { | |
| if (el && preselectedAssetId && !assignment) { | |
| const trigger = el.querySelector('[role="combobox"]') as HTMLElement; | |
| if (trigger) { | |
| (employeeFieldRef as any).current = trigger; | |
| } | |
| } | |
| }}> | |
| <SearchableDropdown | |
| options={employees.map(employee => ({ | |
| value: employee.id.toString(), | |
| label: `#${employee.empCode} - ${getEmployeeDisplayName(employee)} - ${employee.email}` | |
| }))} | |
| value={field.value?.toString() || ''} | |
| onChange={(value) => { | |
| const employeeId = parseInt(value); | |
| field.onChange(employeeId); | |
| // Auto-populate employee name when employee is selected | |
| const selectedEmployee = employees.find(employee => employee.id === employeeId); | |
| if (selectedEmployee) { | |
| form.setValue('employeeName', getEmployeeDisplayName(selectedEmployee)); | |
| } | |
| }} | |
| placeholder="Select an employee" | |
| searchPlaceholder="Search employees by name, email, or ID..." | |
| error={!!form.formState.errors.employeeID} | |
| aria-label="Employee selection" | |
| aria-describedby={form.formState.errors.employeeID ? 'employeeID-error' : undefined} | |
| emptyMessage="No employees found matching your search" | |
| /> | |
| </div> | |
| </FormControl> | |
| <FormMessage id="employeeID-error" /> | |
| </FormItem> | |
| )} | |
| /> | |
| {/* Employee Name (Auto-populated) */} | |
| <FormField | |
| control={form.control} | |
| name="employeeName" | |
| render={({ field }) => ( | |
| <FormItem> | |
| <FormLabel>Employee Name *</FormLabel> | |
| <FormControl> | |
| <input | |
| {...field} | |
| type="text" | |
| placeholder="Employee name will be auto-populated" | |
| className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" | |
| readOnly | |
| /> | |
| </FormControl> | |
| <FormDescription> | |
| This field is automatically populated when you select an employee. | |
| </FormDescription> | |
| <FormMessage /> | |
| </FormItem> | |
| )} | |
| /> | |
| {/* Asset Code (Auto-populated) */} | |
| <FormField | |
| control={form.control} | |
| name="assetCode" | |
| render={({ field }) => ( | |
| <FormItem> | |
| <FormLabel>Asset Code *</FormLabel> | |
| <FormControl> | |
| <input | |
| {...field} | |
| type="text" | |
| placeholder="Asset code will be auto-populated" | |
| className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" | |
| readOnly | |
| /> | |
| </FormControl> | |
| <FormDescription> | |
| This field is automatically populated when you select an asset. | |
| </FormDescription> | |
| <FormMessage /> | |
| </FormItem> | |
| )} | |
| /> | |
| {/* Assigned Date */} | |
| <FormField | |
| control={form.control} | |
| name="assignedDate" | |
| render={({ field }) => ( | |
| <FormItem className="flex flex-col"> | |
| <FormLabel>Assigned Date *</FormLabel> | |
| <FormControl> | |
| <CustomDatePicker | |
| value={field.value} | |
| onChange={field.onChange} | |
| placeholder="Pick a date" | |
| disabled={(date) => date < new Date("1900-01-01")} | |
| /> | |
| </FormControl> | |
| <FormDescription> | |
| The date when the asset was assigned to the employee. | |
| </FormDescription> | |
| <FormMessage /> | |
| </FormItem> | |
| )} | |
| /> | |
| {/* Returned Date */} | |
| <FormField | |
| control={form.control} | |
| name="returnedDate" | |
| render={({ field }) => ( | |
| <FormItem className="flex flex-col"> | |
| <FormLabel>Returned Date</FormLabel> | |
| <FormControl> | |
| <CustomDatePicker | |
| value={field.value} | |
| onChange={field.onChange} | |
| placeholder="Not returned yet" | |
| disabled={(date) => { | |
| const assignedDate = form.getValues('assignedDate'); | |
| return ( | |
| date < new Date("1900-01-01") || | |
| (assignedDate && date < assignedDate) | |
| ); | |
| }} | |
| /> | |
| </FormControl> | |
| <FormDescription> | |
| Leave empty if the asset is still assigned. The returned date must be after the assigned date. | |
| </FormDescription> | |
| <FormMessage /> | |
| </FormItem> | |
| )} | |
| /> | |
| </div> | |
| {/* Remarks */} | |
| <FormField | |
| control={form.control} | |
| name="remarks" | |
| render={({ field }) => ( | |
| <FormItem> | |
| <FormLabel>Remarks *</FormLabel> | |
| <FormControl> | |
| <Textarea | |
| placeholder="Enter any additional notes about this assignment..." | |
| className="min-h-20" | |
| {...field} | |
| /> | |
| </FormControl> | |
| <FormDescription> | |
| Provide any relevant information about this assignment (purpose, conditions, etc.). | |
| </FormDescription> | |
| <FormMessage /> | |
| </FormItem> | |
| )} | |
| /> | |
| {/* Form Actions */} | |
| <div className="flex flex-col sm:flex-row justify-end gap-3 pt-4"> | |
| <Button | |
| type="button" | |
| variant="outline" | |
| onClick={onCancel} | |
| disabled={isSubmitting} | |
| className="flex items-center justify-center gap-2 min-h-[44px] md:min-h-0 touch-manipulation w-full sm:w-auto" | |
| aria-label="Cancel assignment form" | |
| > | |
| <X className="h-4 w-4" /> | |
| <span>Cancel</span> | |
| </Button> | |
| <Button | |
| type="submit" | |
| disabled={isSubmitting} | |
| className="flex items-center justify-center gap-2 min-h-[44px] md:min-h-0 touch-manipulation w-full sm:w-auto" | |
| aria-label={isEditing ? 'Update assignment' : 'Create assignment'} | |
| > | |
| {isSubmitting ? ( | |
| <Loader2 className="h-4 w-4 animate-spin" /> | |
| ) : ( | |
| <Save className="h-4 w-4" /> | |
| )} | |
| <span> | |
| {isSubmitting | |
| ? (isEditing ? 'Updating...' : 'Creating...') | |
| : (isEditing ? 'Update Assignment' : 'Create Assignment') | |
| } | |
| </span> | |
| </Button> | |
| </div> | |
| </form> | |
| </Form> | |
| </CardContent> | |
| </Card> | |
| ); | |
| }; | |
| export default AssignmentForm; |