Răsfoiți Sursa

Show job, request and inspection statuses in the customer's own words in the portal (#522)

* Show job, request and inspection statuses in the customer's own words in the portal

* Name cancelled requests in the portal too, and match the request words' gender
Bernt Christian Egeland 3 zile în urmă
părinte
comite
4344d98894

+ 19 - 0
messages/de/portal.json

@@ -244,5 +244,24 @@
     "confirmationSubject": "Ihre Buchung bei {workshop}",
     "confirmationBody": "Ihr {vehicle} ist für {when} bei {workshop} gebucht. Änderung nötig? Nutzen Sie den Link aus unserer letzten Nachricht oder rufen Sie {phone} an.",
     "confirmationBodyRequest": "Danke, {workshop} hat Ihre Anfrage für {when} für Ihren {vehicle} erhalten und bestätigt in Kürze. Fragen? Rufen Sie {phone} an."
+  },
+  "status": {
+    "job": {
+      "pending": "Gebucht",
+      "scheduled": "Geplant",
+      "inProgress": "In Arbeit",
+      "waitingParts": "Wartet auf Teile",
+      "completed": "Abgeschlossen"
+    },
+    "request": {
+      "pending": "Offen",
+      "converted": "Gebucht",
+      "dismissed": "Geschlossen",
+      "cancelled": "Abgesagt"
+    },
+    "inspection": {
+      "inProgress": "In Arbeit",
+      "completed": "Abgeschlossen"
+    }
   }
 }

+ 19 - 0
messages/en/portal.json

@@ -244,5 +244,24 @@
     "confirmationSubject": "Your booking at {workshop}",
     "confirmationBody": "Your {vehicle} is booked for {when} at {workshop}. Need to change it? Use the link in our previous message or call {phone}.",
     "confirmationBodyRequest": "Thanks, {workshop} has received your request for {when} for your {vehicle} and will confirm shortly. Questions? Call {phone}."
+  },
+  "status": {
+    "job": {
+      "pending": "Booked",
+      "scheduled": "Scheduled",
+      "inProgress": "In progress",
+      "waitingParts": "Waiting for parts",
+      "completed": "Completed"
+    },
+    "request": {
+      "pending": "Pending",
+      "converted": "Booked",
+      "dismissed": "Closed",
+      "cancelled": "Cancelled"
+    },
+    "inspection": {
+      "inProgress": "In progress",
+      "completed": "Completed"
+    }
   }
 }

+ 19 - 0
messages/es/portal.json

@@ -244,5 +244,24 @@
     "confirmationSubject": "Tu reserva en {workshop}",
     "confirmationBody": "Tu {vehicle} está reservado para {when} en {workshop}. ¿Necesitas cambiarlo? Usa el enlace de nuestro mensaje anterior o llama al {phone}.",
     "confirmationBodyRequest": "Gracias, {workshop} ha recibido tu solicitud para {when} para tu {vehicle} y la confirmará en breve. ¿Dudas? Llama al {phone}."
+  },
+  "status": {
+    "job": {
+      "pending": "Reservado",
+      "scheduled": "Programado",
+      "inProgress": "En curso",
+      "waitingParts": "Esperando piezas",
+      "completed": "Completado"
+    },
+    "request": {
+      "pending": "Pendiente",
+      "converted": "Reservada",
+      "dismissed": "Cerrada",
+      "cancelled": "Cancelada"
+    },
+    "inspection": {
+      "inProgress": "En curso",
+      "completed": "Completada"
+    }
   }
 }

+ 19 - 0
messages/fr/portal.json

@@ -244,5 +244,24 @@
     "confirmationSubject": "Votre réservation chez {workshop}",
     "confirmationBody": "Votre {vehicle} est réservé pour {when} chez {workshop}. Besoin de changer ? Utilisez le lien de notre message précédent ou appelez le {phone}.",
     "confirmationBodyRequest": "Merci, {workshop} a reçu votre demande pour {when} pour votre {vehicle} et confirmera sous peu. Des questions ? Appelez le {phone}."
