|
|
@@ -15,6 +15,7 @@ import {
|
|
|
SelectValue,
|
|
|
} from '@/components/ui/select'
|
|
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
|
|
+import { Checkbox } from '@/components/ui/checkbox'
|
|
|
import {
|
|
|
Command,
|
|
|
CommandEmpty,
|
|
|
@@ -34,13 +35,28 @@ import { DocsLink } from '@/components/docs-link'
|
|
|
import { toast } from 'sonner'
|
|
|
import { useGlassModal } from '@/components/glass-modal'
|
|
|
import { createVehicle, updateVehicle } from '../Actions/vehicleActions'
|
|
|
+import type { VehicleDocumentScan } from '../Actions/aiAnalyzeVehicleDocument'
|
|
|
+import { ScanDocumentButton } from './ScanDocumentButton'
|
|
|
+import { nameSimilarity } from '@/lib/name-similarity'
|
|
|
import { Camera, Check, ChevronsUpDown, Loader2, Plus, X } from 'lucide-react'
|
|
|
import { compressImage } from '@/lib/compress-image'
|
|
|
import { CustomerForm } from '@/features/customers/Components/CustomerForm'
|
|
|
+import { createCustomer } from '@/features/customers/Actions/customerActions'
|
|
|
import { useTranslations } from 'next-intl'
|
|
|
import { useServiceType } from '@/components/service-type-context'
|
|
|
import type { CreateVehicleInput } from '../Schema/vehicleSchema'
|
|
|
|
|
|
+/**
|
|
|
+ * How alike two names must read before the scanned keeper is offered as an
|
|
|
+ * existing customer rather than a new one.
|
|
|
+ */
|
|
|
+const OWNER_MATCH_THRESHOLD = 0.9
|
|
|
+
|
|
|
+interface OwnerMatch {
|
|
|
+ customer: { id: string; name: string; company: string | null }
|
|
|
+ score: number
|
|
|
+}
|
|
|
+
|
|
|
interface VehicleFormProps {
|
|
|
open: boolean
|
|
|
onOpenChange: (open: boolean) => void
|
|
|
@@ -87,14 +103,26 @@ export function VehicleForm({
|
|
|
const [customerOpen, setCustomerOpen] = useState(false)
|
|
|
const [showCustomerForm, setShowCustomerForm] = useState(false)
|
|
|
const [localCustomers, setLocalCustomers] = useState(customers || [])
|
|
|
+ const [fuelType, setFuelType] = useState(vehicle?.fuelType ?? 'gasoline')
|
|
|
+ /** Keeper read off a scanned document, until it is tied to a customer. */
|
|
|
+ const [scannedOwner, setScannedOwner] = useState<{ name?: string; address?: string } | null>(null)
|
|
|
+ const [ownerMatches, setOwnerMatches] = useState<OwnerMatch[]>([])
|
|
|
+ /** Whether saving should also create the keeper as a customer. */
|
|
|
+ const [addOwner, setAddOwner] = useState(true)
|
|
|
+ const [showOwnerMatch, setShowOwnerMatch] = useState(false)
|
|
|
const fileRef = useRef<HTMLInputElement>(null)
|
|
|
+ const formRef = useRef<HTMLFormElement>(null)
|
|
|
|
|
|
// Sync state when vehicle prop changes (e.g. opening edit for a different vehicle)
|
|
|
useEffect(() => {
|
|
|
setSelectedCustomerId(vehicle?.customerId || defaultCustomerId || 'none')
|
|
|
setPreview(vehicle?.imageUrl ?? null)
|
|
|
setImageFile(null)
|
|
|
- }, [vehicle?.id, vehicle?.customerId, vehicle?.imageUrl, defaultCustomerId])
|
|
|
+ setFuelType(vehicle?.fuelType ?? 'gasoline')
|
|
|
+ setScannedOwner(null)
|
|
|
+ setOwnerMatches([])
|
|
|
+ setAddOwner(true)
|
|
|
+ }, [vehicle?.id, vehicle?.customerId, vehicle?.imageUrl, vehicle?.fuelType, defaultCustomerId])
|
|
|
|
|
|
const selectedCustomerLabel = useMemo(() => {
|
|
|
if (!selectedCustomerId || selectedCustomerId === 'none') return t('noCustomer')
|
|
|
@@ -142,6 +170,55 @@ export function VehicleForm({
|
|
|
return url
|
|
|
}
|
|
|
|
|
|
+ const applyScan = (data: VehicleDocumentScan) => {
|
|
|
+ const form = formRef.current
|
|
|
+ if (!form) return
|
|
|
+
|
|
|
+ const setIfEmpty = (name: string, value: string | undefined) => {
|
|
|
+ if (!value) return
|
|
|
+ const input = form.elements.namedItem(name) as HTMLInputElement | null
|
|
|
+ if (input && !input.value) input.value = value
|
|
|
+ }
|
|
|
+
|
|
|
+ setIfEmpty('make', data.make)
|
|
|
+ setIfEmpty('model', data.model)
|
|
|
+ setIfEmpty('year', data.year ? String(data.year) : undefined)
|
|
|
+ setIfEmpty('vin', data.vin)
|
|
|
+ setIfEmpty('licensePlate', data.licensePlate)
|
|
|
+ setIfEmpty('color', data.color)
|
|
|
+ setIfEmpty('engineSize', data.engineSize)
|
|
|
+
|
|
|
+ // A select has no empty state, so only fill it while it still holds the
|
|
|
+ // value the form opened with.
|
|
|
+ if (data.fuelType && fuelType === (vehicle?.fuelType ?? 'gasoline')) {
|
|
|
+ setFuelType(data.fuelType)
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!data.owner?.name || selectedCustomerId !== 'none') return
|
|
|
+ setScannedOwner(data.owner)
|
|
|
+ setAddOwner(true)
|
|
|
+
|
|
|
+ // Near-identical names are still a judgement call: two brothers at one
|
|
|
+ // address differ by a first name. Offer the close ones and let the user say.
|
|
|
+ const ownerName = data.owner.name
|
|
|
+ const candidates = localCustomers
|
|
|
+ .map((c) => ({
|
|
|
+ customer: c,
|
|
|
+ // Papers naming a business may match the company field rather than the
|
|
|
+ // contact saved as the customer's name.
|
|
|
+ score: Math.max(
|
|
|
+ nameSimilarity(ownerName, c.name),
|
|
|
+ c.company ? nameSimilarity(ownerName, c.company) : 0
|
|
|
+ ),
|
|
|
+ }))
|
|
|
+ .filter((m) => m.score >= OWNER_MATCH_THRESHOLD)
|
|
|
+ .sort((a, b) => b.score - a.score)
|
|
|
+ .slice(0, 5)
|
|
|
+
|
|
|
+ setOwnerMatches(candidates)
|
|
|
+ if (candidates.length > 0) setShowOwnerMatch(true)
|
|
|
+ }
|
|
|
+
|
|
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
|
|
e.preventDefault()
|
|
|
const formData = new FormData(e.currentTarget)
|
|
|
@@ -161,6 +238,21 @@ export function VehicleForm({
|
|
|
return
|
|
|
}
|
|
|
|
|
|
+ // The keeper read off the papers is nobody until a customer row exists,
|
|
|
+ // and losing them to an unnoticed checkbox is worse than an extra record.
|
|
|
+ let customerId = selectedCustomerId === 'none' ? undefined : selectedCustomerId || undefined
|
|
|
+ if (!customerId && addOwner && scannedOwner?.name) {
|
|
|
+ const created = await createCustomer({
|
|
|
+ name: scannedOwner.name,
|
|
|
+ address: scannedOwner.address,
|
|
|
+ })
|
|
|
+ if (created.success && created.data) {
|
|
|
+ customerId = (created.data as { id: string }).id
|
|
|
+ } else {
|
|
|
+ toast.error(created.error || t('ownerSaveFailed'))
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
const data: CreateVehicleInput & { imageUrl?: string } = {
|
|
|
make: formData.get('make') as string,
|
|
|
model: formData.get('model') as string,
|
|
|
@@ -169,11 +261,11 @@ export function VehicleForm({
|
|
|
licensePlate: (formData.get('licensePlate') as string) || undefined,
|
|
|
color: (formData.get('color') as string) || undefined,
|
|
|
mileage: Number(formData.get('mileage')) || 0,
|
|
|
- fuelType: (formData.get('fuelType') as string) || undefined,
|
|
|
+ fuelType: fuelType || undefined,
|
|
|
transmission: (formData.get('transmission') as string) || undefined,
|
|
|
engineSize: (formData.get('engineSize') as string) || undefined,
|
|
|
engineCode: (formData.get('engineCode') as string) || undefined,
|
|
|
- customerId: selectedCustomerId === 'none' ? undefined : selectedCustomerId || undefined,
|
|
|
+ customerId,
|
|
|
}
|
|
|
|
|
|
const payload = imageUrl ? { ...data, imageUrl } : data
|
|
|
@@ -198,7 +290,7 @@ export function VehicleForm({
|
|
|
return (
|
|
|
<>
|
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
|
- <DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
|
|
|
+ <DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl">
|
|
|
<DialogHeader>
|
|
|
<DialogTitle>{vehicle ? t('editTitle') : t('addTitle')}</DialogTitle>
|
|
|
<DocsLink href="/docs/features/vehicles" variant="hint" className="self-start" />
|
|
|
@@ -207,223 +299,282 @@ export function VehicleForm({
|
|
|
</DialogDescription>
|
|
|
</DialogHeader>
|
|
|
|
|
|
- <form onSubmit={handleSubmit} className="space-y-4">
|
|
|
- {/* Image upload */}
|
|
|
- <div className="space-y-2">
|
|
|
- <Label>{t('photo')}</Label>
|
|
|
- <div
|
|
|
- {...interactiveRow(() => fileRef.current?.click())}
|
|
|
- className="group relative flex h-44 cursor-pointer items-center justify-center overflow-hidden rounded-xl border-2 border-dashed border-border bg-muted/30 transition-colors hover:border-primary/50 hover:bg-muted/50"
|
|
|
- >
|
|
|
- {preview ? (
|
|
|
- <>
|
|
|
- <Image
|
|
|
- src={preview}
|
|
|
- alt="Vehicle preview"
|
|
|
- fill
|
|
|
- unoptimized
|
|
|
- className="object-cover"
|
|
|
- />
|
|
|
- <div className="absolute inset-0 bg-black/0 transition-colors group-hover:bg-black/20" />
|
|
|
- <button
|
|
|
- type="button"
|
|
|
- onClick={(e) => {
|
|
|
- e.stopPropagation()
|
|
|
- clearImage()
|
|
|
- }}
|
|
|
- className="absolute top-2 right-2 flex h-7 w-7 items-center justify-center rounded-full bg-black/60 text-white opacity-0 transition-opacity group-hover:opacity-100"
|
|
|
- >
|
|
|
- <X className="h-4 w-4" />
|
|
|
- </button>
|
|
|
- </>
|
|
|
- ) : (
|
|
|
- <div className="flex flex-col items-center gap-2 text-muted-foreground">
|
|
|
- <Camera className="h-8 w-8" />
|
|
|
- <span className="text-sm">{t('clickToUpload')}</span>
|
|
|
- <span className="text-xs">{t('imageFormats')}</span>
|
|
|
- </div>
|
|
|
- )}
|
|
|
- </div>
|
|
|
- <input
|
|
|
- ref={fileRef}
|
|
|
- type="file"
|
|
|
- accept="image/jpeg,image/png,image/webp,image/avif"
|
|
|
- onChange={handleImageSelect}
|
|
|
- className="hidden"
|
|
|
- />
|
|
|
+ <form
|
|
|
+ ref={formRef}
|
|
|
+ onSubmit={handleSubmit}
|
|
|
+ className="grid gap-x-6 gap-y-4 md:grid-cols-2"
|
|
|
+ >
|
|
|
+ {/* Scan the registration document and fill the form from it */}
|
|
|
+ <div className="md:col-span-2">
|
|
|
+ <ScanDocumentButton onScanned={applyScan} />
|
|
|
</div>
|
|
|
|
|
|
- {/* Customer selector */}
|
|
|
- <div className="space-y-2">
|
|
|
- <Label>{t('customer')}</Label>
|
|
|
- <Popover open={customerOpen} onOpenChange={setCustomerOpen} modal={true}>
|
|
|
- <PopoverTrigger asChild>
|
|
|
- <Button
|
|
|
- variant="outline"
|
|
|
- role="combobox"
|
|
|
- aria-expanded={customerOpen}
|
|
|
- className="w-full justify-between font-normal"
|
|
|
- >
|
|
|
- <span className="truncate">{selectedCustomerLabel}</span>
|
|
|
- <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
|
- </Button>
|
|
|
- </PopoverTrigger>
|
|
|
- <PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
|
|
|
- <Command>
|
|
|
- <CommandInput placeholder={t('searchCustomers')} />
|
|
|
- <CommandList className="max-h-60 overflow-y-auto">
|
|
|
- <CommandEmpty>{t('noCustomerFound')}</CommandEmpty>
|
|
|
- <CommandGroup>
|
|
|
- <CommandItem
|
|
|
- value="no-customer"
|
|
|
- onSelect={() => {
|
|
|
- setSelectedCustomerId('none')
|
|
|
- setCustomerOpen(false)
|
|
|
- }}
|
|
|
- >
|
|
|
- <Check
|
|
|
- className={`mr-2 h-4 w-4 ${selectedCustomerId === 'none' ? 'opacity-100' : 'opacity-0'}`}
|
|
|
- />
|
|
|
- {t('noCustomer')}
|
|
|
- </CommandItem>
|
|
|
- {localCustomers.map((c) => {
|
|
|
- const label = c.name + (c.company ? ` (${c.company})` : '')
|
|
|
- return (
|
|
|
- <CommandItem
|
|
|
- key={c.id}
|
|
|
- value={label}
|
|
|
- onSelect={() => {
|
|
|
- setSelectedCustomerId(c.id)
|
|
|
- setCustomerOpen(false)
|
|
|
- }}
|
|
|
- >
|
|
|
- <Check
|
|
|
- className={`mr-2 h-4 w-4 ${selectedCustomerId === c.id ? 'opacity-100' : 'opacity-0'}`}
|
|
|
- />
|
|
|
- {label}
|
|
|
- </CommandItem>
|
|
|
- )
|
|
|
- })}
|
|
|
- </CommandGroup>
|
|
|
- </CommandList>
|
|
|
- <div className="border-t p-1">
|
|
|
+ {/* Left: what the vehicle belongs to and where its data comes from */}
|
|
|
+ <div className="space-y-4">
|
|
|
+ <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
|
+ {t('sectionAssignment')}
|
|
|
+ </p>
|
|
|
+
|
|
|
+ {/* Image upload */}
|
|
|
+ <div className="space-y-2">
|
|
|
+ <Label>{t('photo')}</Label>
|
|
|
+ <div
|
|
|
+ {...interactiveRow(() => fileRef.current?.click())}
|
|
|
+ className="group relative flex h-44 cursor-pointer items-center justify-center overflow-hidden rounded-xl border-2 border-dashed border-border bg-muted/30 transition-colors hover:border-primary/50 hover:bg-muted/50"
|
|
|
+ >
|
|
|
+ {preview ? (
|
|
|
+ <>
|
|
|
+ <Image
|
|
|
+ src={preview}
|
|
|
+ alt="Vehicle preview"
|
|
|
+ fill
|
|
|
+ unoptimized
|
|
|
+ className="object-cover"
|
|
|
+ />
|
|
|
+ <div className="absolute inset-0 bg-black/0 transition-colors group-hover:bg-black/20" />
|
|
|
<button
|
|
|
type="button"
|
|
|
- className="flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent"
|
|
|
- onClick={() => {
|
|
|
- setCustomerOpen(false)
|
|
|
- setShowCustomerForm(true)
|
|
|
+ onClick={(e) => {
|
|
|
+ e.stopPropagation()
|
|
|
+ clearImage()
|
|
|
}}
|
|
|
+ className="absolute top-2 right-2 flex h-7 w-7 items-center justify-center rounded-full bg-black/60 text-white opacity-0 transition-opacity group-hover:opacity-100"
|
|
|
>
|
|
|
- <Plus className="h-4 w-4" />
|
|
|
- {t('addCustomer')}
|
|
|
+ <X className="h-4 w-4" />
|
|
|
</button>
|
|
|
+ </>
|
|
|
+ ) : (
|
|
|
+ <div className="flex flex-col items-center gap-2 text-muted-foreground">
|
|
|
+ <Camera className="h-8 w-8" />
|
|
|
+ <span className="text-sm">{t('clickToUpload')}</span>
|
|
|
+ <span className="text-xs">{t('imageFormats')}</span>
|
|
|
</div>
|
|
|
- </Command>
|
|
|
- </PopoverContent>
|
|
|
- </Popover>
|
|
|
- </div>
|
|
|
-
|
|
|
- <div className="grid grid-cols-2 gap-4">
|
|
|
- <div className="space-y-2">
|
|
|
- <Label htmlFor="make">{isMarine ? t('makeMarine') : t('make')}</Label>
|
|
|
- <Input
|
|
|
- id="make"
|
|
|
- name="make"
|
|
|
- placeholder={isMarine ? 'Boston Whaler' : 'Toyota'}
|
|
|
- defaultValue={vehicle?.make}
|
|
|
- required
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ <input
|
|
|
+ ref={fileRef}
|
|
|
+ type="file"
|
|
|
+ accept="image/jpeg,image/png,image/webp,image/avif"
|
|
|
+ onChange={handleImageSelect}
|
|
|
+ className="hidden"
|
|
|
/>
|
|
|
</div>
|
|
|
- <div className="space-y-2">
|
|
|
- <Label htmlFor="model">{t('model')}</Label>
|
|
|
- <Input
|
|
|
- id="model"
|
|
|
- name="model"
|
|
|
- placeholder={isMarine ? 'Montauk 170' : 'Camry'}
|
|
|
- defaultValue={vehicle?.model}
|
|
|
- required
|
|
|
- />
|
|
|
- </div>
|
|
|
- </div>
|
|
|
|
|
|
- <div className="grid grid-cols-2 gap-4">
|
|
|
+ {/* Customer selector */}
|
|
|
<div className="space-y-2">
|
|
|
- <Label htmlFor="year">{t('year')}</Label>
|
|
|
- <Input
|
|
|
- id="year"
|
|
|
- name="year"
|
|
|
- type="number"
|
|
|
- placeholder="2024"
|
|
|
- defaultValue={vehicle?.year}
|
|
|
- required
|
|
|
- />
|
|
|
- </div>
|
|
|
- <div className="space-y-2">
|
|
|
- <Label htmlFor="mileage">{isMarine ? t('mileageMarine') : t('mileage')}</Label>
|
|
|
- <Input
|
|
|
- id="mileage"
|
|
|
- name="mileage"
|
|
|
- type="number"
|
|
|
- placeholder="0"
|
|
|
- defaultValue={vehicle?.mileage}
|
|
|
- />
|
|
|
+ <Label>{t('customer')}</Label>
|
|
|
+ <Popover open={customerOpen} onOpenChange={setCustomerOpen} modal={true}>
|
|
|
+ <PopoverTrigger asChild>
|
|
|
+ <Button
|
|
|
+ variant="outline"
|
|
|
+ role="combobox"
|
|
|
+ aria-expanded={customerOpen}
|
|
|
+ className="w-full justify-between font-normal"
|
|
|
+ >
|
|
|
+ <span className="truncate">{selectedCustomerLabel}</span>
|
|
|
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
|
+ </Button>
|
|
|
+ </PopoverTrigger>
|
|
|
+ <PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
|
|
|
+ <Command>
|
|
|
+ <CommandInput placeholder={t('searchCustomers')} />
|
|
|
+ <CommandList className="max-h-60 overflow-y-auto">
|
|
|
+ <CommandEmpty>{t('noCustomerFound')}</CommandEmpty>
|
|
|
+ <CommandGroup>
|
|
|
+ <CommandItem
|
|
|
+ value="no-customer"
|
|
|
+ onSelect={() => {
|
|
|
+ setSelectedCustomerId('none')
|
|
|
+ setCustomerOpen(false)
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <Check
|
|
|
+ className={`mr-2 h-4 w-4 ${selectedCustomerId === 'none' ? 'opacity-100' : 'opacity-0'}`}
|
|
|
+ />
|
|
|
+ {t('noCustomer')}
|
|
|
+ </CommandItem>
|
|
|
+ {localCustomers.map((c) => {
|
|
|
+ const label = c.name + (c.company ? ` (${c.company})` : '')
|
|
|
+ return (
|
|
|
+ <CommandItem
|
|
|
+ key={c.id}
|
|
|
+ value={label}
|
|
|
+ onSelect={() => {
|
|
|
+ setSelectedCustomerId(c.id)
|
|
|
+ setCustomerOpen(false)
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <Check
|
|
|
+ className={`mr-2 h-4 w-4 ${selectedCustomerId === c.id ? 'opacity-100' : 'opacity-0'}`}
|
|
|
+ />
|
|
|
+ {label}
|
|
|
+ </CommandItem>
|
|
|
+ )
|
|
|
+ })}
|
|
|
+ </CommandGroup>
|
|
|
+ </CommandList>
|
|
|
+ <div className="border-t p-1">
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ className="flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent"
|
|
|
+ onClick={() => {
|
|
|
+ setCustomerOpen(false)
|
|
|
+ setShowCustomerForm(true)
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <Plus className="h-4 w-4" />
|
|
|
+ {t('addCustomer')}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </Command>
|
|
|
+ </PopoverContent>
|
|
|
+ </Popover>
|
|
|
+
|
|
|
+ {/* Keeper read off the papers who matches no customer yet */}
|
|
|
+ {scannedOwner?.name && selectedCustomerId === 'none' && (
|
|
|
+ <div className="space-y-2 rounded-lg border border-dashed border-border bg-muted/30 p-3">
|
|
|
+ <label className="flex cursor-pointer items-start gap-3">
|
|
|
+ <Checkbox
|
|
|
+ className="mt-0.5"
|
|
|
+ checked={addOwner}
|
|
|
+ onCheckedChange={(next) => setAddOwner(next === true)}
|
|
|
+ />
|
|
|
+ <span className="min-w-0">
|
|
|
+ <span className="block text-sm">
|
|
|
+ {t('addScannedOwner', { name: scannedOwner.name })}
|
|
|
+ </span>
|
|
|
+ {scannedOwner.address && (
|
|
|
+ <span className="block truncate text-xs text-muted-foreground">
|
|
|
+ {scannedOwner.address}
|
|
|
+ </span>
|
|
|
+ )}
|
|
|
+ </span>
|
|
|
+ </label>
|
|
|
+
|
|
|
+ {ownerMatches.length > 0 && (
|
|
|
+ <Button
|
|
|
+ type="button"
|
|
|
+ size="sm"
|
|
|
+ variant="outline"
|
|
|
+ className="w-full"
|
|
|
+ onClick={() => setShowOwnerMatch(true)}
|
|
|
+ >
|
|
|
+ {t('ownerMatchChoose')}
|
|
|
+ </Button>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
- <div className="grid grid-cols-2 gap-4">
|
|
|
- <div className="space-y-2">
|
|
|
- <Label htmlFor="vin">{isMarine ? t('vinMarine') : t('vin')}</Label>
|
|
|
- <Input
|
|
|
- id="vin"
|
|
|
- name="vin"
|
|
|
- placeholder="1HGCM82633A004352"
|
|
|
- defaultValue={vehicle?.vin ?? ''}
|
|
|
- />
|
|
|
+ {/* Right: the vehicle's own details */}
|
|
|
+ <div className="space-y-4">
|
|
|
+ <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
|
+ {t('sectionDetails')}
|
|
|
+ </p>
|
|
|
+
|
|
|
+ <div className="grid grid-cols-2 gap-4">
|
|
|
+ <div className="space-y-2">
|
|
|
+ <Label htmlFor="make">{isMarine ? t('makeMarine') : t('make')}</Label>
|
|
|
+ <Input
|
|
|
+ id="make"
|
|
|
+ name="make"
|
|
|
+ placeholder={isMarine ? 'Boston Whaler' : 'Toyota'}
|
|
|
+ defaultValue={vehicle?.make}
|
|
|
+ required
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div className="space-y-2">
|
|
|
+ <Label htmlFor="model">{t('model')}</Label>
|
|
|
+ <Input
|
|
|
+ id="model"
|
|
|
+ name="model"
|
|
|
+ placeholder={isMarine ? 'Montauk 170' : 'Camry'}
|
|
|
+ defaultValue={vehicle?.model}
|
|
|
+ required
|
|
|
+ />
|
|
|
+ </div>
|
|
|
</div>
|
|
|
- <div className="space-y-2">
|
|
|
- <Label htmlFor="licensePlate">
|
|
|
- {isMarine ? t('licensePlateMarine') : t('licensePlate')}
|
|
|
- </Label>
|
|
|
- <Input
|
|
|
- id="licensePlate"
|
|
|
- name="licensePlate"
|
|
|
- placeholder="ABC-1234"
|
|
|
- defaultValue={vehicle?.licensePlate ?? ''}
|
|
|
- />
|
|
|
+
|
|
|
+ <div className="grid grid-cols-2 gap-4">
|
|
|
+ <div className="space-y-2">
|
|
|
+ <Label htmlFor="year">{t('year')}</Label>
|
|
|
+ <Input
|
|
|
+ id="year"
|
|
|
+ name="year"
|
|
|
+ type="number"
|
|
|
+ placeholder="2024"
|
|
|
+ defaultValue={vehicle?.year}
|
|
|
+ required
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div className="space-y-2">
|
|
|
+ <Label htmlFor="mileage">{isMarine ? t('mileageMarine') : t('mileage')}</Label>
|
|
|
+ <Input
|
|
|
+ id="mileage"
|
|
|
+ name="mileage"
|
|
|
+ type="number"
|
|
|
+ placeholder="0"
|
|
|
+ defaultValue={vehicle?.mileage}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
</div>
|
|
|
- </div>
|
|
|
|
|
|
- <div className="grid grid-cols-3 gap-4">
|
|
|
- <div className="space-y-2">
|
|
|
- <Label htmlFor="color">{t('color')}</Label>
|
|
|
- <Input
|
|
|
- id="color"
|
|
|
- name="color"
|
|
|
- placeholder="Silver"
|
|
|
- defaultValue={vehicle?.color ?? ''}
|
|
|
- />
|
|
|
+ <div className="grid grid-cols-2 gap-4">
|
|
|
+ <div className="space-y-2">
|
|
|
+ <Label htmlFor="vin">{isMarine ? t('vinMarine') : t('vin')}</Label>
|
|
|
+ <Input
|
|
|
+ id="vin"
|
|
|
+ name="vin"
|
|
|
+ placeholder="1HGCM82633A004352"
|
|
|
+ defaultValue={vehicle?.vin ?? ''}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div className="space-y-2">
|
|
|
+ <Label htmlFor="licensePlate">
|
|
|
+ {isMarine ? t('licensePlateMarine') : t('licensePlate')}
|
|
|
+ </Label>
|
|
|
+ <Input
|
|
|
+ id="licensePlate"
|
|
|
+ name="licensePlate"
|
|
|
+ placeholder="ABC-1234"
|
|
|
+ defaultValue={vehicle?.licensePlate ?? ''}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
</div>
|
|
|
- <div className="space-y-2">
|
|
|
- <Label htmlFor="fuelType">{t('fuelType')}</Label>
|
|
|
- <Select name="fuelType" defaultValue={vehicle?.fuelType ?? 'gasoline'}>
|
|
|
- <SelectTrigger>
|
|
|
- <SelectValue />
|
|
|
- </SelectTrigger>
|
|
|
- <SelectContent>
|
|
|
- <SelectItem value="gasoline">{t('gasoline')}</SelectItem>
|
|
|
- <SelectItem value="diesel">{t('diesel')}</SelectItem>
|
|
|
- {!isMarine && (
|
|
|
- <>
|
|
|
- <SelectItem value="electric">{t('electric')}</SelectItem>
|
|
|
- <SelectItem value="hybrid">{t('hybrid')}</SelectItem>
|
|
|
- </>
|
|
|
- )}
|
|
|
- {isMarine && <SelectItem value="two-stroke">{t('twoStroke')}</SelectItem>}
|
|
|
- <SelectItem value="other">{t('other')}</SelectItem>
|
|
|
- </SelectContent>
|
|
|
- </Select>
|
|
|
+
|
|
|
+ <div className="grid grid-cols-2 gap-4">
|
|
|
+ <div className="space-y-2">
|
|
|
+ <Label htmlFor="color">{t('color')}</Label>
|
|
|
+ <Input
|
|
|
+ id="color"
|
|
|
+ name="color"
|
|
|
+ placeholder="Silver"
|
|
|
+ defaultValue={vehicle?.color ?? ''}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div className="space-y-2">
|
|
|
+ <Label htmlFor="fuelType">{t('fuelType')}</Label>
|
|
|
+ <Select name="fuelType" value={fuelType} onValueChange={setFuelType}>
|
|
|
+ <SelectTrigger>
|
|
|
+ <SelectValue />
|
|
|
+ </SelectTrigger>
|
|
|
+ <SelectContent>
|
|
|
+ <SelectItem value="gasoline">{t('gasoline')}</SelectItem>
|
|
|
+ <SelectItem value="diesel">{t('diesel')}</SelectItem>
|
|
|
+ {!isMarine && (
|
|
|
+ <>
|
|
|
+ <SelectItem value="electric">{t('electric')}</SelectItem>
|
|
|
+ <SelectItem value="hybrid">{t('hybrid')}</SelectItem>
|
|
|
+ </>
|
|
|
+ )}
|
|
|
+ {isMarine && <SelectItem value="two-stroke">{t('twoStroke')}</SelectItem>}
|
|
|
+ <SelectItem value="other">{t('other')}</SelectItem>
|
|
|
+ </SelectContent>
|
|
|
+ </Select>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
+
|
|
|
<div className="space-y-2">
|
|
|
<Label htmlFor="transmission">
|
|
|
{isMarine ? t('transmissionMarine') : t('transmission')}
|
|
|
@@ -451,32 +602,32 @@ export function VehicleForm({
|
|
|
</SelectContent>
|
|
|
</Select>
|
|
|
</div>
|
|
|
- </div>
|
|
|
|
|
|
- <div className="grid grid-cols-2 gap-4">
|
|
|
- <div className="space-y-2">
|
|
|
- <Label htmlFor="engineSize">
|
|
|
- {isMarine ? t('engineSizeMarine') : t('engineSize')}
|
|
|
- </Label>
|
|
|
- <Input
|
|
|
- id="engineSize"
|
|
|
- name="engineSize"
|
|
|
- placeholder={isMarine ? 'Mercury F 350 XXL Verado V-10 (350 hp)' : '2.5L'}
|
|
|
- defaultValue={vehicle?.engineSize ?? ''}
|
|
|
- />
|
|
|
- </div>
|
|
|
- <div className="space-y-2">
|
|
|
- <Label htmlFor="engineCode">{t('engineCode')}</Label>
|
|
|
- <Input
|
|
|
- id="engineCode"
|
|
|
- name="engineCode"
|
|
|
- placeholder="2AR-FE"
|
|
|
- defaultValue={vehicle?.engineCode ?? ''}
|
|
|
- />
|
|
|
+ <div className="grid grid-cols-2 gap-4">
|
|
|
+ <div className="space-y-2">
|
|
|
+ <Label htmlFor="engineSize">
|
|
|
+ {isMarine ? t('engineSizeMarine') : t('engineSize')}
|
|
|
+ </Label>
|
|
|
+ <Input
|
|
|
+ id="engineSize"
|
|
|
+ name="engineSize"
|
|
|
+ placeholder={isMarine ? 'Mercury F 350 XXL Verado V-10 (350 hp)' : '2.5L'}
|
|
|
+ defaultValue={vehicle?.engineSize ?? ''}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div className="space-y-2">
|
|
|
+ <Label htmlFor="engineCode">{t('engineCode')}</Label>
|
|
|
+ <Input
|
|
|
+ id="engineCode"
|
|
|
+ name="engineCode"
|
|
|
+ placeholder="2AR-FE"
|
|
|
+ defaultValue={vehicle?.engineCode ?? ''}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
- <div className="flex justify-end gap-3 pt-4">
|
|
|
+ <div className="flex justify-end gap-3 border-t pt-4 md:col-span-2">
|
|
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
|
|
{tc('cancel')}
|
|
|
</Button>
|
|
|
@@ -489,12 +640,69 @@ export function VehicleForm({
|
|
|
</DialogContent>
|
|
|
</Dialog>
|
|
|
|
|
|
+ {/* Close matches for the keeper on the papers */}
|
|
|
+ <Dialog open={showOwnerMatch} onOpenChange={setShowOwnerMatch}>
|
|
|
+ <DialogContent className="sm:max-w-md">
|
|
|
+ <DialogHeader>
|
|
|
+ <DialogTitle>{t('ownerMatchTitle')}</DialogTitle>
|
|
|
+ <DialogDescription>
|
|
|
+ {t('ownerMatchDescription', { name: scannedOwner?.name ?? '' })}
|
|
|
+ </DialogDescription>
|
|
|
+ </DialogHeader>
|
|
|
+
|
|
|
+ <div className="space-y-2">
|
|
|
+ {ownerMatches.map(({ customer, score }) => (
|
|
|
+ <button
|
|
|
+ key={customer.id}
|
|
|
+ type="button"
|
|
|
+ className="flex w-full items-center justify-between gap-3 rounded-lg border border-border p-3 text-left transition-colors hover:bg-accent"
|
|
|
+ onClick={() => {
|
|
|
+ setSelectedCustomerId(customer.id)
|
|
|
+ setScannedOwner(null)
|
|
|
+ setShowOwnerMatch(false)
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <span className="min-w-0">
|
|
|
+ <span className="block truncate text-sm font-medium">{customer.name}</span>
|
|
|
+ {customer.company && (
|
|
|
+ <span className="block truncate text-xs text-muted-foreground">
|
|
|
+ {customer.company}
|
|
|
+ </span>
|
|
|
+ )}
|
|
|
+ </span>
|
|
|
+ <span className="shrink-0 text-xs text-muted-foreground">
|
|
|
+ {t('ownerMatchScore', { percent: Math.round(score * 100) })}
|
|
|
+ </span>
|
|
|
+ </button>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="flex justify-end gap-2">
|
|
|
+ <Button type="button" variant="outline" onClick={() => setShowOwnerMatch(false)}>
|
|
|
+ {tc('cancel')}
|
|
|
+ </Button>
|
|
|
+ <Button
|
|
|
+ type="button"
|
|
|
+ onClick={() => {
|
|
|
+ setShowOwnerMatch(false)
|
|
|
+ setShowCustomerForm(true)
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <Plus className="mr-1 h-4 w-4" />
|
|
|
+ {t('ownerMatchCreateNew')}
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+ </DialogContent>
|
|
|
+ </Dialog>
|
|
|
+
|
|
|
<CustomerForm
|
|
|
open={showCustomerForm}
|
|
|
onOpenChange={setShowCustomerForm}
|
|
|
+ defaults={scannedOwner ?? undefined}
|
|
|
onCreated={(created) => {
|
|
|
setLocalCustomers((prev) => [...prev, created])
|
|
|
setSelectedCustomerId(created.id)
|
|
|
+ setScannedOwner(null)
|
|
|
}}
|
|
|
/>
|
|
|
</>
|