'use client' import { useEffect, useRef, useState } from 'react' import { useLocale, useTranslations } from 'next-intl' import { Braces, FileSpreadsheet, Loader2, Send, Table2, Upload, X } from 'lucide-react' import { toast } from 'sonner' import { useServiceType } from '@/components/service-type-context' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' import { Dialog, DialogBody, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { useFormatDate } from '@/lib/use-format-date' import { track } from '@/lib/analytics' import { cn } from '@/lib/utils' import { type Locale, localeNames, locales } from '@/i18n/config' import type { CodeImportChecked, CodeImportCounts, CodeImportStatus, } from '@/features/integrations/Lib/types' import { getCodeImports } from '../Actions/getCodeImports' import { MakeCombobox, type PickedMake } from './MakeCombobox' import { MAKES, makeKey, makeName } from '../Lib/makes' const CHECK_URL = '/api/protected/fault-codes/import/check' const SEND_URL = '/api/protected/fault-codes/import' const EXAMPLE_URL = '/api/protected/fault-codes/import/example' const FILE_PROBLEMS = [ 'unreadable', 'empty', 'no_code_column', 'too_many', 'format', 'legacy_xls', ] as const const FAILURES = ['paused', 'nothing', 'demo', 'rights', 'too_large'] as const type Row = CodeImportChecked & { title: string | null } type Checked = { counts: CodeImportCounts; rows: Row[] } type Reply = { success: true; data: T } | { success: false; error?: string; problem?: string } /** A JSON file's own make, language and source, to fill the fields the person left empty. */ async function jsonMeta( file: File ): Promise<{ make?: string; language?: string; source?: string }> { if (!file.name.toLowerCase().endsWith('.json')) return {} try { const parsed = JSON.parse(await file.text()) if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') return {} const s = (v: unknown) => (typeof v === 'string' && v.trim() ? v.trim() : undefined) return { make: s(parsed.make), language: s(parsed.language ?? parsed.lang), source: s(parsed.source), } } catch { return {} } } /** * Importing fault codes from a file: a make's own codes Torqvoice does not have yet, or more * text for codes it has. The examples show the shape; the file is read and every code checked * the moment it is dropped, so the person sees what is new, what adds to ours and what to fix * before anything is sent. What is sent waits for a person at Torqvoice to publish it. */ export function ImportCodesDialog({ open, onOpenChange, }: { open: boolean onOpenChange: (open: boolean) => void }) { const t = useTranslations('faultCodes.import') const locale = useLocale() const isMarine = useServiceType() === 'marine' const { formatDate } = useFormatDate() const [make, setMake] = useState(null) const [lang, setLang] = useState( (locales as readonly string[]).includes(locale) ? locale : 'en' ) const [source, setSource] = useState('') const [file, setFile] = useState(null) const [checking, setChecking] = useState(false) const [checked, setChecked] = useState(null) const [fileProblem, setFileProblem] = useState(null) const [rights, setRights] = useState(false) const [sending, setSending] = useState(false) const [dragging, setDragging] = useState(false) const [imports, setImports] = useState([]) const fileInput = useRef(null) const list = new Intl.ListFormat(locale, { style: 'long', type: 'conjunction' }) const makeKeyChosen = make?.key ?? null // Earlier imports and how far their review has come, each time the dialog opens. useEffect(() => { if (!open) return let cancelled = false getCodeImports() .then((r) => { if (!cancelled && r.success && r.data) setImports(r.data) }) .catch(() => undefined) return () => { cancelled = true } }, [open]) // The file is checked as soon as there is one and a make to check it for. useEffect(() => { if (!file || !makeKeyChosen) { setChecked(null) return } let cancelled = false setChecking(true) setFileProblem(null) const form = new FormData() form.append('file', file, file.name) form.append('make', makeKeyChosen) form.append('lang', lang) fetch(CHECK_URL, { method: 'POST', body: form }) .then((res) => res.json().catch(() => null) as Promise | null>) .then((reply) => { if (cancelled) return if (reply?.success) { setChecked(reply.data) const c = reply.data.counts track('fault_code:import_check', { make: makeKeyChosen, file_type: file.name.split('.').pop()?.toLowerCase() ?? '', rows: reply.data.rows.length, new: c.new, change: c.change, same: c.same, problem: c.problem, }) return } setChecked(null) setFileProblem(FILE_PROBLEMS.find((p) => p === reply?.problem) ?? 'failed') }) .catch(() => { if (!cancelled) setFileProblem('failed') }) .finally(() => { if (!cancelled) setChecking(false) }) return () => { cancelled = true } }, [file, makeKeyChosen, lang]) const pick = async (picked: File | undefined) => { if (!picked) return setFile(picked) setRights(false) if (fileInput.current) fileInput.current.value = '' const meta = await jsonMeta(picked) if (meta.make && !make) { const key = makeKey(meta.make) if (key) setMake({ key, name: MAKES.find((m) => m.key === key)?.name ?? meta.make }) } if (meta.language && (locales as readonly string[]).includes(meta.language)) setLang(meta.language) if (meta.source && !source) setSource(meta.source) } const clearFile = () => { setFile(null) setChecked(null) setFileProblem(null) setRights(false) } const toSend = checked ? checked.counts.new + checked.counts.change : 0 const send = async () => { if (!file || !makeKeyChosen || !rights || toSend === 0 || sending) return setSending(true) const form = new FormData() form.append('file', file, file.name) form.append('make', makeKeyChosen) form.append('lang', lang) if (source.trim()) form.append('source', source.trim()) form.append('rights', 'yes') let reply: Reply<{ id: string; counts: CodeImportCounts; imports: CodeImportStatus[] }> | null = null try { const res = await fetch(SEND_URL, { method: 'POST', body: form }) reply = await res.json().catch(() => null) } catch { reply = null } setSending(false) if (reply?.success) { track('fault_code:import_send', { make: makeKeyChosen, codes: reply.data.counts.new + reply.data.counts.change, new: reply.data.counts.new, change: reply.data.counts.change, source: Boolean(source.trim()), }) setImports(reply.data.imports) clearFile() onOpenChange(false) toast.success(t('sent', { count: reply.data.counts.new + reply.data.counts.change })) return } const failure = FAILURES.find((f) => f === reply?.error) toast.error(failure ? t(`errors.${failure}`) : t('errors.failed')) } const rowState = (r: Row) => { if (r.state === 'problem') return t(`problems.${r.problem ?? 'code'}`, { make: make?.name ?? '' }) if (r.state === 'new') return t('state.new') const what = r.fields.filter((f) => f !== 'causes').map((f) => t(`fields.${f}`)) if (r.addedCauses > 0) what.push(t('addsCauses', { count: r.addedCauses })) else if (r.fields.includes('causes')) what.push(t('fields.causes')) return t('state.change', { what: list.format(what) }) } // Problems first, so they are fixed before anything else is read. const shown = checked ? [ ...checked.rows.filter((r) => r.state === 'problem'), ...checked.rows.filter((r) => r.state === 'new' || r.state === 'change'), ] : [] const missing = !makeKeyChosen ? t('missing.make') : !file ? t('missing.file') : checking || !checked ? null : toSend === 0 ? t('missing.nothing') : !rights ? t('missing.rights') : null return ( !sending && onOpenChange(next)}> {t('title')} {t('description')} {imports.length > 0 && (

{t('yours')}

    {imports.slice(0, 3).map((i) => (
  • {makeName(i.make)} {t('codes', { count: i.counts.new + i.counts.change })} {[ i.counts.published > 0 ? t('status.published', { count: i.counts.published }) : null, i.counts.pending > 0 ? t('status.pending', { count: i.counts.pending }) : null, i.counts.rejected > 0 ? t('status.rejected', { count: i.counts.rejected }) : null, formatDate(i.createdAt), ] .filter(Boolean) .join(' ยท ')}
  • ))}
)}

{t('examples.title')}

{t('examples.columns')}{' '} track('fault_code:import_example_download', { format: 'csv' })} className="underline underline-offset-2 hover:text-foreground" > {t('examples.csv')}