+  },
+  "status": {
+    "job": {
+      "pending": "Réservé",
+      "scheduled": "Planifié",
+      "inProgress": "En cours",
+      "waitingParts": "En attente de pièces",
+      "completed": "Terminé"
+    },
+    "request": {
+      "pending": "En attente",
+      "converted": "Réservée",
+      "dismissed": "Clôturée",
+      "cancelled": "Annulée"
+    },
+    "inspection": {
+      "inProgress": "En cours",
+      "completed": "Terminée"
+    }
   }
 }

+ 19 - 0
messages/it/portal.json

@@ -244,5 +244,24 @@
     "confirmationSubject": "La tua prenotazione da {workshop}",
     "confirmationBody": "La tua {vehicle} è prenotata per {when} da {workshop}. Devi cambiare? Usa il link del messaggio precedente o chiama il {phone}.",
     "confirmationBodyRequest": "Grazie, {workshop} ha ricevuto la tua richiesta per {when} per la tua {vehicle} e confermerà a breve. Domande? Chiama il {phone}."
+  },
+  "status": {
+    "job": {
+      "pending": "Prenotato",
+      "scheduled": "Programmato",
+      "inProgress": "In corso",
+      "waitingParts": "In attesa di ricambi",
+      "completed": "Completato"
+    },
+    "request": {
+      "pending": "In attesa",
+      "converted": "Prenotata",
+      "dismissed": "Chiusa",
+      "cancelled": "Annullata"
+    },
+    "inspection": {
+      "inProgress": "In corso",
+      "completed": "Completata"
+    }
   }
 }

+ 19 - 0
messages/lt/portal.json

@@ -244,5 +244,24 @@
     "confirmationSubject": "Jūsų rezervacija: {workshop}",
     "confirmationBody": "Jūsų {vehicle} rezervuota {when} dirbtuvėse {workshop}. Reikia pakeisti? Naudokite nuorodą ankstesnėje žinutėje arba skambinkite {phone}.",
     "confirmationBodyRequest": "Ačiū, {workshop} gavo jūsų užklausą dėl {when} jūsų {vehicle} ir netrukus patvirtins. Klausimai? Skambinkite {phone}."
+  },
+  "status": {
+    "job": {
+      "pending": "Užregistruota",
+      "scheduled": "Suplanuota",
+      "inProgress": "Vykdoma",
+      "waitingParts": "Laukiama dalių",
+      "completed": "Baigta"
+    },
+    "request": {
+      "pending": "Laukiama",
+      "converted": "Užregistruota",
+      "dismissed": "Uždaryta",
+      "cancelled": "Atšaukta"
+    },
+    "inspection": {
+      "inProgress": "Vykdoma",
+      "completed": "Baigta"
+    }
   }
 }

+ 19 - 0
messages/nb/portal.json

@@ -244,5 +244,24 @@
     "confirmationSubject": "Din time hos {workshop}",
     "confirmationBody": "{vehicle} er bestilt til {when} hos {workshop}. Må du endre? Bruk lenken i forrige melding eller ring {phone}.",
     "confirmationBodyRequest": "Takk, {workshop} har mottatt forespørselen din om {when} for {vehicle} og bekrefter snart. Spørsmål? Ring {phone}."
+  },
+  "status": {
+    "job": {
+      "pending": "Booket",
+      "scheduled": "Planlagt",
+      "inProgress": "Pågår",
+      "waitingParts": "Venter på deler",
+      "completed": "Fullført"
+    },
+    "request": {
+      "pending": "Venter",
+      "converted": "Booket",
+      "dismissed": "Avsluttet",
+      "cancelled": "Avlyst"
+    },
+    "inspection": {
+      "inProgress": "Pågår",
+      "completed": "Fullført"
+    }
   }
 }

+ 19 - 0
messages/nl/portal.json

@@ -244,5 +244,24 @@
     "confirmationSubject": "Uw boeking bij {workshop}",
     "confirmationBody": "Uw {vehicle} is geboekt voor {when} bij {workshop}. Wijzigen? Gebruik de link in ons vorige bericht of bel {phone}.",
     "confirmationBodyRequest": "Bedankt, {workshop} heeft uw verzoek voor {when} voor uw {vehicle} ontvangen en bevestigt binnenkort. Vragen? Bel {phone}."
