Spaces:
Sleeping
Sleeping
| import React, { useState } from 'react'; | |
| import { useForm, useFieldArray } from 'react-hook-form'; | |
| import { zodResolver } from '@hookform/resolvers/zod'; | |
| import { Button } from '@/components/ui/button'; | |
| import { Input } from '@/components/ui/input'; | |
| import { Textarea } from '@/components/ui/textarea'; | |
| import { | |
| Form, | |
| FormControl, | |
| FormField, | |
| FormItem, | |
| FormLabel, | |
| FormMessage, | |
| FormDescription, | |
| } from '@/components/ui/form'; | |
| import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; | |
| import { Loader2, Plus, X, Send, Trash2 } from 'lucide-react'; | |
| import { CustomDropdown } from '@/components/asset-management/shared/CustomDropdown'; | |
| import { | |
| AssetRequestCreateRequest, | |
| AssetRequestCreateSchema, | |
| RequestedAssetItem, | |
| ASSET_REQUEST_CONFIG, | |
| } from '@/types/asset-request'; | |
| import { ASSET_TYPES } from '@/types/asset-management'; | |
| interface AssetRequestFormProps { | |
| employeeID: number; | |
| onSubmit: (data: AssetRequestCreateRequest) => Promise<void>; | |
| isSubmitting?: boolean; | |
| } | |
| type AssetRequestFormValues = { | |
| requestedAssets: RequestedAssetItem[]; | |
| justification: string; | |
| }; | |
| const AssetRequestForm: React.FC<AssetRequestFormProps> = ({ | |
| employeeID, | |
| onSubmit, | |
| isSubmitting = false, | |
| }) => { | |
| const [justificationLength, setJustificationLength] = useState(0); | |
| const form = useForm<AssetRequestFormValues>({ | |
| resolver: zodResolver(AssetRequestCreateSchema.omit({ employeeID: true })), | |
| defaultValues: { | |
| requestedAssets: [ | |
| { | |
| assetType: '', | |
| quantity: 1, | |
| specifications: '', | |
| }, | |
| ], | |
| justification: '', | |
| }, | |
| }); | |
| const { fields, append, remove } = useFieldArray({ | |
| control: form.control, | |
| name: 'requestedAssets', | |
| }); | |
| const handleSubmit = async (values: AssetRequestFormValues) => { | |
| try { | |
| const requestData: AssetRequestCreateRequest = { | |
| employeeID, | |
| requestedAssets: values.requestedAssets, | |
| justification: values.justification, | |
| }; | |
| await onSubmit(requestData); | |
| // Reset form on successful submission | |
| form.reset({ | |
| requestedAssets: [ | |
| { | |
| assetType: '', | |
| quantity: 1, | |
| specifications: '', | |
| }, | |
| ], | |
| justification: '', | |
| }); | |
| setJustificationLength(0); | |
| } catch (error) { | |
| console.error('Form submission error:', error); | |
| if (error instanceof Error) { | |
| form.setError('root', { | |
| type: 'manual', | |
| message: error.message, | |
| }); | |
| } | |
| } | |
| }; | |
| const handleAddItem = () => { | |
| if (fields.length < ASSET_REQUEST_CONFIG.MAX_ITEMS_PER_REQUEST) { | |
| append({ | |
| assetType: '', | |
| quantity: 1, | |
| specifications: '', | |
| }); | |
| } | |
| }; | |
| const handleRemoveItem = (index: number) => { | |
| if (fields.length > 1) { | |
| remove(index); | |
| } | |
| }; | |
| return ( | |
| <Card className="w-full"> | |
| <CardHeader className="pb-4"> | |
| <CardTitle className="flex items-center gap-2 text-lg sm:text-xl"> | |
| Request Assets | |
| </CardTitle> | |
| <p className="text-sm text-muted-foreground"> | |
| Submit a request for assets you need. You can request multiple items at once. | |
| </p> | |
| </CardHeader> | |
| <CardContent className="px-4 sm:px-6"> | |
| <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" | |
| role="alert" | |
| aria-live="polite" | |
| > | |
| <p className="text-sm text-destructive font-medium"> | |
| {form.formState.errors.root.message} | |
| </p> | |
| </div> | |
| )} | |
| {/* Requested Assets Section */} | |
| <div className="space-y-4"> | |
| <div className="flex items-center justify-between"> | |
| <h3 className="text-base font-semibold">Requested Assets</h3> | |
| <Button | |
| type="button" | |
| variant="outline" | |
| size="sm" | |
| onClick={handleAddItem} | |
| disabled={fields.length >= ASSET_REQUEST_CONFIG.MAX_ITEMS_PER_REQUEST} | |
| className="flex items-center gap-2" | |
| aria-label="Add another asset to request" | |
| > | |
| <Plus className="h-4 w-4" aria-hidden="true" /> | |
| Add Item | |
| </Button> | |
| </div> | |
| {fields.map((field, index) => ( | |
| <Card key={field.id} className="border-2"> | |
| <CardContent className="p-4 space-y-4"> | |
| <div className="flex items-center justify-between mb-2"> | |
| <h4 className="text-sm font-medium">Item {index + 1}</h4> | |
| {fields.length > 1 && ( | |
| <Button | |
| type="button" | |
| variant="ghost" | |
| size="sm" | |
| onClick={() => handleRemoveItem(index)} | |
| className="h-8 w-8 p-0 text-destructive hover:text-destructive" | |
| aria-label={`Remove item ${index + 1}`} | |
| > | |
| <Trash2 className="h-4 w-4" /> | |
| </Button> | |
| )} | |
| </div> | |
| <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> | |
| {/* Asset Type */} | |
| <FormField | |
| control={form.control} | |
| name={`requestedAssets.${index}.assetType`} | |
| render={({ field }) => ( | |
| <FormItem> | |
| <FormLabel>Asset Type *</FormLabel> | |
| <FormControl> | |
| <CustomDropdown | |
| options={ASSET_TYPES.map((type) => ({ | |
| value: type, | |
| label: type, | |
| }))} | |
| value={field.value} | |
| onChange={field.onChange} | |
| placeholder="Select asset type" | |
| error={ | |
| !!form.formState.errors.requestedAssets?.[index]?.assetType | |
| } | |
| aria-label={`Asset type for item ${index + 1}`} | |
| /> | |
| </FormControl> | |
| <FormMessage /> | |
| </FormItem> | |
| )} | |
| /> | |
| {/* Quantity */} | |
| <FormField | |
| control={form.control} | |
| name={`requestedAssets.${index}.quantity`} | |
| render={({ field }) => ( | |
| <FormItem> | |
| <FormLabel>Quantity *</FormLabel> | |
| <FormControl> | |
| <Input | |
| type="number" | |
| min="1" | |
| max={ASSET_REQUEST_CONFIG.MAX_QUANTITY_PER_ITEM} | |
| placeholder="1" | |
| {...field} | |
| onChange={(e) => field.onChange(parseInt(e.target.value) || 1)} | |
| aria-label={`Quantity for item ${index + 1}`} | |
| /> | |
| </FormControl> | |
| <FormMessage /> | |
| </FormItem> | |
| )} | |
| /> | |
| </div> | |
| {/* Specifications */} | |
| <FormField | |
| control={form.control} | |
| name={`requestedAssets.${index}.specifications`} | |
| render={({ field }) => ( | |
| <FormItem> | |
| <FormLabel>Specifications (Optional)</FormLabel> | |
| <FormControl> | |
| <Textarea | |
| placeholder="e.g., 16GB RAM, i7 processor, specific brand preference..." | |
| className="min-h-20 resize-none" | |
| maxLength={500} | |
| {...field} | |
| aria-label={`Specifications for item ${index + 1}`} | |
| /> | |
| </FormControl> | |
| <FormDescription className="text-xs"> | |
| {field.value?.length || 0}/500 characters | |
| </FormDescription> | |
| <FormMessage /> | |
| </FormItem> | |
| )} | |
| /> | |
| </CardContent> | |
| </Card> | |
| ))} | |
| {fields.length >= ASSET_REQUEST_CONFIG.MAX_ITEMS_PER_REQUEST && ( | |
| <p className="text-sm text-muted-foreground"> | |
| Maximum of {ASSET_REQUEST_CONFIG.MAX_ITEMS_PER_REQUEST} items per request | |
| reached. | |
| </p> | |
| )} | |
| </div> | |
| {/* Justification */} | |
| <FormField | |
| control={form.control} | |
| name="justification" | |
| render={({ field }) => ( | |
| <FormItem> | |
| <FormLabel>Justification *</FormLabel> | |
| <FormControl> | |
| <Textarea | |
| placeholder="Please explain why you need these assets and how they will help you perform your job..." | |
| className="min-h-32 resize-none" | |
| maxLength={ASSET_REQUEST_CONFIG.MAX_JUSTIFICATION_LENGTH} | |
| {...field} | |
| onChange={(e) => { | |
| field.onChange(e); | |
| setJustificationLength(e.target.value.length); | |
| }} | |
| aria-label="Justification for asset request" | |
| /> | |
| </FormControl> | |
| <FormDescription className="flex justify-between text-xs"> | |
| <span> | |
| Minimum {ASSET_REQUEST_CONFIG.MIN_JUSTIFICATION_LENGTH} characters | |
| required | |
| </span> | |
| <span | |
| className={ | |
| justificationLength < ASSET_REQUEST_CONFIG.MIN_JUSTIFICATION_LENGTH | |
| ? 'text-destructive' | |
| : '' | |
| } | |
| > | |
| {justificationLength}/{ASSET_REQUEST_CONFIG.MAX_JUSTIFICATION_LENGTH} | |
| </span> | |
| </FormDescription> | |
| <FormMessage /> | |
| </FormItem> | |
| )} | |
| /> | |
| {/* Form Actions */} | |
| <div className="flex flex-col-reverse sm:flex-row justify-end gap-3 pt-4"> | |
| <Button | |
| type="button" | |
| variant="outline" | |
| onClick={() => { | |
| form.reset(); | |
| setJustificationLength(0); | |
| }} | |
| disabled={isSubmitting} | |
| className="flex items-center justify-center gap-2 w-full sm:w-auto min-h-10" | |
| aria-label="Clear form" | |
| > | |
| <X className="h-4 w-4" aria-hidden="true" /> | |
| Clear | |
| </Button> | |
| <Button | |
| type="submit" | |
| disabled={isSubmitting} | |
| className="flex items-center justify-center gap-2 w-full sm:w-auto min-h-10" | |
| aria-label="Submit asset request" | |
| > | |
| {isSubmitting ? ( | |
| <> | |
| <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" /> | |
| <span>Submitting...</span> | |
| </> | |
| ) : ( | |
| <> | |
| <Send className="h-4 w-4" aria-hidden="true" /> | |
| <span>Submit Request</span> | |
| </> | |
| )} | |
| </Button> | |
| </div> | |
| </form> | |
| </Form> | |
| </CardContent> | |
| </Card> | |
| ); | |
| }; | |
| export default AssetRequestForm; | |