ImportCodesDialog.tsx 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567
  1. 'use client'
  2. import { useEffect, useRef, useState } from 'react'
  3. import { useLocale, useTranslations } from 'next-intl'
  4. import { Braces, FileSpreadsheet, Loader2, Send, Table2, Upload, X } from 'lucide-react'
  5. import { toast } from 'sonner'
  6. import { useServiceType } from '@/components/service-type-context'
  7. import { Button } from '@/components/ui/button'
  8. import { Checkbox } from '@/components/ui/checkbox'
  9. import {
  10. Dialog,
  11. DialogBody,
  12. DialogContent,
  13. DialogDescription,
  14. DialogFooter,
  15. DialogHeader,
  16. DialogTitle,
  17. } from '@/components/ui/dialog'
  18. import { Input } from '@/components/ui/input'
  19. import { Label } from '@/components/ui/label'
  20. import {
  21. Select,
  22. SelectContent,
  23. SelectItem,
  24. SelectTrigger,
  25. SelectValue,
  26. } from '@/components/ui/select'
  27. import { useFormatDate } from '@/lib/use-format-date'
  28. import { track } from '@/lib/analytics'
  29. import { cn } from '@/lib/utils'
  30. import { type Locale, localeNames, locales } from '@/i18n/config'
  31. import type {
  32. CodeImportChecked,
  33. CodeImportCounts,
  34. CodeImportStatus,
  35. } from '@/features/integrations/Lib/types'
  36. import { getCodeImports } from '../Actions/getCodeImports'
  37. import { MakeCombobox, type PickedMake } from './MakeCombobox'
  38. import { MAKES, makeKey, makeName } from '../Lib/makes'
  39. const CHECK_URL = '/api/protected/fault-codes/import/check'
  40. const SEND_URL = '/api/protected/fault-codes/import'
  41. const EXAMPLE_URL = '/api/protected/fault-codes/import/example'
  42. const FILE_PROBLEMS = [
  43. 'unreadable',
  44. 'empty',
  45. 'no_code_column',
  46. 'too_many',
  47. 'format',
  48. 'legacy_xls',
  49. ] as const
  50. const FAILURES = ['paused', 'nothing', 'demo', 'rights', 'too_large'] as const
  51. type Row = CodeImportChecked & { title: string | null }
  52. type Checked = { counts: CodeImportCounts; rows: Row[] }
  53. type Reply<T> = { success: true; data: T } | { success: false; error?: string; problem?: string }
  54. /** A JSON file's own make, language and source, to fill the fields the person left empty. */
  55. async function jsonMeta(
  56. file: File
  57. ): Promise<{ make?: string; language?: string; source?: string }> {
  58. if (!file.name.toLowerCase().endsWith('.json')) return {}
  59. try {
  60. const parsed = JSON.parse(await file.text())
  61. if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') return {}
  62. const s = (v: unknown) => (typeof v === 'string' && v.trim() ? v.trim() : undefined)
  63. return {
  64. make: s(parsed.make),
  65. language: s(parsed.language ?? parsed.lang),
  66. source: s(parsed.source),
  67. }
  68. } catch {
  69. return {}
  70. }
  71. }
  72. /**
  73. * Importing fault codes from a file: a make's own codes Torqvoice does not have yet, or more
  74. * text for codes it has. The examples show the shape; the file is read and every code checked
  75. * the moment it is dropped, so the person sees what is new, what adds to ours and what to fix
  76. * before anything is sent. What is sent waits for a person at Torqvoice to publish it.
  77. */
  78. export function ImportCodesDialog({
  79. open,
  80. onOpenChange,
  81. }: {
  82. open: boolean
  83. onOpenChange: (open: boolean) => void
  84. }) {
  85. const t = useTranslations('faultCodes.import')
  86. const locale = useLocale()
  87. const isMarine = useServiceType() === 'marine'
  88. const { formatDate } = useFormatDate()
  89. const [make, setMake] = useState<PickedMake | null>(null)
  90. const [lang, setLang] = useState<string>(
  91. (locales as readonly string[]).includes(locale) ? locale : 'en'
  92. )
  93. const [source, setSource] = useState('')
  94. const [file, setFile] = useState<File | null>(null)
  95. const [checking, setChecking] = useState(false)
  96. const [checked, setChecked] = useState<Checked | null>(null)
  97. const [fileProblem, setFileProblem] = useState<string | null>(null)
  98. const [rights, setRights] = useState(false)
  99. const [sending, setSending] = useState(false)
  100. const [dragging, setDragging] = useState(false)
  101. const [imports, setImports] = useState<CodeImportStatus[]>([])
  102. const fileInput = useRef<HTMLInputElement>(null)
  103. const list = new Intl.ListFormat(locale, { style: 'long', type: 'conjunction' })
  104. const makeKeyChosen = make?.key ?? null
  105. // Earlier imports and how far their review has come, each time the dialog opens.
  106. useEffect(() => {
  107. if (!open) return
  108. let cancelled = false
  109. getCodeImports()
  110. .then((r) => {
  111. if (!cancelled && r.success && r.data) setImports(r.data)
  112. })
  113. .catch(() => undefined)
  114. return () => {
  115. cancelled = true
  116. }
  117. }, [open])
  118. // The file is checked as soon as there is one and a make to check it for.
  119. useEffect(() => {
  120. if (!file || !makeKeyChosen) {
  121. setChecked(null)
  122. return
  123. }
  124. let cancelled = false
  125. setChecking(true)
  126. setFileProblem(null)
  127. const form = new FormData()
  128. form.append('file', file, file.name)
  129. form.append('make', makeKeyChosen)
  130. form.append('lang', lang)
  131. fetch(CHECK_URL, { method: 'POST', body: form })
  132. .then((res) => res.json().catch(() => null) as Promise<Reply<Checked> | null>)
  133. .then((reply) => {
  134. if (cancelled) return
  135. if (reply?.success) {
  136. setChecked(reply.data)
  137. const c = reply.data.counts
  138. track('fault_code:import_check', {
  139. make: makeKeyChosen,
  140. file_type: file.name.split('.').pop()?.toLowerCase() ?? '',
  141. rows: reply.data.rows.length,
  142. new: c.new,
  143. change: c.change,
  144. same: c.same,
  145. problem: c.problem,
  146. })
  147. return
  148. }
  149. setChecked(null)
  150. setFileProblem(FILE_PROBLEMS.find((p) => p === reply?.problem) ?? 'failed')
  151. })
  152. .catch(() => {
  153. if (!cancelled) setFileProblem('failed')
  154. })
  155. .finally(() => {
  156. if (!cancelled) setChecking(false)
  157. })
  158. return () => {
  159. cancelled = true
  160. }
  161. }, [file, makeKeyChosen, lang])
  162. const pick = async (picked: File | undefined) => {
  163. if (!picked) return
  164. setFile(picked)
  165. setRights(false)
  166. if (fileInput.current) fileInput.current.value = ''
  167. const meta = await jsonMeta(picked)
  168. if (meta.make && !make) {
  169. const key = makeKey(meta.make)
  170. if (key) setMake({ key, name: MAKES.find((m) => m.key === key)?.name ?? meta.make })
  171. }
  172. if (meta.language && (locales as readonly string[]).includes(meta.language))
  173. setLang(meta.language)
  174. if (meta.source && !source) setSource(meta.source)
  175. }
  176. const clearFile = () => {
  177. setFile(null)
  178. setChecked(null)
  179. setFileProblem(null)
  180. setRights(false)
  181. }
  182. const toSend = checked ? checked.counts.new + checked.counts.change : 0
  183. const send = async () => {
  184. if (!file || !makeKeyChosen || !rights || toSend === 0 || sending) return
  185. setSending(true)
  186. const form = new FormData()
  187. form.append('file', file, file.name)
  188. form.append('make', makeKeyChosen)
  189. form.append('lang', lang)
  190. if (source.trim()) form.append('source', source.trim())
  191. form.append('rights', 'yes')
  192. let reply: Reply<{ id: string; counts: CodeImportCounts; imports: CodeImportStatus[] }> | null =
  193. null
  194. try {
  195. const res = await fetch(SEND_URL, { method: 'POST', body: form })
  196. reply = await res.json().catch(() => null)
  197. } catch {
  198. reply = null
  199. }
  200. setSending(false)
  201. if (reply?.success) {
  202. track('fault_code:import_send', {
  203. make: makeKeyChosen,
  204. codes: reply.data.counts.new + reply.data.counts.change,
  205. new: reply.data.counts.new,
  206. change: reply.data.counts.change,
  207. source: Boolean(source.trim()),
  208. })
  209. setImports(reply.data.imports)
  210. clearFile()
  211. onOpenChange(false)
  212. toast.success(t('sent', { count: reply.data.counts.new + reply.data.counts.change }))
  213. return
  214. }
  215. const failure = FAILURES.find((f) => f === reply?.error)
  216. toast.error(failure ? t(`errors.${failure}`) : t('errors.failed'))
  217. }
  218. const rowState = (r: Row) => {
  219. if (r.state === 'problem')
  220. return t(`problems.${r.problem ?? 'code'}`, { make: make?.name ?? '' })
  221. if (r.state === 'new') return t('state.new')
  222. const what = r.fields.filter((f) => f !== 'causes').map((f) => t(`fields.${f}`))
  223. if (r.addedCauses > 0) what.push(t('addsCauses', { count: r.addedCauses }))
  224. else if (r.fields.includes('causes')) what.push(t('fields.causes'))
  225. return t('state.change', { what: list.format(what) })
  226. }
  227. // Problems first, so they are fixed before anything else is read.
  228. const shown = checked
  229. ? [
  230. ...checked.rows.filter((r) => r.state === 'problem'),
  231. ...checked.rows.filter((r) => r.state === 'new' || r.state === 'change'),
  232. ]
  233. : []
  234. const missing = !makeKeyChosen
  235. ? t('missing.make')
  236. : !file
  237. ? t('missing.file')
  238. : checking || !checked
  239. ? null
  240. : toSend === 0
  241. ? t('missing.nothing')
  242. : !rights
  243. ? t('missing.rights')
  244. : null
  245. return (
  246. <Dialog open={open} onOpenChange={(next) => !sending && onOpenChange(next)}>
  247. <DialogContent className="sm:max-w-3xl">
  248. <DialogHeader>
  249. <DialogTitle>{t('title')}</DialogTitle>
  250. <DialogDescription>{t('description')}</DialogDescription>
  251. </DialogHeader>
  252. <DialogBody className="space-y-6">
  253. {imports.length > 0 && (
  254. <section className="space-y-1.5" aria-label={t('yours')}>
  255. <h3 className="text-sm font-medium">{t('yours')}</h3>
  256. <ul className="divide-y divide-card-edge/60 rounded-lg border border-card-edge text-sm">
  257. {imports.slice(0, 3).map((i) => (
  258. <li
  259. key={i.id}
  260. className="flex flex-wrap items-center justify-between gap-x-3 gap-y-0.5 px-3 py-2"
  261. >
  262. <span className="font-medium">
  263. {makeName(i.make)}
  264. <span className="ml-2 font-normal text-muted-foreground">
  265. {t('codes', { count: i.counts.new + i.counts.change })}
  266. </span>
  267. </span>
  268. <span className="text-xs text-muted-foreground">
  269. {[
  270. i.counts.published > 0
  271. ? t('status.published', { count: i.counts.published })
  272. : null,
  273. i.counts.pending > 0
  274. ? t('status.pending', { count: i.counts.pending })
  275. : null,
  276. i.counts.rejected > 0
  277. ? t('status.rejected', { count: i.counts.rejected })
  278. : null,
  279. formatDate(i.createdAt),
  280. ]
  281. .filter(Boolean)
  282. .join(' · ')}
  283. </span>
  284. </li>
  285. ))}
  286. </ul>
  287. </section>
  288. )}
  289. <section className="space-y-2">
  290. <h3 className="text-sm font-medium">{t('examples.title')}</h3>
  291. <div className="grid gap-3 sm:grid-cols-2">
  292. <a
  293. href={`${EXAMPLE_URL}?format=xlsx`}
  294. download
  295. onClick={() => track('fault_code:import_example_download', { format: 'xlsx' })}
  296. className="flex items-center gap-3 rounded-lg border border-card-edge px-3.5 py-3 transition-colors hover:border-primary/40 hover:bg-primary/5"
  297. >
  298. <Table2 className="size-5 shrink-0 text-primary" aria-hidden />
  299. <span className="min-w-0">
  300. <span className="block text-sm font-medium">{t('examples.sheet')}</span>
  301. <span className="block text-xs text-muted-foreground">
  302. {t('examples.sheetHint')}
  303. </span>
  304. </span>
  305. </a>
  306. <a
  307. href={`${EXAMPLE_URL}?format=json`}
  308. download
  309. onClick={() => track('fault_code:import_example_download', { format: 'json' })}
  310. className="flex items-center gap-3 rounded-lg border border-card-edge px-3.5 py-3 transition-colors hover:border-primary/40 hover:bg-primary/5"
  311. >
  312. <Braces className="size-5 shrink-0 text-primary" aria-hidden />
  313. <span className="min-w-0">
  314. <span className="block text-sm font-medium">{t('examples.json')}</span>
  315. <span className="block text-xs text-muted-foreground">
  316. {t('examples.jsonHint')}
  317. </span>
  318. </span>
  319. </a>
  320. </div>
  321. <p className="text-xs text-muted-foreground">
  322. {t('examples.columns')}{' '}
  323. <a
  324. href={`${EXAMPLE_URL}?format=csv`}
  325. download
  326. onClick={() => track('fault_code:import_example_download', { format: 'csv' })}
  327. className="underline underline-offset-2 hover:text-foreground"
  328. >
  329. {t('examples.csv')}
  330. </a>
  331. </p>
  332. </section>
  333. <section className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1.3fr)]">
  334. <div className="space-y-1.5">
  335. <Label htmlFor="import-make" className="text-xs text-muted-foreground">
  336. {t('make')}
  337. </Label>
  338. <MakeCombobox id="import-make" value={make} onChange={setMake} marine={isMarine} />
  339. </div>
  340. <div className="space-y-1.5">
  341. <Label htmlFor="import-lang" className="text-xs text-muted-foreground">
  342. {t('language')}
  343. </Label>
  344. <Select value={lang} onValueChange={setLang}>
  345. <SelectTrigger id="import-lang" className="w-full">
  346. <SelectValue />
  347. </SelectTrigger>
  348. <SelectContent>
  349. {locales.map((l) => (
  350. <SelectItem key={l} value={l}>
  351. {localeNames[l as Locale]}
  352. </SelectItem>
  353. ))}
  354. </SelectContent>
  355. </Select>
  356. </div>
  357. <div className="space-y-1.5">
  358. <Label htmlFor="import-source" className="text-xs text-muted-foreground">
  359. {t('source')}
  360. </Label>
  361. <Input
  362. id="import-source"
  363. value={source}
  364. maxLength={500}
  365. placeholder={t('sourcePlaceholder')}
  366. onChange={(e) => setSource(e.target.value)}
  367. />
  368. </div>
  369. </section>
  370. <section className="space-y-3">
  371. {file ? (
  372. <div className="flex items-center gap-3 rounded-lg border border-card-edge px-3 py-2.5">
  373. <FileSpreadsheet className="size-5 shrink-0 text-primary" aria-hidden />
  374. <span className="min-w-0 flex-1">
  375. <span className="block truncate text-sm font-medium">{file.name}</span>
  376. <span className="block text-xs text-muted-foreground">
  377. {checking
  378. ? t('checking')
  379. : checked
  380. ? t('codes', { count: checked.rows.length })
  381. : !makeKeyChosen
  382. ? t('missing.make')
  383. : ''}
  384. </span>
  385. </span>
  386. {checking && (
  387. <Loader2 className="size-4 animate-spin text-muted-foreground" aria-hidden />
  388. )}
  389. <Button
  390. type="button"
  391. variant="ghost"
  392. size="icon-sm"
  393. className="text-muted-foreground"
  394. aria-label={t('removeFile')}
  395. onClick={clearFile}
  396. >
  397. <X />
  398. </Button>
  399. </div>
  400. ) : (
  401. <button
  402. type="button"
  403. onClick={() => fileInput.current?.click()}
  404. onDragOver={(e) => {
  405. if (!e.dataTransfer.types.includes('Files')) return
  406. e.preventDefault()
  407. setDragging(true)
  408. }}
  409. onDragLeave={() => setDragging(false)}
  410. onDrop={(e) => {
  411. e.preventDefault()
  412. setDragging(false)
  413. void pick(e.dataTransfer.files[0])
  414. }}
  415. className={cn(
  416. 'flex w-full cursor-pointer flex-col items-center justify-center gap-1.5 rounded-lg border-2 border-dashed border-border bg-muted/20 px-4 py-8 text-center text-xs text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-foreground',
  417. dragging && 'border-primary/60 bg-primary/5'
  418. )}
  419. >
  420. <Upload className="size-5" aria-hidden />
  421. <span className="text-sm font-medium text-foreground">{t('drop')}</span>
  422. <span>{t('dropTypes')}</span>
  423. </button>
  424. )}
  425. <input
  426. ref={fileInput}
  427. type="file"
  428. accept=".xlsx,.xlsm,.csv,.txt,.json"
  429. hidden
  430. data-testid="import-file-input"
  431. onChange={(e) => void pick(e.target.files?.[0])}
  432. />
  433. {fileProblem && (
  434. <p className="text-sm font-medium text-destructive">
  435. {fileProblem === 'failed'
  436. ? t('errors.failed')
  437. : t(`fileProblems.${fileProblem as (typeof FILE_PROBLEMS)[number]}`)}
  438. </p>
  439. )}
  440. {checked && (
  441. <>
  442. <div className="flex flex-wrap gap-2">
  443. {checked.counts.new > 0 && (
  444. <span className="rounded-md bg-emerald-500/10 px-2.5 py-1 text-xs font-medium text-emerald-700 dark:text-emerald-300">
  445. {t('counts.new', { count: checked.counts.new })}
  446. </span>
  447. )}
  448. {checked.counts.change > 0 && (
  449. <span className="rounded-md bg-primary/10 px-2.5 py-1 text-xs font-medium text-primary">
  450. {t('counts.change', { count: checked.counts.change })}
  451. </span>
  452. )}
  453. {checked.counts.problem > 0 && (
  454. <span className="rounded-md bg-destructive/10 px-2.5 py-1 text-xs font-medium text-destructive">
  455. {t('counts.problem', { count: checked.counts.problem })}
  456. </span>
  457. )}
  458. {checked.counts.same > 0 && (
  459. <span className="rounded-md bg-muted px-2.5 py-1 text-xs text-muted-foreground">
  460. {t('counts.same', { count: checked.counts.same })}
  461. </span>
  462. )}
  463. </div>
  464. {shown.length > 0 && (
  465. <ul
  466. className="divide-y divide-card-edge/60 rounded-lg border border-card-edge text-sm"
  467. aria-label={t('rows')}
  468. >
  469. {shown.slice(0, 200).map((r) => (
  470. <li
  471. key={r.n}
  472. className="grid grid-cols-[4.5rem_minmax(0,1fr)] gap-x-3 gap-y-0.5 px-3 py-2 sm:grid-cols-[4.5rem_minmax(0,1fr)_minmax(0,14rem)]"
  473. >
  474. <span className="font-mono text-xs leading-5">{r.code}</span>
  475. <span className="truncate">
  476. {r.title ?? (
  477. <span className="text-muted-foreground">{t('row', { n: r.n })}</span>
  478. )}
  479. </span>
  480. <span
  481. className={cn(
  482. 'col-start-2 text-xs sm:col-start-auto sm:text-right',
  483. r.state === 'problem'
  484. ? 'text-destructive'
  485. : r.state === 'new'
  486. ? 'text-emerald-700 dark:text-emerald-300'
  487. : 'text-primary'
  488. )}
  489. >
  490. {r.state === 'problem'
  491. ? `${t('row', { n: r.n })}: ${rowState(r)}`
  492. : rowState(r)}
  493. </span>
  494. </li>
  495. ))}
  496. </ul>
  497. )}
  498. {shown.length > 200 && (
  499. <p className="text-xs text-muted-foreground">
  500. {t('more', { count: shown.length - 200 })}
  501. </p>
  502. )}
  503. {checked.counts.problem > 0 && toSend > 0 && (
  504. <p className="text-xs text-muted-foreground">{t('problemsLeftOut')}</p>
  505. )}
  506. {toSend > 0 && (
  507. <label className="flex items-start gap-2.5 text-sm">
  508. <Checkbox
  509. checked={rights}
  510. onCheckedChange={(v) => setRights(v === true)}
  511. className="mt-0.5"
  512. />
  513. <span>{t('rights')}</span>
  514. </label>
  515. )}
  516. </>
  517. )}
  518. </section>
  519. </DialogBody>
  520. <DialogFooter className="items-center sm:justify-between">
  521. <p className="text-xs text-muted-foreground" aria-live="polite">
  522. {missing ?? t('reviewHint')}
  523. </p>
  524. <div className="flex gap-2">
  525. <Button
  526. type="button"
  527. variant="ghost"
  528. disabled={sending}
  529. onClick={() => onOpenChange(false)}
  530. >
  531. {t('cancel')}
  532. </Button>
  533. <Button
  534. type="button"
  535. disabled={Boolean(missing) || checking || sending || toSend === 0}
  536. onClick={() => void send()}
  537. >
  538. {sending ? <Loader2 className="animate-spin" /> : <Send />}
  539. {toSend > 0 ? t('send', { count: toSend }) : t('sendNone')}
  540. </Button>
  541. </div>
  542. </DialogFooter>
  543. </DialogContent>
  544. </Dialog>
  545. )
  546. }