+  },
+  "status": {
+    "job": {
+      "pending": "Geboekt",
+      "scheduled": "Ingepland",
+      "inProgress": "Bezig",
+      "waitingParts": "Wacht op onderdelen",
+      "completed": "Afgerond"
+    },
+    "request": {
+      "pending": "In behandeling",
+      "converted": "Geboekt",
+      "dismissed": "Gesloten",
+      "cancelled": "Geannuleerd"
+    },
+    "inspection": {
+      "inProgress": "Bezig",
+      "completed": "Afgerond"
+    }
   }
 }

+ 19 - 0
messages/pl/portal.json

@@ -244,5 +244,24 @@
     "confirmationSubject": "Twoja rezerwacja w {workshop}",
     "confirmationBody": "Twój {vehicle} jest zarezerwowany na {when} w {workshop}. Chcesz zmienić? Użyj linku z poprzedniej wiadomości lub zadzwoń: {phone}.",
     "confirmationBodyRequest": "Dziękujemy, {workshop} otrzymał prośbę o {when} dla Twojego {vehicle} i wkrótce ją potwierdzi. Pytania? Zadzwoń: {phone}."
+  },
+  "status": {
+    "job": {
+      "pending": "Zarezerwowane",
+      "scheduled": "Zaplanowane",
+      "inProgress": "W toku",
+      "waitingParts": "Czeka na części",
+      "completed": "Zakończone"
+    },
+    "request": {
+      "pending": "Oczekuje",
+      "converted": "Zarezerwowane",
+      "dismissed": "Zamknięte",
+      "cancelled": "Anulowane"
+    },
+    "inspection": {
+      "inProgress": "W toku",
+      "completed": "Zakończona"
+    }
   }
 }

+ 19 - 0
messages/pt-BR/portal.json

@@ -244,5 +244,24 @@
     "confirmationSubject": "Seu agendamento na {workshop}",
     "confirmationBody": "Seu {vehicle} está agendado para {when} na {workshop}. Precisa alterar? Use o link da mensagem anterior ou ligue para {phone}.",
     "confirmationBodyRequest": "Obrigado, a {workshop} recebeu sua solicitação para {when} para o seu {vehicle} e confirmará em breve. Dúvidas? Ligue para {phone}."
+  },
+  "status": {
+    "job": {
+      "pending": "Agendado",
+      "scheduled": "Programado",
+      "inProgress": "Em andamento",
+      "waitingParts": "Aguardando peças",
+      "completed": "Concluído"
+    },
+    "request": {
+      "pending": "Pendente",
+      "converted": "Agendada",
+      "dismissed": "Encerrada",
+      "cancelled": "Cancelada"
+    },
+    "inspection": {
+      "inProgress": "Em andamento",
+      "completed": "Concluída"
+    }
   }
 }

+ 19 - 0
messages/pt-PT/portal.json

@@ -244,5 +244,24 @@
     "confirmationSubject": "A sua marcação na {workshop}",
     "confirmationBody": "A sua viatura {vehicle} está marcada para {when} na {workshop}. Precisa de alterar? Use o link da nossa mensagem anterior ou ligue para o {phone}.",
     "confirmationBodyRequest": "Obrigado! A {workshop} recebeu o seu pedido para {when} para a sua viatura {vehicle} e irá confirmá-lo em breve. Dúvidas? Ligue para o {phone}."
+  },
+  "status": {
+    "job": {
+      "pending": "Marcado",
+      "scheduled": "Agendado",
+      "inProgress": "Em curso",
+      "waitingParts": "A aguardar peças",
+      "completed": "Concluído"
+    },
+    "request": {
+      "pending": "Pendente",
+      "converted": "Marcado",
+      "dismissed": "Encerrado",
+      "cancelled": "Cancelado"
+    },
+    "inspection": {
+      "inProgress": "Em curso",
+      "completed": "Concluída"
+    }
   }
 }

+ 19 - 0
messages/ru/portal.json

@@ -244,5 +244,24 @@
     "confirmationSubject": "Ваша запись в {workshop}",
     "confirmationBody": "Ваш {vehicle} записан на {when} в {workshop}. Нужно изменить? Воспользуйтесь ссылкой из предыдущего сообщения или позвоните {phone}.",
     "confirmationBodyRequest": "Спасибо, {workshop} получила ваш запрос на {when} для {vehicle} и скоро подтвердит. Вопросы? Звоните {phone}."
