pmtool / src /components /asset-management /AssignmentForm.tsx
devarshia5's picture
Upload 487 files
d97b8f9 verified
Raw
History Blame Contribute Delete
24.2 kB
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;