Jelajahi Sumber

Show work order statuses and types in the reader's language in lists (#542)

* Show work order statuses and types in the reader's language in lists

* Search and sort a customer's documents by the status they show
Bernt Christian Egeland 1 hari lalu
induk
melakukan
d8b1c282f7

+ 1 - 0
messages/de/service.json

@@ -92,6 +92,7 @@
       "inspection": "Überprüfung"
     },
     "statusOptions": {
+      "scheduled": "Geplant",
       "pending": "Ausstehend",
       "in_progress": "In Bearbeitung",
       "waiting_parts": "Warten auf Teile",

+ 1 - 0
messages/en/service.json

@@ -92,6 +92,7 @@
       "inspection": "Inspection"
     },
     "statusOptions": {
+      "scheduled": "Scheduled",
       "pending": "Pending",
       "in_progress": "In Progress",
       "waiting_parts": "Waiting Parts",

+ 1 - 0
messages/es/service.json

@@ -92,6 +92,7 @@
       "inspection": "Inspección"
     },
     "statusOptions": {
+      "scheduled": "Programado",
       "pending": "Pendiente",
       "in_progress": "En Progreso",
       "waiting_parts": "Esperando Piezas",

+ 1 - 0
messages/fr/service.json

@@ -92,6 +92,7 @@
       "inspection": "Inspection"
     },
     "statusOptions": {
+      "scheduled": "Planifié",
       "pending": "En Attente",
       "in_progress": "En Cours",
       "waiting_parts": "En Attente de Pièces",

+ 1 - 0
messages/it/service.json

@@ -92,6 +92,7 @@
       "inspection": "Ispezione"
     },
     "statusOptions": {
+      "scheduled": "Pianificato",
       "pending": "In Sospeso",
       "in_progress": "In Corso",
       "waiting_parts": "In Attesa di Pezzi",

+ 1 - 0
messages/lt/service.json

@@ -92,6 +92,7 @@
       "inspection": "Apžiūra"
     },
     "statusOptions": {
+      "scheduled": "Suplanuota",
       "pending": "Laukia",
       "in_progress": "Vykdoma",
       "waiting_parts": "Laukia dalių",

+ 1 - 0
messages/nb/service.json

@@ -92,6 +92,7 @@
       "inspection": "Inspeksjon"
     },
     "statusOptions": {
+      "scheduled": "Planlagt",
       "pending": "Avventende",
       "in_progress": "Pågår",
       "waiting_parts": "Venter på deler",

+ 1 - 0
messages/nl/service.json

@@ -92,6 +92,7 @@
       "inspection": "Inspectie"
     },
     "statusOptions": {
+      "scheduled": "Ingepland",
       "pending": "In afwachting",
       "in_progress": "In uitvoering",
       "waiting_parts": "Wacht op onderdelen",

+ 1 - 0
messages/pl/service.json

@@ -92,6 +92,7 @@
       "inspection": "Inspekcja"
     },
     "statusOptions": {
+      "scheduled": "Zaplanowane",
       "pending": "Oczekujące",
       "in_progress": "W Trakcie",
       "waiting_parts": "Oczekiwanie na Części",

+ 1 - 0
messages/pt-BR/service.json

@@ -92,6 +92,7 @@
       "inspection": "Inspeção"
     },
     "statusOptions": {
+      "scheduled": "Agendado",
       "pending": "Pendente",
       "in_progress": "Em Progresso",
       "waiting_parts": "Aguardando Peças",

+ 1 - 0
messages/pt-PT/service.json

@@ -92,6 +92,7 @@
       "inspection": "Inspeção"
     },
     "statusOptions": {
+      "scheduled": "Agendado",
       "pending": "Pendente",
       "in_progress": "Em curso",
       "waiting_parts": "A aguardar peças",

+ 1 - 0
messages/ru/service.json

@@ -92,6 +92,7 @@
       "inspection": "Осмотр"
     },
     "statusOptions": {
+      "scheduled": "Запланировано",
       "pending": "Ожидает",
       "in_progress": "В работе",
       "waiting_parts": "Ожидание запчастей",

+ 1 - 0
messages/tr/service.json

@@ -92,6 +92,7 @@
       "inspection": "Muayene"
     },
     "statusOptions": {
+      "scheduled": "Planlandı",
       "pending": "Beklemede",
       "in_progress": "Devam Ediyor",
       "waiting_parts": "Parça Bekleniyor",

+ 54 - 0
src/__tests__/app/customers/document-search.test.ts

@@ -0,0 +1,54 @@
+import { describe, expect, it } from 'vitest'
+import {
+  type DocumentRow,
+  visibleDocuments,
+} from '@/app/(authenticated)/customers/[id]/customer-detail-client'
+
+/**
+ * A customer's invoices show each status as its label ("In Progress"), so a
+ * search for what the badge says has to find it, in whatever language the
+ * reader uses, and sorting by status follows the labels too.
+ */
+const row = (id: string, status: string): DocumentRow => ({
+  id,
+  href: `/x/${id}`,
+  number: id,
+  title: 'Brake pads',
+  vehicle: null,
+  status,
+  date: new Date('2026-11-08T12:00:00Z'),
+  total: 100,
+})
+const rows = [row('2026-1', 'completed'), row('2026-2', 'in-progress'), row('2026-3', 'pending')]
+const german: Record<string, string> = {
+  completed: 'Abgeschlossen',
+  'in-progress': 'In Bearbeitung',
+  pending: 'Ausstehend',
+}
+const options = {
+  sortBy: 'number' as const,
+  sortOrder: 'asc' as const,
+  vehicleLabel: () => '',
+  statusLabel: (status: string) => german[status] ?? status,
+}
+
+describe("a customer's documents", () => {
+  it('are found by the status label the reader sees', () => {
+    const found = visibleDocuments(rows, { ...options, search: 'in bearbeitung' })
+    expect(found.map((r) => r.id)).toEqual(['2026-2'])
+  })
+
+  it('are still found by the stored status', () => {
+    const found = visibleDocuments(rows, { ...options, search: 'pending' })
+    expect(found.map((r) => r.id)).toEqual(['2026-3'])
+  })
+
+  it('sort by status in the order the labels read', () => {
+    const sorted = visibleDocuments(rows, { ...options, search: '', sortBy: 'status' })
+    expect(sorted.map((r) => german[r.status])).toEqual([
+      'Abgeschlossen',
+      'Ausstehend',
+      'In Bearbeitung',
+    ])
+  })
+})

+ 37 - 0
src/__tests__/features/vehicles/service-labels.test.tsx

@@ -0,0 +1,37 @@
+import { renderHook } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import { useServiceLabels } from '@/features/vehicles/Lib/useServiceLabels'
+
+/**
+ * Lists of work orders print a status and a type on every row. They printed
+ * the stored value, "in-progress" and "maintenance", in English whatever the
+ * language, beside a work order that said "In Progress".
+ */
+describe('work order status and type in lists', () => {
+  const labels = () => renderHook(() => useServiceLabels()).result.current
+
+  it('says each stored status the way the work order itself does', () => {
+    const { status } = labels()
+    expect(
+      ['pending', 'scheduled', 'in-progress', 'in_progress', 'waiting-parts', 'completed'].map(
+        status
+      )
+    ).toEqual(['Pending', 'Scheduled', 'In Progress', 'In Progress', 'Waiting Parts', 'Completed'])
+  })
+
+  it('names the kinds of work', () => {
+    const { type } = labels()
+    expect(['maintenance', 'repair', 'upgrade', 'inspection'].map(type)).toEqual([
+      'Maintenance',
+      'Repair',
+      'Upgrade',
+      'Inspection',
+    ])
+  })
+
+  it('prints a value it has no label for as it is stored', () => {
+    const { status, type } = labels()
+    expect(status('on-hold')).toBe('on-hold')
+    expect(type('detailing')).toBe('detailing')
+  })
+})

+ 87 - 43
src/app/(authenticated)/customers/[id]/customer-detail-client.tsx

@@ -73,6 +73,7 @@ import { SendSmsDialog } from '@/features/sms/Components/SendSmsDialog'
 import { TelegramQrCode } from '@/features/telegram/Components/TelegramQrCode'
 import { useServiceType } from '@/components/service-type-context'
 import { toast } from 'sonner'
+import { useServiceLabels } from '@/features/vehicles/Lib/useServiceLabels'
 
 interface ServiceRequestItem {
   id: string
@@ -198,6 +199,12 @@ export function CustomerDetailClient({
 }) {
   const t = useTranslations('customers.detail')
   const tVehicles = useTranslations('vehicles.list')
+  const serviceLabel = useServiceLabels()
+  const tQuoteStatus = useTranslations('quotes.statusLabels')
+  const quoteStatusLabel = useCallback(
+    (status: string) => (tQuoteStatus.has(status) ? tQuoteStatus(status) : status),
+    [tQuoteStatus]
+  )
   const serviceType = useServiceType()
   // A vessel's reading is engine hours, the same number the header calls it.
   const readingUnit = serviceType === 'marine' ? 'hrs' : unitSystem === 'metric' ? 'km' : 'mi'
@@ -658,6 +665,7 @@ export function CustomerDetailClient({
           <DocumentList
             rows={invoiceRows}
             statusStyles={statusColors}
+            statusLabel={serviceLabel.status}
             numberLabel={t('documentTable.invoiceNumber')}
             emptyLabel={t('noInvoices')}
             search={search}
@@ -671,6 +679,7 @@ export function CustomerDetailClient({
           <DocumentList
             rows={quoteRows}
             statusStyles={quoteStatusColors}
+            statusLabel={quoteStatusLabel}
             numberLabel={t('documentTable.quoteNumber')}
             emptyLabel={t('noQuotes')}
             search={search}
@@ -756,7 +765,7 @@ const quoteStatusColors: Record<string, string> = {
   converted: 'bg-purple-500/10 text-purple-500 border-purple-500/20',
 }
 
-interface DocumentRow {
+export interface DocumentRow {
   id: string
   href: string
   number: string | null
@@ -767,7 +776,7 @@ interface DocumentRow {
   total: number
 }
 
-type DocumentSortKey = 'number' | 'title' | 'vehicle' | 'status' | 'date' | 'total'
+export type DocumentSortKey = 'number' | 'title' | 'vehicle' | 'status' | 'date' | 'total'
 
 const DOCUMENT_SORT_KEYS: DocumentSortKey[] = [
   'number',
@@ -778,6 +787,73 @@ const DOCUMENT_SORT_KEYS: DocumentSortKey[] = [
   'total',
 ]
 
+/**
+ * The rows a customer's invoice or quote list shows, searched and sorted as the
+ * reader sees them: a status matches and sorts by its label, not its stored value.
+ */
+export function visibleDocuments(
+  rows: DocumentRow[],
+  {
+    search,
+    sortBy,
+    sortOrder,
+    vehicleLabel,
+    statusLabel,
+  }: {
+    search: string
+    sortBy: DocumentSortKey
+    sortOrder: 'asc' | 'desc'
+    vehicleLabel: (v: DocumentRow['vehicle']) => string
+    statusLabel: (status: string) => string
+  }
+): DocumentRow[] {
+  const term = search.trim().toLowerCase()
+  const filtered = term
+    ? rows.filter((row) =>
+        [
+          row.number,
+          row.title,
+          vehicleLabel(row.vehicle),
+          row.vehicle?.licensePlate,
+          row.status,
+          // What the badge says, so searching for the status you see finds it.
+          statusLabel(row.status),
+        ]
+          .filter(Boolean)
+          .some((field) => field!.toLowerCase().includes(term))
+      )
+    : rows
+
+  const dir = sortOrder === 'asc' ? 1 : -1
+  // Sorting a copy: `rows` is derived from props on every render, but
+  // mutating it would still reorder the array the caller just built.
+  return [...filtered].sort((a, b) => {
+    switch (sortBy) {
+      case 'number':
+        // Unnumbered drafts sort to the end either way rather than
+        // bunching at the top under an empty string.
+        if (!a.number || !b.number) return a.number ? -1 : b.number ? 1 : 0
+        return a.number.localeCompare(b.number, undefined, { numeric: true }) * dir
+      case 'title':
+        return a.title.localeCompare(b.title) * dir
+      case 'vehicle': {
+        // Parts-only rows have no vehicle; keep them at the end either way
+        // rather than letting an empty label head the ascending sort.
+        const av = vehicleLabel(a.vehicle)
+        const bv = vehicleLabel(b.vehicle)
+        if (!av || !bv) return av ? -1 : bv ? 1 : 0
+        return av.localeCompare(bv) * dir
+      }
+      case 'status':
+        return statusLabel(a.status).localeCompare(statusLabel(b.status)) * dir
+      case 'total':
+        return (a.total - b.total) * dir
+      default:
+        return (a.date.getTime() - b.date.getTime()) * dir
+    }
+  })
+}
+
 /// Shared list for the customer's invoices and quotes: the two differ only in
 /// which number and date they carry, so the rows are normalised before they
 /// get here. Both lists are loaded whole (one customer's documents, not the
@@ -785,6 +861,7 @@ const DOCUMENT_SORT_KEYS: DocumentSortKey[] = [
 function DocumentList({
   rows,
   statusStyles,
+  statusLabel,
   numberLabel,
   emptyLabel,
   search,
@@ -794,6 +871,8 @@ function DocumentList({
 }: {
   rows: DocumentRow[]
   statusStyles: Record<string, string>
+  /** The status as the reader's language says it: invoices and quotes have their own. */
+  statusLabel: (status: string) => string
   numberLabel: string
   emptyLabel: string
   search: string
@@ -822,45 +901,10 @@ function DocumentList({
     commitNow: handleSearchSubmit,
   } = useDebouncedSearch(search, (term) => onQueryChange({ search: term }))
 
-  const visibleRows = useMemo(() => {
-    const term = search.trim().toLowerCase()
-    const filtered = term
-      ? rows.filter((row) =>
-          [row.number, row.title, vehicleLabel(row.vehicle), row.vehicle?.licensePlate, row.status]
-            .filter(Boolean)
-            .some((field) => field!.toLowerCase().includes(term))
-        )
-      : rows
-
-    const dir = sortOrder === 'asc' ? 1 : -1
-    // Sorting a copy: `rows` is derived from props on every render, but
-    // mutating it would still reorder the array the caller just built.
-    return [...filtered].sort((a, b) => {
-      switch (sortBy) {
-        case 'number':
-          // Unnumbered drafts sort to the end either way rather than
-          // bunching at the top under an empty string.
-          if (!a.number || !b.number) return a.number ? -1 : b.number ? 1 : 0
-          return a.number.localeCompare(b.number, undefined, { numeric: true }) * dir
-        case 'title':
-          return a.title.localeCompare(b.title) * dir
-        case 'vehicle': {
-          // Parts-only rows have no vehicle; keep them at the end either way
-          // rather than letting an empty label head the ascending sort.
-          const av = vehicleLabel(a.vehicle)
-          const bv = vehicleLabel(b.vehicle)
-          if (!av || !bv) return av ? -1 : bv ? 1 : 0
-          return av.localeCompare(bv) * dir
-        }
-        case 'status':
-          return a.status.localeCompare(b.status) * dir
-        case 'total':
-          return (a.total - b.total) * dir
-        default:
-          return (a.date.getTime() - b.date.getTime()) * dir
-      }
-    })
-  }, [rows, search, sortBy, sortOrder, vehicleLabel])
+  const visibleRows = useMemo(
+    () => visibleDocuments(rows, { search, sortBy, sortOrder, vehicleLabel, statusLabel }),
+    [rows, search, sortBy, sortOrder, vehicleLabel, statusLabel]
+  )
 
   const handleSort = (column: DocumentSortKey) => {
     // First click on a new column sorts descending for dates and amounts and
@@ -967,7 +1011,7 @@ function DocumentList({
                 </div>
                 <div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
                   <Badge variant="outline" className={`text-xs ${statusStyles[row.status] || ''}`}>
-                    {row.status}
+                    {statusLabel(row.status)}
                   </Badge>
                   <span className="font-mono">{formatDate(row.date)}</span>
                   <span className="truncate">{displayVehicle(row.vehicle)}</span>
@@ -1022,7 +1066,7 @@ function DocumentList({
                         variant="outline"
                         className={`text-xs ${statusStyles[row.status] || ''}`}
                       >
-                        {row.status}
+                        {statusLabel(row.status)}
                       </Badge>
                     </TableCell>
                     <TableCell className="font-mono text-xs">{formatDate(row.date)}</TableCell>

+ 4 - 2
src/app/(authenticated)/dashboard-client.tsx

@@ -97,6 +97,7 @@ import { customCardId, type CustomWidget } from '@/features/dashboard/custom-car
 import { useConfirm } from '@/components/confirm-dialog'
 import { GettingStartedCard } from '@/features/onboarding/Components/GettingStartedCard'
 import type { OnboardingChecklistData } from '@/features/onboarding/Actions/checklistActions'
+import { useServiceLabels } from '@/features/vehicles/Lib/useServiceLabels'
 
 interface ServiceItem {
   id: string
@@ -338,6 +339,7 @@ export function DashboardClient({
   const formatCurrency = useFormatCurrency()
   const t = useTranslations('dashboard')
   const tcm = useTranslations('common.contextMenu')
+  const serviceLabel = useServiceLabels()
   const tAudit = useTranslations('audit')
   // Empty-state actions reuse the labels their own sections already ship,
   // so a card with nothing in it can still offer the next step without
@@ -1805,7 +1807,7 @@ export function DashboardClient({
                                 variant="outline"
                                 className={`shrink-0 text-xs ${statusColors[s.status] || ''}`}
                               >
-                                {s.status}
+                                {serviceLabel.status(s.status)}
                               </Badge>
                             </div>
                             <p className="truncate text-xs text-muted-foreground">
@@ -1891,7 +1893,7 @@ export function DashboardClient({
                                           variant="outline"
                                           className={`text-xs ${statusColors[s.status] || ''}`}
                                         >
-                                          {s.status}
+                                          {serviceLabel.status(s.status)}
                                         </Badge>
                                       </span>
                                     </TableCell>

+ 20 - 5
src/app/(authenticated)/reports/reports-client.tsx

@@ -123,6 +123,7 @@ import {
 } from '@/features/reports/Components/csv-export'
 import { ReportPDF } from '@/features/reports/Components/ReportPDF'
 import { pdf } from '@react-pdf/renderer'
+import { useServiceLabels } from '@/features/vehicles/Lib/useServiceLabels'
 
 type ReportTab =
   | 'financial'
@@ -169,6 +170,7 @@ export default function ReportsClient({
 }: ReportsClientProps) {
   const formatCurrency = useFormatCurrency()
   const t = useTranslations('reports')
+  const serviceLabel = useServiceLabels()
   const searchParams = useSearchParams()
   const router = useRouter()
   const pathname = usePathname()
@@ -1181,7 +1183,13 @@ export default function ReportsClient({
                     />
                   </AppCard>
                   <AppCard title={t('revenue.revenueByType')} className="lg:col-span-2">
-                    <RevenueTypeDonut data={revenueData.byType} formatCurrency={fmtCurrency} />
+                    <RevenueTypeDonut
+                      data={revenueData.byType.map((row) => ({
+                        ...row,
+                        type: serviceLabel.type(row.type),
+                      }))}
+                      formatCurrency={fmtCurrency}
+                    />
                   </AppCard>
                 </div>
 
@@ -1625,7 +1633,12 @@ export default function ReportsClient({
               )}
               {serviceData.byType.length > 0 && (
                 <AppCard title={t('services.byType')}>
-                  <ServiceTypeDonut data={serviceData.byType} />
+                  <ServiceTypeDonut
+                    data={serviceData.byType.map((row) => ({
+                      ...row,
+                      type: serviceLabel.type(row.type),
+                    }))}
+                  />
                 </AppCard>
               )}
             </div>
@@ -2263,7 +2276,9 @@ export default function ReportsClient({
                   <TableBody>
                     {jobAnalyticsData.topServiceTypes.map((row) => (
                       <TableRow key={row.type}>
-                        <TableCell className="text-sm font-medium">{row.type}</TableCell>
+                        <TableCell className="text-sm font-medium">
+                          {serviceLabel.type(row.type)}
+                        </TableCell>
                         <TableCell className="text-right text-sm">{row.count}</TableCell>
                         <TableCell className="text-right text-sm">
                           {fmtCurrency(row.avgValue)}
@@ -2488,7 +2503,7 @@ export default function ReportsClient({
                     return (
                       <div key={item.type}>
                         <div className="flex items-center justify-between mb-1">
-                          <span className="text-sm capitalize">{item.type}</span>
+                          <span className="text-sm">{serviceLabel.type(item.type)}</span>
                           <span className="text-sm font-medium tabular-nums">
                             {fmtCurrency(item.totalCost)}
                           </span>
@@ -2645,7 +2660,7 @@ export default function ReportsClient({
                                 </TableCell>
                                 <TableCell className="text-sm font-medium">{row.title}</TableCell>
                                 <TableCell className="text-sm">
-                                  <Badge variant="secondary">{row.type}</Badge>
+                                  <Badge variant="secondary">{serviceLabel.type(row.type)}</Badge>
                                 </TableCell>
                                 <TableCell className="text-right text-sm">
                                   {fmtCurrency(row.totalAmount)}

+ 6 - 4
src/app/(authenticated)/vehicles/[id]/service-records-table.tsx

@@ -42,6 +42,7 @@ import { Download, ExternalLink, Gauge, Loader2, Paperclip, Plus, Search, User }
 import { toast } from 'sonner'
 import { useTranslations } from 'next-intl'
 import { useServiceType } from '@/components/service-type-context'
+import { useServiceLabels } from '@/features/vehicles/Lib/useServiceLabels'
 
 const warrantyBadgeStyles: Record<WarrantyStatus, string> = {
   active: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
@@ -116,6 +117,7 @@ export function ServiceRecordsTable({
   const [navigatingId, setNavigatingId] = useState<string | null>(null)
   const [isExporting, setIsExporting] = useState(false)
   const t = useTranslations('vehicles.services')
+  const serviceLabel = useServiceLabels()
   const tcm = useTranslations('common.contextMenu')
   const serviceType = useServiceType()
 
@@ -293,13 +295,13 @@ export function ServiceRecordsTable({
 
                 <div className="mt-2 flex flex-wrap items-center gap-1.5">
                   <Badge variant="outline" className={`text-xs ${typeColors[record.type] || ''}`}>
-                    {record.type}
+                    {serviceLabel.type(record.type)}
                   </Badge>
                   <Badge
                     variant="outline"
                     className={`text-xs ${statusColors[record.status] || ''}`}
                   >
-                    {record.status}
+                    {serviceLabel.status(record.status)}
                   </Badge>
                   {warranty !== 'none' && <WarrantyBadge status={warranty} t={t} />}
                 </div>
@@ -394,7 +396,7 @@ export function ServiceRecordsTable({
                             variant="outline"
                             className={`text-xs ${typeColors[record.type] || ''}`}
                           >
-                            {record.type}
+                            {serviceLabel.type(record.type)}
                           </Badge>
                         </TableCell>
                         <TableCell>
@@ -402,7 +404,7 @@ export function ServiceRecordsTable({
                             variant="outline"
                             className={`text-xs ${statusColors[record.status] || ''}`}
                           >
-                            {record.status}
+                            {serviceLabel.status(record.status)}
                           </Badge>
                         </TableCell>
                         <TableCell className="text-right font-mono text-xs">

+ 4 - 2
src/app/(authenticated)/work-orders/work-orders-client.tsx

@@ -67,6 +67,7 @@ import { SMS_TEMPLATE_DEFAULTS, interpolateSmsTemplate } from '@/lib/sms-templat
 import { statusColorClasses } from '@/features/work-order-statuses/Lib/stages'
 import { cn } from '@/lib/utils'
 import { ListEmpty } from '@/components/list-empty'
+import { useServiceLabels } from '@/features/vehicles/Lib/useServiceLabels'
 
 /** The workshop's own status, in its colour, beside the stage. */
 function CustomStatusChip({ status }: { status: { name: string; color: string } }) {
@@ -208,6 +209,7 @@ export function WorkOrdersClient({
     Boolean
   ).length
   const t = useTranslations('workOrders.list')
+  const serviceLabel = useServiceLabels()
   const [navigatingId, setNavigatingId] = useState<string | null>(null)
   const [showPicker, setShowPicker] = useState(false)
   const [showNotifyDialog, setShowNotifyDialog] = useState(false)
@@ -464,7 +466,7 @@ export function WorkOrdersClient({
                   )}
                   <div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-xs text-muted-foreground">
                     <Badge variant="outline" className={`text-xs ${statusColors[r.status] || ''}`}>
-                      {r.status}
+                      {serviceLabel.status(r.status)}
                     </Badge>
                     {r.customStatus && <CustomStatusChip status={r.customStatus} />}
                     <span className="font-mono">
@@ -672,7 +674,7 @@ export function WorkOrdersClient({
                               variant="outline"
                               className={`text-xs ${statusColors[r.status] || ''}`}
                             >
-                              {r.status}
+                              {serviceLabel.status(r.status)}
                             </Badge>
                             {r.customStatus && <CustomStatusChip status={r.customStatus} />}
                           </div>

+ 38 - 0
src/features/vehicles/Lib/useServiceLabels.ts

@@ -0,0 +1,38 @@
+import { useMemo } from 'react'
+import { useTranslations } from 'next-intl'
+import { statusMessageKeys } from '../Components/service-detail/types'
+
+/**
+ * Status keys under service.basicInfo.statusOptions, by stored value. A
+ * booking stores `scheduled`, and older demo data still has `in_progress`
+ * from before the values were hyphenated.
+ */
+const STATUS_KEYS: Record<string, string> = {
+  ...statusMessageKeys,
+  scheduled: 'scheduled',
+  in_progress: 'in_progress',
+  waiting_parts: 'waiting_parts',
+}
+
+const TYPES = new Set(['maintenance', 'repair', 'upgrade', 'inspection'])
+
+/**
+ * A work order's status and type as the reader's language says them.
+ *
+ * Lists printed the stored value ("in-progress", "maintenance") in English
+ * whatever the language, while the work order itself said "In Progress". A
+ * value with no label yet, such as a type added later, prints as stored
+ * rather than as a missing message.
+ */
+export function useServiceLabels() {
+  const t = useTranslations('service.basicInfo')
+  // Stable between renders, so a list can filter and sort by them inside a memo.
+  return useMemo(
+    () => ({
+      status: (value: string) =>
+        STATUS_KEYS[value] ? t(`statusOptions.${STATUS_KEYS[value]}`) : value,
+      type: (value: string) => (TYPES.has(value) ? t(`typeOptions.${value}`) : value),
+    }),
+    [t]
+  )
+}