+  },
+  "status": {
+    "job": {
+      "pending": "Записан",
+      "scheduled": "Запланирован",
+      "inProgress": "В работе",
+      "waitingParts": "Ожидает запчасти",
+      "completed": "Завершён"
+    },
+    "request": {
+      "pending": "Ожидает",
+      "converted": "Записана",
+      "dismissed": "Закрыта",
+      "cancelled": "Отменена"
+    },
+    "inspection": {
+      "inProgress": "В работе",
+      "completed": "Завершена"
+    }
   }
 }

+ 19 - 0
messages/tr/portal.json

@@ -244,5 +244,24 @@
     "confirmationSubject": "{workshop} randevunuz",
     "confirmationBody": "{vehicle} aracınız {when} için {workshop} adresinde randevulandı. Değiştirmek mi gerekiyor? Önceki mesajımızdaki bağlantıyı kullanın veya {phone} numarasını arayın.",
     "confirmationBodyRequest": "Teşekkürler, {workshop} {vehicle} aracınız için {when} talebinizi aldı ve kısa süre içinde onaylayacak. Sorunuz mu var? {phone} numarasını arayın."
+  },
+  "status": {
+    "job": {
+      "pending": "Rezerve edildi",
+      "scheduled": "Planlandı",
+      "inProgress": "Devam ediyor",
+      "waitingParts": "Parça bekleniyor",
+      "completed": "Tamamlandı"
+    },
+    "request": {
+      "pending": "Beklemede",
+      "converted": "Rezerve edildi",
+      "dismissed": "Kapatıldı",
+      "cancelled": "İptal edildi"
+    },
+    "inspection": {
+      "inProgress": "Devam ediyor",
+      "completed": "Tamamlandı"
+    }
   }
 }

+ 48 - 0
src/__tests__/features/portal/status-labels.test.ts

@@ -0,0 +1,48 @@
+import fs from 'node:fs'
+import path from 'node:path'
+import { describe, expect, it } from 'vitest'
+import { portalStatusLabel } from '@/features/portal/Lib/statusLabels'
+
+/**
+ * The portal names a job's, a request's and an inspection's status in the
+ * customer's words and language. It used to print the stored value, so a
+ * customer read "in-progress" or "in_progress" whatever their language.
+ */
+
+const read = (locale: string) =>
+  JSON.parse(fs.readFileSync(path.join(process.cwd(), 'messages', locale, 'portal.json'), 'utf8'))
+const LOCALES = fs.readdirSync(path.join(process.cwd(), 'messages'))
+const tFor = (locale: string) => (key: string) =>
+  key.split('.').reduce((o, k) => o?.[k], read(locale).status) as string
+
+describe('statuses in the customer portal', () => {
+  const t = tFor('en')
+
+  it('names the statuses a customer meets in plain words', () => {
+    expect(portalStatusLabel('job', 'in-progress', t)).toBe('In progress')
+    expect(portalStatusLabel('job', 'waiting-parts', t)).toBe('Waiting for parts')
+    expect(portalStatusLabel('job', 'pending', t)).toBe('Booked')
+    expect(portalStatusLabel('request', 'converted', t)).toBe('Booked')
+    expect(portalStatusLabel('request', 'cancelled', t)).toBe('Cancelled')
+    expect(portalStatusLabel('inspection', 'in_progress', t)).toBe('In progress')
+  })
+
+  it('tidies a status it does not know rather than showing the stored value', () => {
+    expect(portalStatusLabel('job', 'on_hold', t)).toBe('On hold')
+  })
+
+  it.each(LOCALES)('%s has a word for every status', (locale) => {
+    const tl = tFor(locale)
+    for (const [kind, statuses] of Object.entries({
+      job: ['pending', 'scheduled', 'in-progress', 'waiting-parts', 'completed'],
+      request: ['pending', 'converted', 'dismissed', 'cancelled'],
+      inspection: ['in_progress', 'completed'],
+    })) {
+      for (const status of statuses) {
+        const label = portalStatusLabel(kind as 'job', status, tl)
+        expect(label, `${locale} ${kind} ${status}`).toEqual(expect.any(String))
+        expect(label).not.toMatch(/[-_]/)
+      }
+    }
+  })
+})

