PlateLookupCommand.tsx 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628
  1. 'use client'
  2. import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
  3. import { useRouter } from 'next/navigation'
  4. import { useFormatter, useTranslations } from 'next-intl'
  5. import {
  6. ArrowRight,
  7. Building2,
  8. Car,
  9. Check,
  10. Copy,
  11. Loader2,
  12. Plus,
  13. Search,
  14. ShieldCheck,
  15. ShieldOff,
  16. User,
  17. X,
  18. } from 'lucide-react'
  19. import { Button } from '@/components/ui/button'
  20. import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog'
  21. import { PLATE_LOOKUP_EVENT, usePlateLookupAccess } from '@/components/plate-lookup-context'
  22. import type { VehicleLookup } from '@/features/integrations/Actions/vehicleLookupActions'
  23. import { cn } from '@/lib/utils'
  24. import { type PlateLookup, lookupPlate } from '../Actions/plateLookupActions'
  25. import { compactPlate, looksLikePlate } from '../Lib/plate'
  26. import { VehicleForm } from './VehicleForm'
  27. /**
  28. * The header's plate lookup: one field, one answer sheet.
  29. *
  30. * Type a plate, press Enter, and the sheet fills with what the workshop knows
  31. * (the vehicle, its owner, its history) beside what the registry publishes
  32. * (identity, engine, dates, tyres). A vehicle the workshop has not seen gets
  33. * an "Add vehicle" that opens the ordinary form with the registry's answer
  34. * already typed in.
  35. *
  36. * Opened from the header button, from Ctrl+Shift+L, or by any code that
  37. * dispatches the plate-lookup event, optionally with a plate to search.
  38. */
  39. const RECENT_KEY = 'torqvoice.plate-lookup.recent'
  40. const RECENT_MAX = 6
  41. function readRecent(): string[] {
  42. try {
  43. const raw = window.localStorage.getItem(RECENT_KEY)
  44. const parsed = raw ? (JSON.parse(raw) as unknown) : []
  45. return Array.isArray(parsed) ? parsed.filter((p): p is string => typeof p === 'string') : []
  46. } catch {
  47. return []
  48. }
  49. }
  50. function writeRecent(plates: string[]) {
  51. try {
  52. window.localStorage.setItem(RECENT_KEY, JSON.stringify(plates))
  53. } catch {
  54. // Private windows and blocked storage: the list is a convenience.
  55. }
  56. }
  57. /** Days until a date, negative when past. */
  58. function daysUntil(iso: string): number {
  59. return Math.round((new Date(iso).getTime() - Date.now()) / 86_400_000)
  60. }
  61. type Phase = 'idle' | 'loading' | 'done'
  62. export function PlateLookupCommand() {
  63. const t = useTranslations('vehicles.plateLookup')
  64. const tf = useTranslations('vehicles.form')
  65. const format = useFormatter()
  66. const router = useRouter()
  67. const { available, canCreate, registryName } = usePlateLookupAccess()
  68. const [open, setOpen] = useState(false)
  69. const [plate, setPlate] = useState('')
  70. const [phase, setPhase] = useState<Phase>('idle')
  71. const [result, setResult] = useState<PlateLookup | null>(null)
  72. const [error, setError] = useState<string | null>(null)
  73. const [recent, setRecent] = useState<string[]>([])
  74. const [copied, setCopied] = useState(false)
  75. const [formOpen, setFormOpen] = useState(false)
  76. const [formLookup, setFormLookup] = useState<VehicleLookup | null>(null)
  77. const inputRef = useRef<HTMLInputElement>(null)
  78. const requestRef = useRef(0)
  79. const run = useCallback(
  80. async (typed: string) => {
  81. const value = typed.trim()
  82. if (!looksLikePlate(value)) {
  83. setError(t('invalid'))
  84. return
  85. }
  86. const id = ++requestRef.current
  87. setPhase('loading')
  88. setError(null)
  89. setResult(null)
  90. setCopied(false)
  91. const res = await lookupPlate({ plate: value })
  92. if (id !== requestRef.current) return
  93. if (!res.success || !res.data) {
  94. setPhase('idle')
  95. setError(res.error || t('registryFailedGeneric'))
  96. return
  97. }
  98. setResult(res.data)
  99. setPhase('done')
  100. const compact = res.data.plate
  101. setRecent((prev) => {
  102. const next = [compact, ...prev.filter((p) => p !== compact)].slice(0, RECENT_MAX)
  103. writeRecent(next)
  104. return next
  105. })
  106. },
  107. [t]
  108. )
  109. // Open on the header button, the shortcut, or a request carrying a plate.
  110. useEffect(() => {
  111. if (!available) return
  112. const onEvent = (e: Event) => {
  113. const detail = (e as CustomEvent<{ plate?: string }>).detail
  114. setOpen(true)
  115. if (detail?.plate) {
  116. setPlate(compactPlate(detail.plate))
  117. void run(detail.plate)
  118. }
  119. }
  120. const onKey = (e: KeyboardEvent) => {
  121. if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === 'l') {
  122. e.preventDefault()
  123. setOpen((prev) => !prev)
  124. }
  125. }
  126. document.addEventListener(PLATE_LOOKUP_EVENT, onEvent)
  127. document.addEventListener('keydown', onKey)
  128. return () => {
  129. document.removeEventListener(PLATE_LOOKUP_EVENT, onEvent)
  130. document.removeEventListener('keydown', onKey)
  131. }
  132. }, [available, run])
  133. useEffect(() => {
  134. if (!open) return
  135. setRecent(readRecent())
  136. const frame = requestAnimationFrame(() => inputRef.current?.focus())
  137. return () => cancelAnimationFrame(frame)
  138. }, [open])
  139. const reset = () => {
  140. setPlate('')
  141. setPhase('idle')
  142. setResult(null)
  143. setError(null)
  144. }
  145. const handleOpenChange = (next: boolean) => {
  146. setOpen(next)
  147. if (!next) reset()
  148. }
  149. const openVehicle = (id: string) => {
  150. handleOpenChange(false)
  151. router.push(`/vehicles/${id}`)
  152. }
  153. const addVehicle = async () => {
  154. if (!result) return
  155. const registry = result.registry
  156. setFormLookup(
  157. registry
  158. ? { ...registry, licensePlate: registry.licensePlate ?? result.plate }
  159. : { licensePlate: result.plate, source: '', connectorId: '' }
  160. )
  161. setOpen(false)
  162. setFormOpen(true)
  163. }
  164. const copyVin = async (vin: string) => {
  165. try {
  166. await navigator.clipboard.writeText(vin)
  167. setCopied(true)
  168. setTimeout(() => setCopied(false), 1500)
  169. } catch {
  170. // Clipboard blocked: the VIN is still on screen to select.
  171. }
  172. }
  173. const fuelLabel = (fuel: string) => {
  174. const known = ['gasoline', 'diesel', 'electric', 'hybrid', 'other']
  175. return known.includes(fuel) ? tf(fuel) : fuel
  176. }
  177. const gearboxLabel = (gearbox: string) => {
  178. const known = ['automatic', 'manual', 'cvt']
  179. return known.includes(gearbox) ? tf(gearbox) : gearbox
  180. }
  181. const date = (iso: string) => format.dateTime(new Date(iso), { dateStyle: 'medium' })
  182. const registry = result?.registry ?? null
  183. const vehicle = result?.vehicle ?? null
  184. /** The headline: the workshop's own record if there is one, else the registry's. */
  185. const headline = useMemo(() => {
  186. if (vehicle) return { make: vehicle.make, model: vehicle.model, year: vehicle.year }
  187. if (registry?.make || registry?.model)
  188. return { make: registry.make ?? '', model: registry.model ?? '', year: registry.year }
  189. return null
  190. }, [vehicle, registry])
  191. const details = useMemo(() => {
  192. if (!registry) return []
  193. const rows: { key: string; label: string; value: string; tone?: 'overdue' | 'soon' }[] = []
  194. const push = (key: string, label: string, value?: string | null, tone?: 'overdue' | 'soon') => {
  195. if (value) rows.push({ key, label, value, tone })
  196. }
  197. push('fuel', t('fuel'), registry.fuelType ? fuelLabel(registry.fuelType) : undefined)
  198. push(
  199. 'transmission',
  200. t('transmission'),
  201. registry.transmission ? gearboxLabel(registry.transmission) : undefined
  202. )
  203. push('engine', t('engine'), registry.engineSize)
  204. push('engineCode', t('engineCode'), registry.engineCode)
  205. push('color', t('color'), registry.color)
  206. push('class', t('vehicleClass'), registry.vehicleClass)
  207. push(
  208. 'firstRegistered',
  209. t('firstRegistered'),
  210. registry.firstRegistered ? date(registry.firstRegistered) : undefined
  211. )
  212. if (registry.inspectionDue) {
  213. const days = daysUntil(registry.inspectionDue)
  214. push(
  215. 'inspectionDue',
  216. t('inspectionDue'),
  217. date(registry.inspectionDue),
  218. days < 0 ? 'overdue' : days <= 60 ? 'soon' : undefined
  219. )
  220. }
  221. push(
  222. 'lastInspected',
  223. t('lastInspected'),
  224. registry.lastInspected ? date(registry.lastInspected) : undefined
  225. )
  226. if (registry.weights?.kerb && registry.weights.grossMax) {
  227. push(
  228. 'weights',
  229. t('weights'),
  230. t('weightsValue', { kerb: registry.weights.kerb, gross: registry.weights.grossMax })
  231. )
  232. } else if (registry.weights?.kerb) {
  233. push('weights', t('weights'), t('kerbOnly', { kerb: registry.weights.kerb }))
  234. } else if (registry.weights?.grossMax) {
  235. push('weights', t('weights'), t('grossOnly', { gross: registry.weights.grossMax }))
  236. }
  237. return rows
  238. // eslint-disable-next-line react-hooks/exhaustive-deps
  239. }, [registry, t, tf, format])
  240. if (!available) return null
  241. return (
  242. <>
  243. <Dialog open={open} onOpenChange={handleOpenChange}>
  244. <DialogContent
  245. className="gap-0 overflow-hidden p-0 sm:max-w-2xl"
  246. showCloseButton={false}
  247. onOpenAutoFocus={(e) => e.preventDefault()}
  248. >
  249. <DialogTitle className="sr-only">{t('title')}</DialogTitle>
  250. <DialogDescription className="sr-only">{t('description')}</DialogDescription>
  251. {/* The plate field, drawn as a plate: a band at the left, the
  252. characters in a fixed-width face, spaced the way a plate is. */}
  253. <form
  254. className="flex items-stretch gap-2 border-b bg-muted/30 p-3"
  255. onSubmit={(e) => {
  256. e.preventDefault()
  257. void run(plate)
  258. }}
  259. >
  260. <div className="flex flex-1 items-stretch overflow-hidden rounded-lg border-2 border-foreground/25 bg-background shadow-sm transition-colors focus-within:border-primary">
  261. <div className="flex w-7 shrink-0 items-end justify-center bg-primary/85 pb-1.5">
  262. <Car className="h-3.5 w-3.5 text-primary-foreground" aria-hidden />
  263. </div>
  264. <input
  265. ref={inputRef}
  266. value={plate}
  267. onChange={(e) => {
  268. setPlate(e.target.value.toUpperCase())
  269. if (error) setError(null)
  270. }}
  271. placeholder={t('placeholder')}
  272. autoComplete="off"
  273. autoCorrect="off"
  274. spellCheck={false}
  275. maxLength={16}
  276. aria-label={t('placeholder')}
  277. className="h-14 min-w-0 flex-1 bg-transparent px-4 font-mono text-2xl font-semibold uppercase tracking-[0.18em] text-foreground outline-none placeholder:font-sans placeholder:text-base placeholder:font-normal placeholder:normal-case placeholder:tracking-normal placeholder:text-muted-foreground/70"
  278. />
  279. {plate && (
  280. <button
  281. type="button"
  282. onClick={() => {
  283. reset()
  284. inputRef.current?.focus()
  285. }}
  286. className="px-3 text-muted-foreground hover:text-foreground"
  287. aria-label={t('clearRecent')}
  288. >
  289. <X className="h-4 w-4" />
  290. </button>
  291. )}
  292. </div>
  293. <Button type="submit" className="h-auto px-4" disabled={phase === 'loading'}>
  294. {phase === 'loading' ? (
  295. <Loader2 className="h-4 w-4 animate-spin" />
  296. ) : (
  297. <Search className="h-4 w-4" />
  298. )}
  299. <span className="hidden sm:inline">{t('search')}</span>
  300. </Button>
  301. </form>
  302. <div className="min-h-[320px]">
  303. {error && (
  304. <p className="border-b bg-destructive/5 px-4 py-2 text-sm text-destructive">
  305. {error}
  306. </p>
  307. )}
  308. {phase === 'idle' && !result && (
  309. <div className="flex flex-col gap-6 px-5 py-6">
  310. <div className="flex items-start gap-4">
  311. <div className="rounded-xl bg-primary/10 p-3 text-primary">
  312. <Search className="h-6 w-6" />
  313. </div>
  314. <div className="space-y-1">
  315. <p className="font-medium">{t('emptyTitle')}</p>
  316. <p className="text-sm text-muted-foreground">
  317. {t('emptyBody', { source: registryName ?? t('registryWord') })}
  318. </p>
  319. </div>
  320. </div>
  321. {recent.length > 0 && (
  322. <div className="space-y-2">
  323. <div className="flex items-center justify-between text-xs text-muted-foreground">
  324. <span className="font-medium uppercase tracking-wide">{t('recent')}</span>
  325. <button
  326. type="button"
  327. className="hover:text-foreground"
  328. onClick={() => {
  329. setRecent([])
  330. writeRecent([])
  331. }}
  332. >
  333. {t('clearRecent')}
  334. </button>
  335. </div>
  336. <div className="flex flex-wrap gap-2">
  337. {recent.map((p) => (
  338. <button
  339. key={p}
  340. type="button"
  341. onClick={() => {
  342. setPlate(p)
  343. void run(p)
  344. }}
  345. className="rounded-md border bg-background px-2.5 py-1 font-mono text-sm tracking-wider transition-colors hover:border-primary hover:text-primary"
  346. >
  347. {p}
  348. </button>
  349. ))}
  350. </div>
  351. </div>
  352. )}
  353. </div>
  354. )}
  355. {phase === 'loading' && (
  356. <div className="space-y-4 px-5 py-6" aria-busy>
  357. <div className="h-7 w-2/3 skeleton rounded" />
  358. <div className="h-4 w-1/3 skeleton rounded" />
  359. <div className="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-3">
  360. {Array.from({ length: 6 }, (_, i) => (
  361. <div key={i} className="space-y-1.5">
  362. <div className="h-3 w-16 skeleton rounded" />
  363. <div className="h-4 w-24 skeleton rounded" />
  364. </div>
  365. ))}
  366. </div>
  367. <p className="pt-2 text-xs text-muted-foreground">
  368. {registryName ? t('looking', { source: registryName }) : t('lookingWorkshop')}
  369. </p>
  370. </div>
  371. )}
  372. {phase === 'done' && result && (
  373. <div className="divide-y">
  374. {/* Headline: what the car is, and which register said so. */}
  375. <div className="flex flex-wrap items-start justify-between gap-3 px-5 py-4">
  376. <div className="min-w-0">
  377. {headline ? (
  378. <p className="truncate text-xl font-semibold leading-tight">
  379. {headline.year ? `${headline.year} ` : ''}
  380. {headline.make} {headline.model}
  381. </p>
  382. ) : (
  383. <p className="text-xl font-semibold leading-tight text-muted-foreground">
  384. {result.source
  385. ? t('registryNotFound', { source: result.source })
  386. : t('registryFailedGeneric')}
  387. </p>
  388. )}
  389. <div className="mt-1.5 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
  390. <span className="rounded border px-1.5 py-0.5 font-mono text-[11px] tracking-wider text-foreground">
  391. {vehicle?.licensePlate ?? registry?.licensePlate ?? result.plate}
  392. </span>
  393. {registry && <span>{t('fromSource', { source: registry.source })}</span>}
  394. {registry?.registered === true && (
  395. <span className="inline-flex items-center gap-1 text-emerald-600 dark:text-emerald-400">
  396. <ShieldCheck className="h-3.5 w-3.5" /> {t('registered')}
  397. </span>
  398. )}
  399. {registry?.registered === false && (
  400. <span className="inline-flex items-center gap-1 text-amber-600 dark:text-amber-400">
  401. <ShieldOff className="h-3.5 w-3.5" /> {t('notRegistered')}
  402. </span>
  403. )}
  404. </div>
  405. {result.registryError && (
  406. <p className="mt-2 text-xs text-amber-600 dark:text-amber-400">
  407. {result.source
  408. ? `${t('registryFailed', { source: result.source })}: ${result.registryError}`
  409. : result.registryError}
  410. </p>
  411. )}
  412. {!result.registryError && !registry && result.source && headline && (
  413. <p className="mt-2 text-xs text-muted-foreground">
  414. {t('registryNotFound', { source: result.source })}
  415. </p>
  416. )}
  417. </div>
  418. {registry?.vin && (
  419. <button
  420. type="button"
  421. onClick={() => copyVin(registry.vin as string)}
  422. className="group flex items-center gap-2 rounded-md border bg-muted/40 px-2.5 py-1.5 text-left transition-colors hover:bg-muted"
  423. title={t('copyVin')}
  424. >
  425. <div>
  426. <div className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
  427. {t('vin')}
  428. </div>
  429. <div className="font-mono text-sm">{registry.vin}</div>
  430. </div>
  431. {copied ? (
  432. <Check className="h-4 w-4 text-emerald-600" />
  433. ) : (
  434. <Copy className="h-4 w-4 text-muted-foreground group-hover:text-foreground" />
  435. )}
  436. </button>
  437. )}
  438. </div>
  439. {/* The workshop's half: the car is yours, or it could be. */}
  440. {vehicle ? (
  441. <div className="flex flex-wrap items-center gap-4 px-5 py-3">
  442. <div className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-primary/10 text-primary">
  443. {vehicle.imageUrl ? (
  444. // eslint-disable-next-line @next/next/no-img-element
  445. <img src={vehicle.imageUrl} alt="" className="h-full w-full object-cover" />
  446. ) : (
  447. <Car className="h-5 w-5" />
  448. )}
  449. </div>
  450. <div className="min-w-0 flex-1">
  451. <p className="text-sm font-medium">
  452. {vehicle.isArchived ? t('inWorkshopArchived') : t('inWorkshop')}
  453. </p>
  454. <p className="flex flex-wrap items-center gap-x-2 text-xs text-muted-foreground">
  455. {vehicle.customer ? (
  456. <span className="inline-flex items-center gap-1">
  457. {vehicle.customer.company ? (
  458. <Building2 className="h-3 w-3" />
  459. ) : (
  460. <User className="h-3 w-3" />
  461. )}
  462. {vehicle.customer.name}
  463. {vehicle.customer.company ? ` (${vehicle.customer.company})` : ''}
  464. </span>
  465. ) : (
  466. <span>{t('noOwner')}</span>
  467. )}
  468. {vehicle.mileage > 0 && (
  469. <span>· {t('mileage', { km: format.number(vehicle.mileage) })}</span>
  470. )}
  471. <span>· {t('services', { count: vehicle.serviceCount })}</span>
  472. {vehicle.lastServiceAt && (
  473. <span>· {t('lastService', { date: date(vehicle.lastServiceAt) })}</span>
  474. )}
  475. </p>
  476. </div>
  477. <Button size="sm" onClick={() => openVehicle(vehicle.id)}>
  478. {t('openVehicle')}
  479. <ArrowRight className="ml-1 h-3.5 w-3.5" />
  480. </Button>
  481. </div>
  482. ) : (
  483. (registry || canCreate) && (
  484. <div className="flex flex-wrap items-center gap-4 px-5 py-3">
  485. <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-dashed text-muted-foreground">
  486. <Plus className="h-5 w-5" />
  487. </div>
  488. <div className="min-w-0 flex-1">
  489. <p className="text-sm font-medium">{t('notInWorkshop')}</p>
  490. <p className="text-xs text-muted-foreground">
  491. {registry ? t('notInWorkshopHint') : t('notInWorkshopNoRegistry')}
  492. </p>
  493. </div>
  494. {canCreate && (
  495. <Button
  496. size="sm"
  497. variant={registry ? 'default' : 'outline'}
  498. onClick={addVehicle}
  499. >
  500. <Plus className="mr-1 h-3.5 w-3.5" />
  501. {t('addVehicle')}
  502. </Button>
  503. )}
  504. </div>
  505. )
  506. )}
  507. {/* The registry's half: everything it publishes, as a sheet. */}
  508. {details.length > 0 && (
  509. <dl className="grid grid-cols-2 gap-x-4 gap-y-3 px-5 py-4 sm:grid-cols-3">
  510. {details.map((row) => (
  511. <div key={row.key} className="min-w-0">
  512. <dt className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
  513. {row.label}
  514. </dt>
  515. <dd
  516. className={cn(
  517. 'truncate text-sm',
  518. row.tone === 'overdue' && 'font-medium text-destructive',
  519. row.tone === 'soon' && 'font-medium text-amber-600 dark:text-amber-400'
  520. )}
  521. title={row.value}
  522. >
  523. {row.value}
  524. {row.tone === 'overdue' && (
  525. <span className="ml-1.5 text-[10px] uppercase">{t('overdue')}</span>
  526. )}
  527. {row.tone === 'soon' && (
  528. <span className="ml-1.5 text-[10px] uppercase">{t('dueSoon')}</span>
  529. )}
  530. </dd>
  531. </div>
  532. ))}
  533. </dl>
  534. )}
  535. {registry?.tyres && registry.tyres.length > 0 && (
  536. <div className="px-5 py-4">
  537. <p className="mb-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
  538. {t('tyres')}
  539. </p>
  540. <table className="w-full text-sm">
  541. <thead className="text-left text-xs text-muted-foreground">
  542. <tr>
  543. <th className="pb-1 font-medium" />
  544. <th className="pb-1 font-medium">{t('tyreSize')}</th>
  545. <th className="pb-1 font-medium">{t('rimSize')}</th>
  546. <th className="pb-1 font-medium">{t('loadSpeed')}</th>
  547. </tr>
  548. </thead>
  549. <tbody>
  550. {registry.tyres.map((axle) => (
  551. <tr key={axle.axle} className="border-t">
  552. <td className="py-1.5 text-muted-foreground">
  553. {t('axle', { n: axle.axle })}
  554. </td>
  555. <td className="py-1.5 font-mono">{axle.tyre ?? '–'}</td>
  556. <td className="py-1.5 font-mono">{axle.rim ?? '–'}</td>
  557. <td className="py-1.5 font-mono">
  558. {[axle.loadIndex, axle.speedRating].filter(Boolean).join(' ') || '–'}
  559. </td>
  560. </tr>
  561. ))}
  562. </tbody>
  563. </table>
  564. </div>
  565. )}
  566. </div>
  567. )}
  568. </div>
  569. <div className="flex items-center gap-4 border-t px-4 py-2 text-xs text-muted-foreground">
  570. <span>
  571. <kbd className="pointer-events-none inline-flex h-5 items-center rounded border bg-muted px-1.5 font-mono text-[10px] font-medium">
  572. ↵
  573. </kbd>
  574. <span className="ml-1.5">{t('hintEnter')}</span>
  575. </span>
  576. <span>
  577. <kbd className="pointer-events-none inline-flex h-5 items-center rounded border bg-muted px-1.5 font-mono text-[10px] font-medium">
  578. ESC
  579. </kbd>
  580. <span className="ml-1.5">{t('hintClose')}</span>
  581. </span>
  582. <span className="ml-auto hidden sm:inline">{t('shortcut')}</span>
  583. </div>
  584. </DialogContent>
  585. </Dialog>
  586. <VehicleForm
  587. open={formOpen}
  588. onOpenChange={(next) => {
  589. setFormOpen(next)
  590. if (!next) setFormLookup(null)
  591. }}
  592. initialLookup={formLookup}
  593. />
  594. </>
  595. )
  596. }