setSource(e.target.value)} />
{file ? (
{file.name} {checking ? t('checking') : checked ? t('codes', { count: checked.rows.length }) : !makeKeyChosen ? t('missing.make') : ''} {checking && ( )}
) : ( )} void pick(e.target.files?.[0])} /> {fileProblem && (

{fileProblem === 'failed' ? t('errors.failed') : t(`fileProblems.${fileProblem as (typeof FILE_PROBLEMS)[number]}`)}

)} {checked && ( <>
{checked.counts.new > 0 && ( {t('counts.new', { count: checked.counts.new })} )} {checked.counts.change > 0 && ( {t('counts.change', { count: checked.counts.change })} )} {checked.counts.problem > 0 && ( {t('counts.problem', { count: checked.counts.problem })} )} {checked.counts.same > 0 && ( {t('counts.same', { count: checked.counts.same })} )}
{shown.length > 0 && (
    {shown.slice(0, 200).map((r) => (
  • {r.code} {r.title ?? ( {t('row', { n: r.n })} )} {r.state === 'problem' ? `${t('row', { n: r.n })}: ${rowState(r)}` : rowState(r)}
  • ))}
)} {shown.length > 200 && (

{t('more', { count: shown.length - 200 })}

)} {checked.counts.problem > 0 && toSend > 0 && (

{t('problemsLeftOut')}

)} {toSend > 0 && ( )} )}

{missing ?? t('reviewHint')}

) }