فهرست منبع

Show a customer's invoices and quotes on their own page (#287)

* Show a customer's invoices and quotes on their own page

Parts-only invoices carry a customer but no vehicle, so the vehicle tab
was the only way into a customer's documents and counter sales had
nowhere to appear. Two new tabs list every invoice and quote reachable
either directly or through a vehicle the customer owns, with search and
sortable columns over both.

* Drop two unused imports blocking lint on main
Bernt Christian Egeland 1 ماه پیش
والد
کامیت
a64209596d

+ 21 - 1
messages/de/customers.json

@@ -35,6 +35,8 @@
     "taxExemptBadge": "Steuerbefreit",
     "tabs": {
       "vehicles": "Fahrzeuge ({count})",
+      "invoices": "Rechnungen ({count})",
+      "quotes": "Angebote ({count})",
       "messages": "Nachrichten",
       "requests": "Anfragen ({count})",
       "sms": "SMS",
@@ -48,7 +50,25 @@
       "mileageMarine": "Betriebsstunden",
       "services": "Wartungen"
     },
-    "noVehicles": "Diesem Kunden sind keine Fahrzeuge zugewiesen"
+    "documentTable": {
+      "invoiceNumber": "Rechnungsnr.",
+      "quoteNumber": "Angebotsnr.",
+      "title": "Titel",
+      "vehicle": "Fahrzeug",
+      "status": "Status",
+      "date": "Datum",
+      "total": "Gesamt",
+      "noVehicle": "Nur Teile",
+      "searchPlaceholder": "Suchen...",
+      "noMatches": "Keine Treffer für deine Suche",
+      "sort": {
+        "asc": "aufsteigend",
+        "desc": "absteigend"
+      }
+    },
+    "noVehicles": "Diesem Kunden sind keine Fahrzeuge zugewiesen",
+    "noInvoices": "Keine Rechnungen für diesen Kunden",
+    "noQuotes": "Keine Angebote für diesen Kunden"
   },
   "form": {
     "addTitle": "Neuen Kunden anlegen",

+ 21 - 1
messages/en/customers.json

@@ -35,6 +35,8 @@
     "taxExemptBadge": "Tax exempt",
     "tabs": {
       "vehicles": "Vehicles ({count})",
+      "invoices": "Invoices ({count})",
+      "quotes": "Quotes ({count})",
       "messages": "Messages",
       "requests": "Requests ({count})",
       "sms": "SMS",
@@ -48,7 +50,25 @@
       "mileageMarine": "Engine Hours",
       "services": "Services"
     },
-    "noVehicles": "No vehicles assigned to this customer"
+    "documentTable": {
+      "invoiceNumber": "Invoice #",
+      "quoteNumber": "Quote #",
+      "title": "Title",
+      "vehicle": "Vehicle",
+      "status": "Status",
+      "date": "Date",
+      "total": "Total",
+      "noVehicle": "Parts only",
+      "searchPlaceholder": "Search...",
+      "noMatches": "Nothing matches your search",
+      "sort": {
+        "asc": "ascending",
+        "desc": "descending"
+      }
+    },
+    "noVehicles": "No vehicles assigned to this customer",
+    "noInvoices": "No invoices for this customer",
+    "noQuotes": "No quotes for this customer"
   },
   "form": {
     "addTitle": "Add New Customer",

+ 21 - 1
messages/es/customers.json

@@ -35,6 +35,8 @@
     "taxExemptBadge": "Exento de impuestos",
     "tabs": {
       "vehicles": "Vehículos ({count})",
+      "invoices": "Facturas ({count})",
+      "quotes": "Presupuestos ({count})",
       "messages": "Mensajes",
       "requests": "Solicitudes ({count})",
       "sms": "SMS",
@@ -48,7 +50,25 @@
       "mileageMarine": "Horas de Motor",
       "services": "Servicios"
     },
-    "noVehicles": "No hay vehículos asignados a este cliente"
+    "documentTable": {
+      "invoiceNumber": "N.º de factura",
+      "quoteNumber": "N.º de presupuesto",
+      "title": "Título",
+      "vehicle": "Vehículo",
+      "status": "Estado",
+      "date": "Fecha",
+      "total": "Total",
+      "noVehicle": "Solo piezas",
+      "searchPlaceholder": "Buscar...",
+      "noMatches": "No hay resultados para tu búsqueda",
+      "sort": {
+        "asc": "ascendente",
+        "desc": "descendente"
+      }
+    },
+    "noVehicles": "No hay vehículos asignados a este cliente",
+    "noInvoices": "No hay facturas para este cliente",
+    "noQuotes": "No hay presupuestos para este cliente"
   },
   "form": {
     "addTitle": "Agregar Nuevo Cliente",

+ 21 - 1
messages/fr/customers.json

@@ -35,6 +35,8 @@
     "taxExemptBadge": "Exonéré de taxe",
     "tabs": {
       "vehicles": "Véhicules ({count})",
+      "invoices": "Factures ({count})",
+      "quotes": "Devis ({count})",
       "messages": "Messages",
       "requests": "Demandes ({count})",
       "sms": "SMS",
@@ -48,7 +50,25 @@
       "mileageMarine": "Heures Moteur",
       "services": "Services"
     },
-    "noVehicles": "Aucun véhicule associé à ce client"
+    "documentTable": {
+      "invoiceNumber": "N° de facture",
+      "quoteNumber": "N° de devis",
+      "title": "Titre",
+      "vehicle": "Véhicule",
+      "status": "Statut",
+      "date": "Date",
+      "total": "Total",
+      "noVehicle": "Pièces uniquement",
+      "searchPlaceholder": "Rechercher...",
+      "noMatches": "Aucun résultat pour votre recherche",
+      "sort": {
+        "asc": "croissant",
+        "desc": "décroissant"
+      }
+    },
+    "noVehicles": "Aucun véhicule associé à ce client",
+    "noInvoices": "Aucune facture pour ce client",
+    "noQuotes": "Aucun devis pour ce client"
   },
   "form": {
     "addTitle": "Ajouter un nouveau client",

+ 21 - 1
messages/it/customers.json

@@ -35,6 +35,8 @@
     "taxExemptBadge": "Esente da imposta",
     "tabs": {
       "vehicles": "Veicoli ({count})",
+      "invoices": "Fatture ({count})",
+      "quotes": "Preventivi ({count})",
       "messages": "Messaggi",
       "requests": "Richieste ({count})",
       "sms": "SMS",
@@ -48,7 +50,25 @@
       "mileageMarine": "Ore Motore",
       "services": "Servizi"
     },
-    "noVehicles": "Nessun veicolo assegnato a questo cliente"
+    "documentTable": {
+      "invoiceNumber": "N. fattura",
+      "quoteNumber": "N. preventivo",
+      "title": "Titolo",
+      "vehicle": "Veicolo",
+      "status": "Stato",
+      "date": "Data",
+      "total": "Totale",
+      "noVehicle": "Solo ricambi",
+      "searchPlaceholder": "Cerca...",
+      "noMatches": "Nessun risultato per la tua ricerca",
+      "sort": {
+        "asc": "crescente",
+        "desc": "decrescente"
+      }
+    },
+    "noVehicles": "Nessun veicolo assegnato a questo cliente",
+    "noInvoices": "Nessuna fattura per questo cliente",
+    "noQuotes": "Nessun preventivo per questo cliente"
   },
   "form": {
     "addTitle": "Aggiungi nuovo cliente",

+ 21 - 1
messages/lt/customers.json

@@ -35,6 +35,8 @@
     "taxExemptBadge": "Atleistas nuo mokesčio",
     "tabs": {
       "vehicles": "Transporto priemonės ({count})",
+      "invoices": "Sąskaitos ({count})",
+      "quotes": "Pasiūlymai ({count})",
       "messages": "Žinutės",
       "requests": "Užklausos ({count})",
       "sms": "SMS",
@@ -48,7 +50,25 @@
       "mileageMarine": "Variklio valandos",
       "services": "Paslaugos"
     },
-    "noVehicles": "Šiam klientui nėra priskirtų transporto priemonių"
+    "documentTable": {
+      "invoiceNumber": "Sąskaitos nr.",
+      "quoteNumber": "Pasiūlymo nr.",
+      "title": "Pavadinimas",
+      "vehicle": "Transporto priemonė",
+      "status": "Būsena",
+      "date": "Data",
+      "total": "Iš viso",
+      "noVehicle": "Tik dalys",
+      "searchPlaceholder": "Ieškoti...",
+      "noMatches": "Pagal paiešką nieko nerasta",
+      "sort": {
+        "asc": "didėjančiai",
+        "desc": "mažėjančiai"
+      }
+    },
+    "noVehicles": "Šiam klientui nėra priskirtų transporto priemonių",
+    "noInvoices": "Šis klientas neturi sąskaitų",
+    "noQuotes": "Šis klientas neturi pasiūlymų"
   },
   "form": {
     "addTitle": "Pridėti naują klientą",

+ 21 - 1
messages/nb/customers.json

@@ -35,6 +35,8 @@
     "taxExemptBadge": "Avgiftsfritatt",
     "tabs": {
       "vehicles": "Kjøretøy ({count})",
+      "invoices": "Fakturaer ({count})",
+      "quotes": "Tilbud ({count})",
       "messages": "Meldinger",
       "requests": "Forespørsler ({count})",
       "sms": "SMS",
@@ -48,7 +50,25 @@
       "mileageMarine": "Motortimer",
       "services": "Tjenester"
     },
-    "noVehicles": "Ingen kjøretøy tilknyttet denne kunden"
+    "documentTable": {
+      "invoiceNumber": "Fakturanr.",
+      "quoteNumber": "Tilbudsnr.",
+      "title": "Tittel",
+      "vehicle": "Kjøretøy",
+      "status": "Status",
+      "date": "Dato",
+      "total": "Totalt",
+      "noVehicle": "Kun deler",
+      "searchPlaceholder": "Søk...",
+      "noMatches": "Ingen treff på søket ditt",
+      "sort": {
+        "asc": "stigende",
+        "desc": "synkende"
+      }
+    },
+    "noVehicles": "Ingen kjøretøy tilknyttet denne kunden",
+    "noInvoices": "Ingen fakturaer for denne kunden",
+    "noQuotes": "Ingen tilbud for denne kunden"
   },
   "form": {
     "addTitle": "Legg til ny kunde",

+ 21 - 1
messages/nl/customers.json

@@ -35,6 +35,8 @@
     "taxExemptBadge": "Belastingvrijgesteld",
     "tabs": {
       "vehicles": "Voertuigen ({count})",
+      "invoices": "Facturen ({count})",
+      "quotes": "Offertes ({count})",
       "messages": "Berichten",
       "requests": "Aanvragen ({count})",
       "sms": "SMS",
@@ -48,7 +50,25 @@
       "mileageMarine": "Motoruren",
       "services": "Services"
     },
-    "noVehicles": "Geen voertuigen gekoppeld aan deze klant"
+    "documentTable": {
+      "invoiceNumber": "Factuurnr.",
+      "quoteNumber": "Offertenr.",
+      "title": "Titel",
+      "vehicle": "Voertuig",
+      "status": "Status",
+      "date": "Datum",
+      "total": "Totaal",
+      "noVehicle": "Alleen onderdelen",
+      "searchPlaceholder": "Zoeken...",
+      "noMatches": "Geen resultaten voor je zoekopdracht",
+      "sort": {
+        "asc": "oplopend",
+        "desc": "aflopend"
+      }
+    },
+    "noVehicles": "Geen voertuigen gekoppeld aan deze klant",
+    "noInvoices": "Geen facturen voor deze klant",
+    "noQuotes": "Geen offertes voor deze klant"
   },
   "form": {
     "addTitle": "Nieuwe klant toevoegen",

+ 21 - 1
messages/pl/customers.json

@@ -35,6 +35,8 @@
     "taxExemptBadge": "Zwolniony z podatku",
     "tabs": {
       "vehicles": "Pojazdy ({count})",
+      "invoices": "Faktury ({count})",
+      "quotes": "Oferty ({count})",
       "messages": "Wiadomości",
       "requests": "Zlecenia ({count})",
       "sms": "SMS",
@@ -48,7 +50,25 @@
       "mileageMarine": "Godziny Pracy",
       "services": "Usługi"
     },
-    "noVehicles": "Brak pojazdów przypisanych do tego klienta"
+    "documentTable": {
+      "invoiceNumber": "Nr faktury",
+      "quoteNumber": "Nr oferty",
+      "title": "Tytuł",
+      "vehicle": "Pojazd",
+      "status": "Status",
+      "date": "Data",
+      "total": "Razem",
+      "noVehicle": "Tylko części",
+      "searchPlaceholder": "Szukaj...",
+      "noMatches": "Brak wyników wyszukiwania",
+      "sort": {
+        "asc": "rosnąco",
+        "desc": "malejąco"
+      }
+    },
+    "noVehicles": "Brak pojazdów przypisanych do tego klienta",
+    "noInvoices": "Brak faktur dla tego klienta",
+    "noQuotes": "Brak ofert dla tego klienta"
   },
   "form": {
     "addTitle": "Dodaj nowego klienta",

+ 21 - 1
messages/pt-BR/customers.json

@@ -35,6 +35,8 @@
     "taxExemptBadge": "Isento de imposto",
     "tabs": {
       "vehicles": "Veículos ({count})",
+      "invoices": "Faturas ({count})",
+      "quotes": "Orçamentos ({count})",
       "messages": "Mensagens",
       "requests": "Solicitações ({count})",
       "sms": "SMS",
@@ -48,7 +50,25 @@
       "mileageMarine": "Horas de Motor",
       "services": "Serviços"
     },
-    "noVehicles": "Nenhum veículo atribuído a este cliente"
+    "documentTable": {
+      "invoiceNumber": "Nº da fatura",
+      "quoteNumber": "Nº do orçamento",
+      "title": "Título",
+      "vehicle": "Veículo",
+      "status": "Status",
+      "date": "Data",
+      "total": "Total",
+      "noVehicle": "Somente peças",
+      "searchPlaceholder": "Buscar...",
+      "noMatches": "Nenhum resultado para sua busca",
+      "sort": {
+        "asc": "crescente",
+        "desc": "decrescente"
+      }
+    },
+    "noVehicles": "Nenhum veículo atribuído a este cliente",
+    "noInvoices": "Nenhuma fatura para este cliente",
+    "noQuotes": "Nenhum orçamento para este cliente"
   },
   "form": {
     "addTitle": "Adicionar Novo Cliente",

+ 21 - 1
messages/ru/customers.json

@@ -35,6 +35,8 @@
     "taxExemptBadge": "Освобожден от налога",
     "tabs": {
       "vehicles": "Транспортные средства ({count})",
+      "invoices": "Счета ({count})",
+      "quotes": "Предложения ({count})",
       "messages": "Сообщения",
       "requests": "Заявки ({count})",
       "sms": "SMS",
@@ -48,7 +50,25 @@
       "mileageMarine": "Моточасы",
       "services": "Обслуживания"
     },
-    "noVehicles": "У этого клиента нет транспортных средств"
+    "documentTable": {
+      "invoiceNumber": "№ счёта",
+      "quoteNumber": "№ предложения",
+      "title": "Название",
+      "vehicle": "Транспортное средство",
+      "status": "Статус",
+      "date": "Дата",
+      "total": "Итого",
+      "noVehicle": "Только запчасти",
+      "searchPlaceholder": "Поиск...",
+      "noMatches": "По вашему запросу ничего не найдено",
+      "sort": {
+        "asc": "по возрастанию",
+        "desc": "по убыванию"
+      }
+    },
+    "noVehicles": "У этого клиента нет транспортных средств",
+    "noInvoices": "У этого клиента нет счетов",
+    "noQuotes": "У этого клиента нет предложений"
   },
   "form": {
     "addTitle": "Добавить нового клиента",

+ 21 - 1
messages/tr/customers.json

@@ -35,6 +35,8 @@
     "taxExemptBadge": "Vergiden muaf",
     "tabs": {
       "vehicles": "Araçlar ({count})",
+      "invoices": "Faturalar ({count})",
+      "quotes": "Teklifler ({count})",
       "messages": "Mesajlar",
       "requests": "Talepler ({count})",
       "sms": "SMS",
@@ -48,7 +50,25 @@
       "mileageMarine": "Motor Saatleri",
       "services": "Servisler"
     },
-    "noVehicles": "Bu müşteriye atanmış araç yok"
+    "documentTable": {
+      "invoiceNumber": "Fatura no.",
+      "quoteNumber": "Teklif no.",
+      "title": "Başlık",
+      "vehicle": "Araç",
+      "status": "Durum",
+      "date": "Tarih",
+      "total": "Toplam",
+      "noVehicle": "Yalnızca parça",
+      "searchPlaceholder": "Ara...",
+      "noMatches": "Aramanızla eşleşen bir sonuç yok",
+      "sort": {
+        "asc": "artan",
+        "desc": "azalan"
+      }
+    },
+    "noVehicles": "Bu müşteriye atanmış araç yok",
+    "noInvoices": "Bu müşteri için fatura yok",
+    "noQuotes": "Bu müşteri için teklif yok"
   },
   "form": {
     "addTitle": "Yeni Müşteri Ekle",

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

@@ -2,12 +2,25 @@
 
 import { useTableKeyboardNav } from '@/hooks/use-table-keyboard-nav'
 import { interactiveRow } from '@/lib/interactive-row'
-import { useState, useTransition } from 'react'
+import { useCallback, useMemo, useState, useTransition } from 'react'
 import Link from 'next/link'
 import { useRouter, useSearchParams } from 'next/navigation'
 import { useTranslations } from 'next-intl'
 import { Card, CardContent } from '@/components/ui/card'
+import { useFormatDate } from '@/lib/use-format-date'
+import { useFormatCurrency } from '@/components/currency-settings-context'
+import { statusColors } from '@/lib/table-utils'
+import { effectiveInvoiceDate } from '@/lib/invoice-utils'
 import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from '@/components/ui/select'
+import { useDebouncedSearch } from '@/hooks/use-debounced-search'
 import { cn } from '@/lib/utils'
 import {
   Table,
@@ -18,12 +31,16 @@ import {
   TableRow,
 } from '@/components/ui/table'
 import {
+  ArrowDown,
   ArrowLeft,
+  ArrowUp,
+  ArrowUpDown,
   Building2,
   Car,
   CheckCircle2,
   Clock,
   ExternalLink,
+  FileText,
   Loader2,
   Mail,
   MapPin,
@@ -32,6 +49,8 @@ import {
   Pencil,
   Phone,
   Plus,
+  Receipt,
+  Search,
   Send,
   Users,
   Wrench,
@@ -63,6 +82,30 @@ interface ServiceRequestItem {
   vehicle: { id: string; make: string; model: string; year: number }
 }
 
+interface InvoiceRow {
+  id: string
+  title: string
+  status: string
+  cost: number
+  totalAmount: number
+  invoiceNumber: string | null
+  invoiceDate: Date | null
+  startDateTime: Date | null
+  serviceDate: Date
+  vehicleId: string | null
+  vehicle: { make: string; model: string; year: number; licensePlate: string | null } | null
+}
+
+interface QuoteRow {
+  id: string
+  title: string
+  status: string
+  quoteNumber: string | null
+  totalAmount: number
+  createdAt: Date
+  vehicle: { make: string; model: string; year: number; licensePlate: string | null } | null
+}
+
 interface CustomerDetail {
   id: string
   customerNumber: string | null
@@ -109,6 +152,10 @@ export function CustomerDetailClient({
   telegramMessages = [],
   telegramNextCursor = null,
   telegramChatId = null,
+  invoices = [],
+  quotes = [],
+  canReadInvoices = false,
+  canReadQuotes = false,
 }: {
   customer: CustomerDetail
   customers?: { id: string; name: string; company: string | null }[]
@@ -129,6 +176,10 @@ export function CustomerDetailClient({
   }[]
   telegramNextCursor?: string | null
   telegramChatId?: string | null
+  invoices?: InvoiceRow[]
+  quotes?: QuoteRow[]
+  canReadInvoices?: boolean
+  canReadQuotes?: boolean
 }) {
   const t = useTranslations('customers.detail')
   const tVehicles = useTranslations('vehicles.list')
@@ -151,19 +202,82 @@ export function CustomerDetailClient({
             ? 'sms'
             : tabParam === 'requests'
               ? 'requests'
-              : 'vehicles'
-
-  const setActiveTab = (tab: 'vehicles' | 'sms' | 'whatsapp' | 'telegram' | 'requests') => {
-    const params = new URLSearchParams(searchParams.toString())
-    if (tab === 'vehicles') {
-      params.delete('tab')
-    } else {
-      params.set('tab', tab)
-    }
-    const qs = params.toString()
-    router.replace(`/customers/${customer.id}${qs ? `?${qs}` : ''}`, { scroll: false })
+              : tabParam === 'invoices' && canReadInvoices
+                ? 'invoices'
+                : tabParam === 'quotes' && canReadQuotes
+                  ? 'quotes'
+                  : 'vehicles'
+
+  const replaceParams = useCallback(
+    (updates: Record<string, string | undefined>) => {
+      const params = new URLSearchParams(searchParams.toString())
+      for (const [key, value] of Object.entries(updates)) {
+        if (value === undefined || value === '') {
+          params.delete(key)
+        } else {
+          params.set(key, value)
+        }
+      }
+      const qs = params.toString()
+      router.replace(`/customers/${customer.id}${qs ? `?${qs}` : ''}`, { scroll: false })
+    },
+    [router, searchParams, customer.id]
+  )
+
+  const setActiveTab = (
+    tab: 'vehicles' | 'invoices' | 'quotes' | 'sms' | 'whatsapp' | 'telegram' | 'requests'
+  ) => {
+    // The sort carries over between invoices and quotes — the columns are the
+    // same — but a search term typed against one list would silently hide rows
+    // in the other, so it is dropped.
+    replaceParams({ tab: tab === 'vehicles' ? undefined : tab, search: undefined })
   }
 
+  const search = searchParams.get('search') ?? ''
+  const sortParam = searchParams.get('sortBy')
+  const sortBy: DocumentSortKey = DOCUMENT_SORT_KEYS.includes(sortParam as DocumentSortKey)
+    ? (sortParam as DocumentSortKey)
+    : 'date'
+  const sortOrder: 'asc' | 'desc' = searchParams.get('sortOrder') === 'asc' ? 'asc' : 'desc'
+
+  const invoiceRows: DocumentRow[] = useMemo(
+    () =>
+      invoices.map((inv) => ({
+        id: inv.id,
+        // Parts-only sales have no vehicle to hang off, so they live under
+        // /sales instead of the vehicle's service route.
+        href: inv.vehicleId ? `/vehicles/${inv.vehicleId}/service/${inv.id}` : `/sales/${inv.id}`,
+        number: inv.invoiceNumber,
+        title: inv.title,
+        vehicle: inv.vehicle,
+        status: inv.status,
+        date: effectiveInvoiceDate(inv),
+        total: inv.totalAmount > 0 ? inv.totalAmount : inv.cost,
+      })),
+    [invoices]
+  )
+
+  const quoteRows: DocumentRow[] = useMemo(
+    () =>
+      quotes.map((q) => ({
+        id: q.id,
+        href: `/quotes/${q.id}`,
+        number: q.quoteNumber,
+        title: q.title,
+        vehicle: q.vehicle,
+        status: q.status,
+        date: new Date(q.createdAt),
+        total: q.totalAmount,
+      })),
+    [quotes]
+  )
+
+  const handleQueryChange = useCallback(
+    (next: { search?: string; sortBy?: DocumentSortKey; sortOrder?: 'asc' | 'desc' }) =>
+      replaceParams(next as Record<string, string | undefined>),
+    [replaceParams]
+  )
+
   const hasContactInfo =
     customer.email ||
     customer.phone ||
@@ -297,6 +411,36 @@ export function CustomerDetailClient({
             <Car className="h-4 w-4" />
             {t('tabs.vehicles', { count: customer.vehicles.length })}
           </button>
+          {canReadInvoices && (
+            <button
+              type="button"
+              onClick={() => setActiveTab('invoices')}
+              className={cn(
+                'flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors',
+                activeTab === 'invoices'
+                  ? 'border-primary text-primary'
+                  : 'border-transparent text-muted-foreground hover:text-foreground'
+              )}
+            >
+              <Receipt className="h-4 w-4" />
+              {t('tabs.invoices', { count: invoices.length })}
+            </button>
+          )}
+          {canReadQuotes && (
+            <button
+              type="button"
+              onClick={() => setActiveTab('quotes')}
+              className={cn(
+                'flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors',
+                activeTab === 'quotes'
+                  ? 'border-primary text-primary'
+                  : 'border-transparent text-muted-foreground hover:text-foreground'
+              )}
+            >
+              <FileText className="h-4 w-4" />
+              {t('tabs.quotes', { count: quotes.length })}
+            </button>
+          )}
           {smsEnabled && (
             <button
               type="button"
@@ -469,6 +613,32 @@ export function CustomerDetailClient({
           </>
         )}
 
+        {activeTab === 'invoices' && canReadInvoices && (
+          <DocumentList
+            rows={invoiceRows}
+            statusStyles={statusColors}
+            numberLabel={t('documentTable.invoiceNumber')}
+            emptyLabel={t('noInvoices')}
+            search={search}
+            sortBy={sortBy}
+            sortOrder={sortOrder}
+            onQueryChange={handleQueryChange}
+          />
+        )}
+
+        {activeTab === 'quotes' && canReadQuotes && (
+          <DocumentList
+            rows={quoteRows}
+            statusStyles={quoteStatusColors}
+            numberLabel={t('documentTable.quoteNumber')}
+            emptyLabel={t('noQuotes')}
+            search={search}
+            sortBy={sortBy}
+            sortOrder={sortOrder}
+            onQueryChange={handleQueryChange}
+          />
+        )}
+
         {activeTab === 'sms' && smsEnabled && (
           <Card>
             <CardContent className="p-0">
@@ -532,6 +702,301 @@ export function CustomerDetailClient({
   )
 }
 
+// Quotes use their own status vocabulary, so they cannot share the work-order
+// palette in table-utils.
+const quoteStatusColors: Record<string, string> = {
+  draft: 'bg-gray-500/10 text-gray-500 border-gray-500/20',
+  sent: 'bg-blue-500/10 text-blue-500 border-blue-500/20',
+  accepted: 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20',
+  rejected: 'bg-red-500/10 text-red-500 border-red-500/20',
+  expired: 'bg-amber-500/10 text-amber-600 border-amber-500/20',
+  converted: 'bg-purple-500/10 text-purple-500 border-purple-500/20',
+}
+
+interface DocumentRow {
+  id: string
+  href: string
+  number: string | null
+  title: string
+  vehicle: { make: string; model: string; year: number; licensePlate: string | null } | null
+  status: string
+  date: Date
+  total: number
+}
+
+type DocumentSortKey = 'number' | 'title' | 'vehicle' | 'status' | 'date' | 'total'
+
+const DOCUMENT_SORT_KEYS: DocumentSortKey[] = [
+  'number',
+  'title',
+  'vehicle',
+  'status',
+  'date',
+  'total',
+]
+
+/// 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
+/// whole org), so search and sort run in memory rather than round-tripping.
+function DocumentList({
+  rows,
+  statusStyles,
+  numberLabel,
+  emptyLabel,
+  search,
+  sortBy,
+  sortOrder,
+  onQueryChange,
+}: {
+  rows: DocumentRow[]
+  statusStyles: Record<string, string>
+  numberLabel: string
+  emptyLabel: string
+  search: string
+  sortBy: DocumentSortKey
+  sortOrder: 'asc' | 'desc'
+  onQueryChange: (params: {
+    search?: string
+    sortBy?: DocumentSortKey
+    sortOrder?: 'asc' | 'desc'
+  }) => void
+}) {
+  const t = useTranslations('customers.detail')
+  const router = useRouter()
+  const { formatDate } = useFormatDate()
+  const formatCurrency = useFormatCurrency()
+  const tableNav = useTableKeyboardNav()
+
+  const vehicleLabel = useCallback(
+    (v: DocumentRow['vehicle']) => (v ? `${v.year} ${v.make} ${v.model}` : ''),
+    []
+  )
+
+  const {
+    value: searchInput,
+    setValue: setSearchInput,
+    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 handleSort = (column: DocumentSortKey) => {
+    // First click on a new column sorts descending for dates and amounts and
+    // ascending for text, which is what people expect from each.
+    const defaultOrder = column === 'date' || column === 'total' ? 'desc' : 'asc'
+    const nextOrder =
+      sortBy === column ? (sortOrder === 'asc' ? 'desc' : 'asc') : (defaultOrder as 'asc' | 'desc')
+    onQueryChange({ sortBy: column, sortOrder: nextOrder })
+  }
+
+  const SortIcon = ({ column }: { column: DocumentSortKey }) => {
+    if (sortBy !== column) return <ArrowUpDown className="ml-1 h-3 w-3 opacity-50" />
+    return sortOrder === 'asc' ? (
+      <ArrowUp className="ml-1 h-3 w-3" />
+    ) : (
+      <ArrowDown className="ml-1 h-3 w-3" />
+    )
+  }
+
+  const SortableHead = ({ column, label }: { column: DocumentSortKey; label: string }) => (
+    <button
+      type="button"
+      className="flex items-center hover:text-foreground"
+      onClick={() => handleSort(column)}
+    >
+      {label}
+      <SortIcon column={column} />
+    </button>
+  )
+
+  const displayVehicle = (v: DocumentRow['vehicle']) =>
+    vehicleLabel(v) || t('documentTable.noVehicle')
+
+  return (
+    <div className="space-y-3">
+      <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
+        <form onSubmit={handleSearchSubmit} className="relative w-full sm:max-w-sm">
+          <Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
+          <Input
+            placeholder={t('documentTable.searchPlaceholder')}
+            value={searchInput}
+            onChange={(e) => setSearchInput(e.target.value)}
+            className="h-9 pl-9"
+            {...tableNav.searchInputProps}
+          />
+        </form>
+        {/* Phones have no column headers to click, so the sort lives here.
+            Above sm the headers take over and this would be a duplicate. */}
+        <Select
+          value={`${sortBy}:${sortOrder}`}
+          onValueChange={(v) => {
+            const [column, order] = v.split(':')
+            onQueryChange({
+              sortBy: column as DocumentSortKey,
+              sortOrder: order as 'asc' | 'desc',
+            })
+          }}
+        >
+          <SelectTrigger className="h-9 w-full sm:hidden">
+            <SelectValue />
+          </SelectTrigger>
+          <SelectContent>
+            {DOCUMENT_SORT_KEYS.flatMap((key) =>
+              (['asc', 'desc'] as const).map((order) => (
+                <SelectItem key={`${key}:${order}`} value={`${key}:${order}`}>
+                  {`${key === 'number' ? numberLabel : t(`documentTable.${key}`)} ${t(
+                    `documentTable.sort.${order}`
+                  )}`}
+                </SelectItem>
+              ))
+            )}
+          </SelectContent>
+        </Select>
+      </div>
+
+      {visibleRows.length === 0 ? (
+        <Card className="border-dashed">
+          <CardContent className="flex flex-col items-center py-12">
+            <FileText className="mb-3 h-10 w-10 text-muted-foreground/40" />
+            <p className="text-sm text-muted-foreground">
+              {search ? t('documentTable.noMatches') : emptyLabel}
+            </p>
+          </CardContent>
+        </Card>
+      ) : (
+        <>
+          {/* Card list (phones + small tablets) */}
+          <div className="space-y-2 md:hidden">
+            {visibleRows.map((row) => (
+              <button
+                key={row.id}
+                type="button"
+                onClick={() => router.push(row.href)}
+                className="w-full rounded-lg border bg-card p-3 text-left active:bg-muted/50"
+              >
+                <div className="flex items-start justify-between gap-3">
+                  <div className="min-w-0 flex-1">
+                    <p className="truncate font-medium">{row.title}</p>
+                    {row.number && (
+                      <p className="font-mono text-xs text-muted-foreground">{row.number}</p>
+                    )}
+                  </div>
+                  <span className="shrink-0 font-semibold">{formatCurrency(row.total)}</span>
+                </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}
+                  </Badge>
+                  <span className="font-mono">{formatDate(row.date)}</span>
+                  <span className="truncate">{displayVehicle(row.vehicle)}</span>
+                </div>
+              </button>
+            ))}
+          </div>
+
+          {/* Table (md and up) */}
+          <div className="hidden rounded-lg border md:block" {...tableNav.containerProps}>
+            <Table>
+              <TableHeader>
+                <TableRow>
+                  <TableHead className="w-[110px]">
+                    <SortableHead column="number" label={numberLabel} />
+                  </TableHead>
+                  <TableHead>
+                    <SortableHead column="title" label={t('documentTable.title')} />
+                  </TableHead>
+                  <TableHead className="hidden lg:table-cell">
+                    <SortableHead column="vehicle" label={t('documentTable.vehicle')} />
+                  </TableHead>
+                  <TableHead className="w-[110px]">
+                    <SortableHead column="status" label={t('documentTable.status')} />
+                  </TableHead>
+                  <TableHead className="w-[100px]">
+                    <SortableHead column="date" label={t('documentTable.date')} />
+                  </TableHead>
+                  <TableHead className="w-[110px]">
+                    <div className="flex justify-end">
+                      <SortableHead column="total" label={t('documentTable.total')} />
+                    </div>
+                  </TableHead>
+                </TableRow>
+              </TableHeader>
+              <TableBody>
+                {visibleRows.map((row) => (
+                  <TableRow
+                    key={row.id}
+                    className="cursor-pointer"
+                    {...interactiveRow(() => router.push(row.href))}
+                  >
+                    <TableCell className="font-mono text-xs">{row.number || '-'}</TableCell>
+                    <TableCell className="max-w-0">
+                      <div className="truncate font-medium">{row.title}</div>
+                    </TableCell>
+                    <TableCell className="hidden text-sm lg:table-cell">
+                      {displayVehicle(row.vehicle)}
+                    </TableCell>
+                    <TableCell>
+                      <Badge
+                        variant="outline"
+                        className={`text-xs ${statusStyles[row.status] || ''}`}
+                      >
+                        {row.status}
+                      </Badge>
+                    </TableCell>
+                    <TableCell className="font-mono text-xs">{formatDate(row.date)}</TableCell>
+                    <TableCell className="text-right font-semibold">
+                      {formatCurrency(row.total)}
+                    </TableCell>
+                  </TableRow>
+                ))}
+              </TableBody>
+            </Table>
+          </div>
+        </>
+      )}
+    </div>
+  )
+}
+
 function ServiceRequestCard({
   request,
   smsEnabled,

+ 19 - 7
src/app/(authenticated)/customers/[id]/page.tsx

@@ -1,5 +1,10 @@
 import { getTranslations } from 'next-intl/server'
-import { getCustomer, getCustomersList } from '@/features/customers/Actions/customerActions'
+import {
+  getCustomer,
+  getCustomerInvoices,
+  getCustomerQuotes,
+  getCustomersList,
+} from '@/features/customers/Actions/customerActions'
 import { getSettings } from '@/features/settings/Actions/settingsActions'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { getConversation } from '@/features/sms/Actions/smsActions'
@@ -13,12 +18,15 @@ import { PageHeader } from '@/components/page-header'
 
 export default async function CustomerDetailPage({ params }: { params: Promise<{ id: string }> }) {
   const { id } = await params
-  const [result, settingsResult, layoutData, customersResult] = await Promise.all([
-    getCustomer(id),
-    getSettings([SETTING_KEYS.UNIT_SYSTEM]),
-    getLayoutData(),
-    getCustomersList(),
-  ])
+  const [result, settingsResult, layoutData, customersResult, invoicesResult, quotesResult] =
+    await Promise.all([
+      getCustomer(id),
+      getSettings([SETTING_KEYS.UNIT_SYSTEM]),
+      getLayoutData(),
+      getCustomersList(),
+      getCustomerInvoices(id),
+      getCustomerQuotes(id),
+    ])
 
   if (!result.success || !result.data) {
     const t = await getTranslations('customers.detail')
@@ -127,6 +135,10 @@ export default async function CustomerDetailPage({ params }: { params: Promise<{
           telegramMessages={telegramMessages}
           telegramNextCursor={telegramNextCursor}
           telegramChatId={result.data.telegramChatId ?? null}
+          invoices={invoicesResult.success && invoicesResult.data ? invoicesResult.data : []}
+          quotes={quotesResult.success && quotesResult.data ? quotesResult.data : []}
+          canReadInvoices={invoicesResult.success}
+          canReadQuotes={quotesResult.success}
         />
       </div>
     </>

+ 66 - 0
src/features/customers/Actions/customerActions.ts

@@ -9,6 +9,7 @@ import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { getFeatures, FeatureGatedError } from '@/lib/features'
 import { createDraftServiceRecord } from '@/features/vehicles/Actions/createDraftServiceRecord'
 import { claimWhatsappMessagesForCustomer } from '@/lib/whatsapp'
+import { serviceDateOrderBy } from '@/lib/date-sort'
 
 export async function getCustomers() {
   return withAuth(
@@ -679,3 +680,68 @@ export async function searchCustomers(search?: string, limit = 20, offset = 0) {
     }
   )
 }
+
+/// Every invoice belonging to a customer: the ones raised directly against
+/// them (parts-only counter sales, which carry no vehicle) plus every invoice
+/// on a vehicle they own. Without the OR, parts-only sales are invisible from
+/// the customer page — the vehicle tab is the only way in and they have no
+/// vehicle to sit under.
+export async function getCustomerInvoices(customerId: string) {
+  return withAuth(
+    async ({ organizationId }) => {
+      return db.serviceRecord.findMany({
+        where: {
+          organizationId,
+          OR: [{ customerId }, { vehicle: { customerId } }],
+        },
+        select: {
+          id: true,
+          title: true,
+          status: true,
+          cost: true,
+          totalAmount: true,
+          invoiceNumber: true,
+          invoiceDate: true,
+          startDateTime: true,
+          serviceDate: true,
+          vehicleId: true,
+          vehicle: { select: { make: true, model: true, year: true, licensePlate: true } },
+        },
+        orderBy: serviceDateOrderBy('desc'),
+      })
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.WORK_ORDERS },
+      ],
+    }
+  )
+}
+
+/// Same reasoning as getCustomerInvoices: a quote is reachable either through
+/// its own customer link or through the vehicle it was written for.
+export async function getCustomerQuotes(customerId: string) {
+  return withAuth(
+    async ({ organizationId }) => {
+      return db.quote.findMany({
+        where: {
+          organizationId,
+          OR: [{ customerId }, { vehicle: { customerId } }],
+        },
+        select: {
+          id: true,
+          title: true,
+          status: true,
+          quoteNumber: true,
+          totalAmount: true,
+          createdAt: true,
+          vehicle: { select: { make: true, model: true, year: true, licensePlate: true } },
+        },
+        orderBy: { createdAt: 'desc' },
+      })
+    },
+    {
+      requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.QUOTES }],
+    }
+  )
+}

+ 1 - 2
src/features/invoice-designer/Components/InvoiceDesigner.tsx

@@ -34,10 +34,9 @@ import {
   saveQuoteLayoutConfig,
 } from '@/features/settings/Actions/invoiceLayoutActions'
 import { setSettings } from '@/features/settings/Actions/settingsActions'
-import { BASE_FONT_SIZE } from '@/features/vehicles/Components/invoice-pdf/styles'
 import { SpecCanvas } from '../Render/SpecCanvas'
 import { SpecThumbnail } from '../Render/SpecThumbnail'
-import { buildDocumentSpec, frameShadowWidth, type DocumentData } from '../Spec/buildSpec'
+import { buildDocumentSpec, type DocumentData } from '../Spec/buildSpec'
 import { buildSampleData, type PrintLabels } from './sample'
 import { specForPreset } from './presetSpec'
 import { themeOf } from './designTheme'