+ 4 - 2
src/app/(public)/portal/[orgId]/dashboard/page.tsx

@@ -18,6 +18,7 @@ import { getTranslations } from 'next-intl/server'
 import { quoteStatusLabel } from '@/features/quotes/Lib/quoteStatus'
 import { resolvePortalOrg } from '@/lib/portal-slug'
 import { workshopTimeZone } from '@/lib/workshop-timezone'
+import { portalStatusLabel } from '@/features/portal/Lib/statusLabels'
 
 export default async function PortalDashboardPage({
   params,
@@ -29,6 +30,7 @@ export default async function PortalDashboardPage({
   const org = await resolvePortalOrg(orgId)
   const timeZone = org ? await workshopTimeZone(org.id) : 'UTC'
   const t = await getTranslations('portal.dashboard')
+  const tStatus = await getTranslations('portal.status')
   const tQuoteStatus = await getTranslations('quotes.statusLabels')
   const tInvoices = await getTranslations('portal.invoices')
   const result = await getPortalDashboard()
@@ -145,7 +147,7 @@ export default async function PortalDashboardPage({
                     </div>
                     <div className="flex shrink-0 items-center gap-3">
                       <Badge variant="outline" className="text-xs">
-                        {inv.status}
+                        {portalStatusLabel('job', inv.status, tStatus)}
                       </Badge>
                       {inv.publicToken && (
                         <Link
@@ -241,7 +243,7 @@ export default async function PortalDashboardPage({
                     </p>
                   </div>
                   <Badge variant="secondary" className="text-xs">
-                    {job.status}
+                    {portalStatusLabel('job', job.status, tStatus)}
                   </Badge>
                 </li>
               ))}

+ 4 - 2
src/app/(public)/portal/[orgId]/inspections/page.tsx

@@ -15,6 +15,7 @@ import { getTranslations } from 'next-intl/server'
 import { resolvePortalOrg } from '@/lib/portal-slug'
 import { workshopTimeZone } from '@/lib/workshop-timezone'
 import { portalConditionCounts } from '@/features/portal/Lib/inspectionSummary'
+import { portalStatusLabel } from '@/features/portal/Lib/statusLabels'
 
 export default async function PortalInspectionsPage({
   params,
@@ -26,6 +27,7 @@ export default async function PortalInspectionsPage({
   const org = await resolvePortalOrg(orgId)
   const timeZone = org ? await workshopTimeZone(org.id) : 'UTC'
   const t = await getTranslations('portal.inspections')
+  const tStatus = await getTranslations('portal.status')
   const result = await getPortalInspections()
 
   if (!result.success || !result.data) {
@@ -73,7 +75,7 @@ export default async function PortalInspectionsPage({
                         variant={insp.status === 'completed' ? 'default' : 'secondary'}
                         className="shrink-0"
                       >
-                        {insp.status.replace('_', ' ')}
+                        {portalStatusLabel('inspection', insp.status, tStatus)}
                       </Badge>
                     </div>
                     <p className="truncate text-xs text-muted-foreground">{insp.template.name}</p>
@@ -140,7 +142,7 @@ export default async function PortalInspectionsPage({
                         <TableCell>{insp.template.name}</TableCell>
                         <TableCell>
                           <Badge variant={insp.status === 'completed' ? 'default' : 'secondary'}>
-                            {insp.status.replace('_', ' ')}
+                            {portalStatusLabel('inspection', insp.status, tStatus)}
                           </Badge>
                         </TableCell>
                         <TableCell>

+ 5 - 1
src/app/(public)/portal/[orgId]/request-service/page.tsx

@@ -9,6 +9,7 @@ import { AppCard } from '@/components/app-card'
 import { getTranslations } from 'next-intl/server'
 import { resolvePortalOrg } from '@/lib/portal-slug'
 import { workshopTimeZone } from '@/lib/workshop-timezone'
+import { portalStatusLabel } from '@/features/portal/Lib/statusLabels'
 
 export default async function PortalRequestServicePage({
   params,
@@ -20,6 +21,7 @@ export default async function PortalRequestServicePage({
   const org = await resolvePortalOrg(orgId)
   const timeZone = org ? await workshopTimeZone(org.id) : 'UTC'
   const t = await getTranslations('portal.requestService')
+  const tStatus = await getTranslations('portal.status')
   const [vehiclesResult, requestsResult] = await Promise.all([
     getPortalVehicles(),
     getPortalServiceRequests(),
@@ -92,7 +94,9 @@ export default async function PortalRequestServicePage({
                       </p>
                     )}
                   </div>
-                  <Badge variant={statusVariant(req.status)}>{req.status}</Badge>
+                  <Badge variant={statusVariant(req.status)}>
+                    {portalStatusLabel('request', req.status, tStatus)}
+                  </Badge>
                 </div>
               ))}
             </div>

+ 8 - 2
src/app/(public)/portal/[orgId]/vehicles/[vehicleId]/page.tsx

@@ -14,6 +14,7 @@ import { getWarrantyStatus, type WarrantyStatus } from '@/lib/warranty'
 import { ReportSoldButton } from '@/features/portal/Components/ReportSoldButton'
 import { formatTypeKey } from '@/features/vehicles/Lib/typeKey'
 import { portalConditionCounts } from '@/features/portal/Lib/inspectionSummary'
+import { portalStatusLabel } from '@/features/portal/Lib/statusLabels'
 
 const warrantyBadgeStyles: Record<WarrantyStatus, string> = {
   active: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
@@ -33,6 +34,7 @@ export default async function PortalVehicleDetailPage({
   const timeZone = org ? await workshopTimeZone(org.id) : 'UTC'
   const money = await workshopMoneyFormatter(org?.id)
   const t = await getTranslations('portal.vehicles')
+  const tStatus = await getTranslations('portal.status')
   const tInvoices = await getTranslations('portal.invoices')
   const tWarranty = await getTranslations('vehicles.services.warranty.status')
   const [result, serviceTypeSetting, ownership] = await Promise.all([
@@ -137,7 +139,9 @@ export default async function PortalVehicleDetailPage({
                         </p>
                       </div>
                       <div className="flex items-center gap-3">
-                        <Badge variant="outline">{sr.status}</Badge>
+                        <Badge variant="outline">
+                          {portalStatusLabel('job', sr.status, tStatus)}
+                        </Badge>
                         {(() => {
                           const ws = getWarrantyStatus(
                             sr.warrantyExpiresAt,
@@ -226,7 +230,9 @@ export default async function PortalVehicleDetailPage({
                           </div>
                         </div>
                         <div className="flex items-center gap-2">
-                          <Badge variant="outline">{insp.status}</Badge>
+                          <Badge variant="outline">
+                            {portalStatusLabel('inspection', insp.status, tStatus)}
+                          </Badge>
                           {insp.publicToken && (
                             <Link
                               href={`/share/inspection/${orgId}/${insp.publicToken}`}

+ 40 - 0
src/features/portal/Lib/statusLabels.ts

@@ -0,0 +1,40 @@
+/**
+ * Statuses as a customer reads them in the portal.
+ *
+ * The portal used to print the stored value, so a customer read
+ * "in-progress" or "in_progress" in English whatever their language. The words
+ * live under `portal.status` in every locale; a status this list does not know
+ * yet is shown tidied up rather than as a missing message.
+ */
+
+export type PortalStatusKind = 'job' | 'request' | 'inspection'
+
+/** Stored value to message key, per kind. */
+const KEYS: Record<PortalStatusKind, Record<string, string>> = {
+  job: {
+    pending: 'pending',
+    scheduled: 'scheduled',
+    'in-progress': 'inProgress',
+    'waiting-parts': 'waitingParts',
+    completed: 'completed',
+  },
+  request: {
+    pending: 'pending',
+    converted: 'converted',
+    dismissed: 'dismissed',
+    // A customer who cancels an online inspection booking cancels its request.
+    cancelled: 'cancelled',
+  },
+  inspection: { in_progress: 'inProgress', completed: 'completed' },
+}
+
+export function portalStatusLabel(
+  kind: PortalStatusKind,
+  status: string,
+  t: (key: string) => string
+): string {
+  const key = KEYS[kind][status]
+  if (key) return t(`${kind}.${key}`)
+  const words = status.replace(/[-_]+/g, ' ').trim()
+  return words.charAt(0).toUpperCase() + words.slice(1)
+}