diff --git a/frontend/package/components/DisputeForm/DisputeFilingForm.tsx b/frontend/package/components/DisputeForm/DisputeFilingForm.tsx new file mode 100644 index 00000000..82fc394c --- /dev/null +++ b/frontend/package/components/DisputeForm/DisputeFilingForm.tsx @@ -0,0 +1,262 @@ +'use client'; + +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { toast } from 'sonner'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { apiClient } from '@/lib/api/client'; + +const DISPUTE_REASONS = [ + { value: 'CARGO_DAMAGED', label: 'Cargo Damaged' }, + { value: 'NON_DELIVERY', label: 'Non-Delivery' }, + { value: 'LATE_DELIVERY', label: 'Late Delivery' }, + { value: 'PAYMENT_DISPUTE', label: 'Payment Dispute' }, + { value: 'OTHER', label: 'Other' }, +] as const; + +const disputeSchema = z.object({ + reason: z.string().min(1, 'Please select a reason'), + description: z.string().min(50, 'Description must be at least 50 characters'), +}); + +type DisputeFormData = z.infer; + +export interface DisputeFilingFormProps { + shipmentId: string; + shipmentStatus?: string; + onSuccess?: () => void; + onClose?: () => void; +} + +export function DisputeFilingForm({ + shipmentId, + shipmentStatus, + onSuccess, + onClose, +}: DisputeFilingFormProps) { + const [step, setStep] = useState<'form' | 'review'>('form'); + const [files, setFiles] = useState([]); + const [submitting, setSubmitting] = useState(false); + + const canFile = + shipmentStatus === 'delivered' || shipmentStatus === 'in_transit'; + + const { + register, + handleSubmit, + watch, + formState: { errors }, + } = useForm({ + resolver: zodResolver(disputeSchema), + }); + + const description = watch('description', ''); + const selectedReason = watch('reason', ''); + + const handleFileChange = (e: React.ChangeEvent) => { + const selectedFiles = Array.from(e.target.files || []); + const validFiles = selectedFiles.filter((f) => { + const isValidType = ['application/pdf', 'image/png', 'image/jpeg'].includes(f.type); + const isValidSize = f.size <= 5 * 1024 * 1024; // 5MB + return isValidType && isValidSize; + }); + if (validFiles.length !== selectedFiles.length) { + toast.error('Some files were rejected. Only PDF/PNG/JPG under 5MB allowed.'); + } + setFiles((prev) => [...prev, ...validFiles].slice(0, 3)); + }; + + const removeFile = (index: number) => { + setFiles((prev) => prev.filter((_, i) => i !== index)); + }; + + const onSubmit = async (data: DisputeFormData) => { + setSubmitting(true); + try { + const formData = new FormData(); + formData.append('reason', data.reason); + formData.append('description', data.description); + files.forEach((f) => formData.append('evidence', f)); + + await apiClient(`/disputes`, { + method: 'POST', + body: formData, + headers: {}, + }); + + toast.success('Dispute filed successfully. You will be notified of the resolution.'); + onSuccess?.(); + } catch (err: unknown) { + const error = err as { message?: string }; + toast.error(error?.message ?? 'Failed to file dispute.'); + } finally { + setSubmitting(false); + } + }; + + if (!canFile && shipmentStatus) { + return ( + + +

+ Disputes can only be filed for shipments that are{' '} + In Transit or{' '} + Delivered. +

+ {onClose && ( + + )} +
+
+ ); + } + + return ( + + + File a Dispute + + +
setStep('review'))}> + {step === 'form' && ( +
+
+ + + {errors.reason && ( +

{errors.reason.message}

+ )} +
+ +
+ +