Browse Source

Sort timesheets by client and vehicle, and print them (#451)

* Add sorting by client and vehicle and a printable timesheet

* dropdown

* shareds
Bernt Christian Egeland 2 weeks ago
parent
commit
a9e3fdaeee

+ 8 - 4
.github/workflows/e2e.yml

@@ -28,10 +28,14 @@ jobs:
       fail-fast: false
       matrix:
         include:
-          - { id: shard-1, name: shard 1 of 4, shard: 1/4 }
-          - { id: shard-2, name: shard 2 of 4, shard: 2/4 }
-          - { id: shard-3, name: shard 3 of 4, shard: 3/4 }
-          - { id: shard-4, name: shard 4 of 4, shard: 4/4 }
+          - { id: shard-1, name: shard 1 of 8, shard: 1/8 }
+          - { id: shard-2, name: shard 2 of 8, shard: 2/8 }
+          - { id: shard-3, name: shard 3 of 8, shard: 3/8 }
+          - { id: shard-4, name: shard 4 of 8, shard: 4/8 }
+          - { id: shard-5, name: shard 5 of 8, shard: 5/8 }
+          - { id: shard-6, name: shard 6 of 8, shard: 6/8 }
+          - { id: shard-7, name: shard 7 of 8, shard: 7/8 }
+          - { id: shard-8, name: shard 8 of 8, shard: 8/8 }
           # The same build started in cloud mode: plan limits, the sign-up
           # pitch and Google sign-in only exist there.
           - { id: cloud, name: cloud, mode: cloud }

+ 3 - 3
e2e/README.md

@@ -137,8 +137,8 @@ you are about to read twice in one file.
 ## In CI
 
 `.github/workflows/e2e.yml` runs the suite on every pull request to main, and on
-demand from the Actions tab. The specs are split into four shards
-(`--shard=1/4` and so on) plus a job for the cloud specs, all at once. Each job
+demand from the Actions tab. The specs are split into eight shards
+(`--shard=1/8` and so on) plus a job for the cloud specs, all at once. Each job
 has its own `postgres:16-alpine` service holding `torqvoice_e2e`, installs
 Chromium, builds with `NEXT_PUBLIC_APP_URL=http://127.0.0.1:3100`, and seeds its
 own database, so the one-test-at-a-time rule still holds inside every job.
@@ -147,7 +147,7 @@ A spec that only passes because another file ran before it will fail here.
 To run one shard the way CI does:
 
 ```bash
-npx playwright test --shard=2/4
+npx playwright test --shard=2/8
 ```
 
 On CI every job writes a blob report, and the last job, `Playwright`, merges them

+ 15 - 0
messages/de/timeTracking.json

@@ -44,6 +44,20 @@
     "apply": "Anwenden",
     "allTechnicians": "Alle Techniker",
     "export": "CSV exportieren",
+    "print": "Stundenzettel drucken",
+    "client": "Kunde",
+    "noClient": "Kein Kunde",
+    "signature": "Unterschrift",
+    "sortBy": "Sortieren nach",
+    "sort": {
+      "default": "Techniker / Tag",
+      "client": "Kunde",
+      "vehicle": "Fahrzeug",
+      "clientVehicle": "Kunde, dann Fahrzeug"
+    },
+    "sortDirection": "Sortierrichtung",
+    "ascending": "Aufsteigend",
+    "descending": "Absteigend",
     "addEntry": "Eintrag hinzufügen",
     "stats": {
       "total": "Gesamtzeit",
@@ -101,6 +115,7 @@
       "duration": "Dauer",
       "hours": "Stunden",
       "job": "Auftrag",
+      "client": "Kunde",
       "vehicle": "Fahrzeug",
       "plate": "Kennzeichen",
       "source": "Quelle",

+ 15 - 0
messages/en/timeTracking.json

@@ -44,6 +44,20 @@
     "apply": "Apply",
     "allTechnicians": "All technicians",
     "export": "Export CSV",
+    "print": "Print timesheet",
+    "client": "Client",
+    "noClient": "No client",
+    "signature": "Signature",
+    "sortBy": "Sort by",
+    "sort": {
+      "default": "Technician / day",
+      "client": "Client",
+      "vehicle": "Vehicle",
+      "clientVehicle": "Client, then vehicle"
+    },
+    "sortDirection": "Sort direction",
+    "ascending": "Ascending",
+    "descending": "Descending",
     "addEntry": "Add entry",
     "stats": {
       "total": "Total time",
@@ -101,6 +115,7 @@
       "duration": "Duration",
       "hours": "Hours",
       "job": "Job",
+      "client": "Client",
       "vehicle": "Vehicle",
       "plate": "Plate",
       "source": "Source",

+ 15 - 0
messages/es/timeTracking.json

@@ -44,6 +44,20 @@
     "apply": "Aplicar",
     "allTechnicians": "Todos los técnicos",
     "export": "Exportar CSV",
+    "print": "Imprimir hoja de horas",
+    "client": "Cliente",
+    "noClient": "Sin cliente",
+    "signature": "Firma",
+    "sortBy": "Ordenar por",
+    "sort": {
+      "default": "Técnico / día",
+      "client": "Cliente",
+      "vehicle": "Vehículo",
+      "clientVehicle": "Cliente, luego vehículo"
+    },
+    "sortDirection": "Sentido de ordenación",
+    "ascending": "Ascendente",
+    "descending": "Descendente",
     "addEntry": "Añadir entrada",
     "stats": {
       "total": "Tiempo total",
@@ -101,6 +115,7 @@
       "duration": "Duración",
       "hours": "Horas",
       "job": "Trabajo",
+      "client": "Cliente",
       "vehicle": "Vehículo",
       "plate": "Matrícula",
       "source": "Origen",

+ 15 - 0
messages/fr/timeTracking.json

@@ -44,6 +44,20 @@
     "apply": "Appliquer",
     "allTechnicians": "Tous les techniciens",
     "export": "Exporter en CSV",
+    "print": "Imprimer la feuille de temps",
+    "client": "Client",
+    "noClient": "Aucun client",
+    "signature": "Signature",
+    "sortBy": "Trier par",
+    "sort": {
+      "default": "Technicien / jour",
+      "client": "Client",
+      "vehicle": "Véhicule",
+      "clientVehicle": "Client, puis véhicule"
+    },
+    "sortDirection": "Sens du tri",
+    "ascending": "Croissant",
+    "descending": "Décroissant",
     "addEntry": "Ajouter une entrée",
     "stats": {
       "total": "Temps total",
@@ -101,6 +115,7 @@
       "duration": "Durée",
       "hours": "Heures",
       "job": "Travail",
+      "client": "Client",
       "vehicle": "Véhicule",
       "plate": "Immatriculation",
       "source": "Source",

+ 15 - 0
messages/it/timeTracking.json

@@ -44,6 +44,20 @@
     "apply": "Applica",
     "allTechnicians": "Tutti i tecnici",
     "export": "Esporta CSV",
+    "print": "Stampa foglio ore",
+    "client": "Cliente",
+    "noClient": "Nessun cliente",
+    "signature": "Firma",
+    "sortBy": "Ordina per",
+    "sort": {
+      "default": "Tecnico / giorno",
+      "client": "Cliente",
+      "vehicle": "Veicolo",
+      "clientVehicle": "Cliente, poi veicolo"
+    },
+    "sortDirection": "Direzione di ordinamento",
+    "ascending": "Crescente",
+    "descending": "Decrescente",
     "addEntry": "Aggiungi voce",
     "stats": {
       "total": "Tempo totale",
@@ -101,6 +115,7 @@
       "duration": "Durata",
       "hours": "Ore",
       "job": "Lavoro",
+      "client": "Cliente",
       "vehicle": "Veicolo",
       "plate": "Targa",
       "source": "Origine",

+ 15 - 0
messages/lt/timeTracking.json

@@ -44,6 +44,20 @@
     "apply": "Taikyti",
     "allTechnicians": "Visi technikai",
     "export": "Eksportuoti CSV",
+    "print": "Spausdinti darbo laiko žiniaraštį",
+    "client": "Klientas",
+    "noClient": "Nėra kliento",
+    "signature": "Parašas",
+    "sortBy": "Rikiuoti pagal",
+    "sort": {
+      "default": "Technikas / diena",
+      "client": "Klientas",
+      "vehicle": "Transporto priemonė",
+      "clientVehicle": "Klientas, tada transporto priemonė"
+    },
+    "sortDirection": "Rikiavimo kryptis",
+    "ascending": "Didėjančia tvarka",
+    "descending": "Mažėjančia tvarka",
     "addEntry": "Pridėti įrašą",
     "stats": {
       "total": "Bendras laikas",
@@ -101,6 +115,7 @@
       "duration": "Trukmė",
       "hours": "Valandos",
       "job": "Darbas",
+      "client": "Klientas",
       "vehicle": "Transporto priemonė",
       "plate": "Numeris",
       "source": "Šaltinis",

+ 15 - 0
messages/nb/timeTracking.json

@@ -44,6 +44,20 @@
     "apply": "Bruk",
     "allTechnicians": "Alle teknikere",
     "export": "Eksporter CSV",
+    "print": "Skriv ut timeliste",
+    "client": "Kunde",
+    "noClient": "Ingen kunde",
+    "signature": "Signatur",
+    "sortBy": "Sorter etter",
+    "sort": {
+      "default": "Tekniker / dag",
+      "client": "Kunde",
+      "vehicle": "Kjøretøy",
+      "clientVehicle": "Kunde, deretter kjøretøy"
+    },
+    "sortDirection": "Sorteringsretning",
+    "ascending": "Stigende",
+    "descending": "Synkende",
     "addEntry": "Legg til oppføring",
     "stats": {
       "total": "Total tid",
@@ -101,6 +115,7 @@
       "duration": "Varighet",
       "hours": "Timer",
       "job": "Jobb",
+      "client": "Kunde",
       "vehicle": "Kjøretøy",
       "plate": "Skilt",
       "source": "Kilde",

+ 15 - 0
messages/nl/timeTracking.json

@@ -44,6 +44,20 @@
     "apply": "Toepassen",
     "allTechnicians": "Alle monteurs",
     "export": "CSV exporteren",
+    "print": "Urenstaat afdrukken",
+    "client": "Klant",
+    "noClient": "Geen klant",
+    "signature": "Handtekening",
+    "sortBy": "Sorteren op",
+    "sort": {
+      "default": "Monteur / dag",
+      "client": "Klant",
+      "vehicle": "Voertuig",
+      "clientVehicle": "Klant, daarna voertuig"
+    },
+    "sortDirection": "Sorteerrichting",
+    "ascending": "Oplopend",
+    "descending": "Aflopend",
     "addEntry": "Registratie toevoegen",
     "stats": {
       "total": "Totale tijd",
@@ -101,6 +115,7 @@
       "duration": "Duur",
       "hours": "Uren",
       "job": "Klus",
+      "client": "Klant",
       "vehicle": "Voertuig",
       "plate": "Kenteken",
       "source": "Bron",

+ 15 - 0
messages/pl/timeTracking.json

@@ -44,6 +44,20 @@
     "apply": "Zastosuj",
     "allTechnicians": "Wszyscy technicy",
     "export": "Eksportuj CSV",
+    "print": "Drukuj ewidencję czasu",
+    "client": "Klient",
+    "noClient": "Brak klienta",
+    "signature": "Podpis",
+    "sortBy": "Sortuj według",
+    "sort": {
+      "default": "Technik / dzień",
+      "client": "Klient",
+      "vehicle": "Pojazd",
+      "clientVehicle": "Klient, następnie pojazd"
+    },
+    "sortDirection": "Kierunek sortowania",
+    "ascending": "Rosnąco",
+    "descending": "Malejąco",
     "addEntry": "Dodaj wpis",
     "stats": {
       "total": "Łączny czas",
@@ -101,6 +115,7 @@
       "duration": "Czas trwania",
       "hours": "Godziny",
       "job": "Zlecenie",
+      "client": "Klient",
       "vehicle": "Pojazd",
       "plate": "Rejestracja",
       "source": "Źródło",

+ 15 - 0
messages/pt-BR/timeTracking.json

@@ -44,6 +44,20 @@
     "apply": "Aplicar",
     "allTechnicians": "Todos os técnicos",
     "export": "Exportar CSV",
+    "print": "Imprimir folha de ponto",
+    "client": "Cliente",
+    "noClient": "Sem cliente",
+    "signature": "Assinatura",
+    "sortBy": "Ordenar por",
+    "sort": {
+      "default": "Técnico / dia",
+      "client": "Cliente",
+      "vehicle": "Veículo",
+      "clientVehicle": "Cliente, depois veículo"
+    },
+    "sortDirection": "Direção da ordenação",
+    "ascending": "Crescente",
+    "descending": "Decrescente",
     "addEntry": "Adicionar registro",
     "stats": {
       "total": "Tempo total",
@@ -101,6 +115,7 @@
       "duration": "Duração",
       "hours": "Horas",
       "job": "Serviço",
+      "client": "Cliente",
       "vehicle": "Veículo",
       "plate": "Placa",
       "source": "Origem",

+ 15 - 0
messages/pt-PT/timeTracking.json

@@ -44,6 +44,20 @@
     "apply": "Aplicar",
     "allTechnicians": "Todos os técnicos",
     "export": "Exportar CSV",
+    "print": "Imprimir folha de horas",
+    "client": "Cliente",
+    "noClient": "Sem cliente",
+    "signature": "Assinatura",
+    "sortBy": "Ordenar por",
+    "sort": {
+      "default": "Técnico / dia",
+      "client": "Cliente",
+      "vehicle": "Viatura",
+      "clientVehicle": "Cliente, depois viatura"
+    },
+    "sortDirection": "Sentido da ordenação",
+    "ascending": "Crescente",
+    "descending": "Decrescente",
     "addEntry": "Adicionar registo",
     "stats": {
       "total": "Tempo total",
@@ -101,6 +115,7 @@
       "duration": "Duração",
       "hours": "Horas",
       "job": "Trabalho",
+      "client": "Cliente",
       "vehicle": "Viatura",
       "plate": "Matrícula",
       "source": "Origem",

+ 15 - 0
messages/ru/timeTracking.json

@@ -44,6 +44,20 @@
     "apply": "Применить",
     "allTechnicians": "Все техники",
     "export": "Экспорт CSV",
+    "print": "Распечатать табель",
+    "client": "Клиент",
+    "noClient": "Без клиента",
+    "signature": "Подпись",
+    "sortBy": "Сортировать по",
+    "sort": {
+      "default": "Техник / день",
+      "client": "Клиент",
+      "vehicle": "Автомобиль",
+      "clientVehicle": "Клиент, затем автомобиль"
+    },
+    "sortDirection": "Направление сортировки",
+    "ascending": "По возрастанию",
+    "descending": "По убыванию",
     "addEntry": "Добавить запись",
     "stats": {
       "total": "Всего времени",
@@ -101,6 +115,7 @@
       "duration": "Длительность",
       "hours": "Часы",
       "job": "Заказ",
+      "client": "Клиент",
       "vehicle": "Автомобиль",
       "plate": "Номер",
       "source": "Источник",

+ 15 - 0
messages/tr/timeTracking.json

@@ -44,6 +44,20 @@
     "apply": "Uygula",
     "allTechnicians": "Tüm teknisyenler",
     "export": "CSV dışa aktar",
+    "print": "Zaman çizelgesini yazdır",
+    "client": "Müşteri",
+    "noClient": "Müşteri yok",
+    "signature": "İmza",
+    "sortBy": "Sıralama ölçütü",
+    "sort": {
+      "default": "Teknisyen / gün",
+      "client": "Müşteri",
+      "vehicle": "Araç",
+      "clientVehicle": "Müşteri, ardından araç"
+    },
+    "sortDirection": "Sıralama yönü",
+    "ascending": "Artan",
+    "descending": "Azalan",
     "addEntry": "Kayıt ekle",
     "stats": {
       "total": "Toplam süre",
@@ -101,6 +115,7 @@
       "duration": "Süre",
       "hours": "Saat",
       "job": "İş",
+      "client": "Müşteri",
       "vehicle": "Araç",
       "plate": "Plaka",
       "source": "Kaynak",

+ 167 - 0
src/__tests__/features/time-tracking/serialize.test.ts

@@ -0,0 +1,167 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { type EntryRow, toSheetEntries } from '@/features/time-tracking/Lib/serialize'
+import { db } from '@/lib/db'
+
+vi.mock('@/lib/db', () => ({
+  db: { user: { findMany: vi.fn() } },
+}))
+
+function entryRow(overrides: Partial<EntryRow> = {}): EntryRow {
+  return {
+    id: 'e1',
+    startedAt: new Date('2026-09-07T06:00:00Z'),
+    endedAt: new Date('2026-09-07T07:00:00Z'),
+    durationMinutes: 60,
+    note: null,
+    source: 'app',
+    editedAt: null,
+    editedByUserId: null,
+    technicianId: 't1',
+    technician: { id: 't1', name: 'Kari', color: '#3b82f6' },
+    serviceRecord: {
+      id: 'j1',
+      title: 'Brakes',
+      status: 'in-progress',
+      vehicleId: 'v1',
+      customer: { name: 'Direct customer' },
+      vehicle: {
+        make: 'Volvo',
+        model: 'V70',
+        licensePlate: 'AB12345',
+        customer: { name: 'Vehicle customer' },
+      },
+    },
+    ...overrides,
+  }
+}
+
+describe('toSheetEntries', () => {
+  beforeEach(() => {
+    vi.mocked(db.user.findMany).mockReset()
+    vi.mocked(db.user.findMany).mockResolvedValue([])
+  })
+
+  it('uses the vehicle customer instead of the direct customer and serializes dates', async () => {
+    const row = entryRow()
+
+    expect(await toSheetEntries([row])).toEqual([
+      {
+        id: 'e1',
+        startedAt: '2026-09-07T06:00:00.000Z',
+        endedAt: '2026-09-07T07:00:00.000Z',
+        durationMinutes: 60,
+        note: null,
+        source: 'app',
+        editedAt: null,
+        editedByName: null,
+        technicianId: 't1',
+        technicianName: 'Kari',
+        technicianColor: '#3b82f6',
+        job: {
+          id: 'j1',
+          title: 'Brakes',
+          status: 'in-progress',
+          vehicleId: 'v1',
+          vehicleLabel: 'Volvo V70',
+          licensePlate: 'AB12345',
+          clientName: 'Vehicle customer',
+        },
+      },
+    ])
+    expect(db.user.findMany).not.toHaveBeenCalled()
+  })
+
+  it('uses the direct customer for a counter sale without a vehicle', async () => {
+    const row = entryRow()
+    row.serviceRecord = {
+      ...row.serviceRecord,
+      title: 'Counter sale',
+      vehicleId: null,
+      vehicle: null,
+      customer: { name: 'Counter customer' },
+    }
+
+    const [serialized] = await toSheetEntries([row])
+
+    expect(serialized.job).toEqual({
+      id: 'j1',
+      title: 'Counter sale',
+      status: 'in-progress',
+      vehicleId: null,
+      vehicleLabel: null,
+      licensePlate: null,
+      clientName: 'Counter customer',
+    })
+  })
+
+  it.each([
+    null,
+    undefined,
+  ])('leaves an unassigned job with a %s customer unnamed', async (customer) => {
+    const row = entryRow({ endedAt: null, durationMinutes: null })
+    row.serviceRecord = { ...row.serviceRecord, vehicleId: null, vehicle: null, customer }
+
+    const [serialized] = await toSheetEntries([row])
+
+    expect(serialized.job.clientName).toBeNull()
+    expect(serialized.job.vehicleLabel).toBeNull()
+    expect(serialized.job.licensePlate).toBeNull()
+    expect(serialized.endedAt).toBeNull()
+    expect(serialized.durationMinutes).toBeNull()
+  })
+
+  it.each([
+    null,
+    undefined,
+  ])('does not fall back to the direct customer when a vehicle has a %s customer', async (customer) => {
+    const row = entryRow()
+    row.serviceRecord.vehicle = { make: 'Volvo', model: 'V70', licensePlate: null, customer }
+
+    const [serialized] = await toSheetEntries([row])
+
+    expect(serialized.job.clientName).toBeNull()
+    expect(serialized.job.vehicleLabel).toBe('Volvo V70')
+    expect(serialized.job.licensePlate).toBeNull()
+  })
+
+  it('batches unique editor ids and resolves names, email fallbacks, and missing editors', async () => {
+    vi.mocked(db.user.findMany).mockResolvedValue([
+      { id: 'u1', name: 'Manager', email: 'manager@example.com' },
+      { id: 'u2', name: null, email: 'editor@example.com' },
+    ] as Awaited<ReturnType<typeof db.user.findMany>>)
+    const editedAt = new Date('2026-09-08T10:00:00Z')
+    const rows = [
+      entryRow({ id: 'e1', editedByUserId: 'u1', editedAt }),
+      entryRow({ id: 'e2', editedByUserId: 'u1', editedAt }),
+      entryRow({ id: 'e3', editedByUserId: 'u2', editedAt }),
+      entryRow({ id: 'e4', editedByUserId: 'missing', editedAt }),
+      entryRow({ id: 'e5' }),
+    ]
+
+    const serialized = await toSheetEntries(rows)
+
+    expect(db.user.findMany).toHaveBeenCalledExactlyOnceWith({
+      where: { id: { in: ['u1', 'u2', 'missing'] } },
+      select: { id: true, name: true, email: true },
+    })
+    expect(serialized.map((e) => e.editedByName)).toEqual([
+      'Manager',
+      'Manager',
+      'editor@example.com',
+      null,
+      null,
+    ])
+    expect(serialized.map((e) => e.editedAt)).toEqual([
+      '2026-09-08T10:00:00.000Z',
+      '2026-09-08T10:00:00.000Z',
+      '2026-09-08T10:00:00.000Z',
+      '2026-09-08T10:00:00.000Z',
+      null,
+    ])
+  })
+
+  it('returns an empty sheet without querying editors for an empty batch', async () => {
+    expect(await toSheetEntries([])).toEqual([])
+    expect(db.user.findMany).not.toHaveBeenCalled()
+  })
+})

+ 136 - 0
src/__tests__/features/time-tracking/timesheet-print.test.tsx

@@ -0,0 +1,136 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { cleanup, render, screen, within } from '@testing-library/react'
+import { NextIntlClientProvider } from 'next-intl'
+import messages from '../../../../messages/en/timeTracking.json'
+import {
+  type PrintGroup,
+  TimesheetPrintSheet,
+} from '@/features/time-tracking/Components/TimesheetPrintSheet'
+import { printTimesheet } from '@/features/time-tracking/Lib/print-timesheet'
+import type { SheetEntry } from '@/features/time-tracking/Lib/timesheet'
+
+const entry: SheetEntry = {
+  id: 'e1',
+  technicianId: 't1',
+  technicianName: 'Kari',
+  technicianColor: '#000',
+  startedAt: '2026-09-06T22:00:00Z',
+  endedAt: '2026-09-07T02:00:00Z',
+  durationMinutes: 240,
+  source: 'app',
+  note: '<script>alert("note")</script>',
+  editedAt: null,
+  editedByName: null,
+  job: {
+    id: 'j1',
+    title: 'Brakes',
+    status: 'in-progress',
+    vehicleId: 'v1',
+    vehicleLabel: 'Volvo V70',
+    licensePlate: 'AB12345',
+    clientName: 'Alice & Bob',
+  },
+}
+
+afterEach(() => {
+  cleanup()
+  document.querySelectorAll('iframe').forEach((frame) => frame.remove())
+  vi.useRealTimers()
+})
+
+function renderSheet(groups: PrintGroup[]) {
+  return render(
+    <NextIntlClientProvider locale="en" messages={{ timeTracking: messages }}>
+      <TimesheetPrintSheet
+        title="Timesheets"
+        range="2026-09-07"
+        technician="All technicians"
+        zoneNote="Times are in UTC"
+        groups={groups}
+        now={new Date('2026-09-07T03:00:00Z')}
+        formatDate={(date) => date.slice(0, 10)}
+        formatTime={(date) => date.slice(11, 16)}
+      />
+    </NextIntlClientProvider>
+  )
+}
+
+describe('printable timesheet', () => {
+  it('lists client, vehicle, plate, source and note with the whole entry as its duration', () => {
+    const { container } = renderSheet([
+      { key: 'alice', label: 'Alice & Bob', entries: [entry], minutes: 240 },
+    ])
+    expect(screen.getByRole('columnheader', { name: 'Client' })).toBeInTheDocument()
+    const row = screen.getAllByRole('row')[2]
+    expect(within(row).getByText('Alice & Bob')).toBeInTheDocument()
+    expect(within(row).getByText('Volvo V70 · AB12345')).toBeInTheDocument()
+    expect(within(row).getByText('App')).toBeInTheDocument()
+    // 22:00 to 02:00 is four hours on the row that says so, not two.
+    expect(within(row).getByText('22:00 – 2026-09-07 02:00')).toBeInTheDocument()
+    expect(within(row).getByText('4h 00m')).toBeInTheDocument()
+    expect(within(row).getByText(entry.note as string)).toBeInTheDocument()
+    expect(container.querySelector('script')).toBeNull()
+    expect(container.querySelector('a, button')).toBeNull()
+  })
+
+  it('heads each group with its subtotal and totals the rows', () => {
+    const running: SheetEntry = {
+      ...entry,
+      id: 'e2',
+      technicianName: 'Ola',
+      endedAt: null,
+      durationMinutes: null,
+      source: 'web',
+      editedByName: 'Manager',
+      job: { ...entry.job, vehicleId: null, vehicleLabel: null, licensePlate: null },
+    }
+    renderSheet([
+      { key: 'a', label: 'Alice & Bob', entries: [entry], minutes: 240 },
+      { key: 'b', label: 'No client', entries: [running], minutes: 300 },
+    ])
+    const rows = screen.getAllByRole('row')
+    expect(rows).toHaveLength(6)
+    expect(within(rows[1]).getByText('Alice & Bob')).toBeInTheDocument()
+    expect(within(rows[1]).getByText('4h 00m')).toBeInTheDocument()
+    expect(within(rows[3]).getByText('No client')).toBeInTheDocument()
+    expect(within(rows[3]).getByText('5h 00m')).toBeInTheDocument()
+    expect(within(rows[4]).getByText('Ola')).toBeInTheDocument()
+    expect(within(rows[4]).getByText('Counter sale')).toBeInTheDocument()
+    expect(within(rows[4]).getByText('22:00 – Running')).toBeInTheDocument()
+    expect(within(rows[4]).getByText('Web (Adjusted by Manager)')).toBeInTheDocument()
+    expect(within(rows[5]).getByText('Total time')).toBeInTheDocument()
+    expect(within(rows[5]).getByText('9h 00m')).toBeInTheDocument()
+    expect(screen.getByText('Signature')).toBeInTheDocument()
+  })
+})
+
+describe('printTimesheet', () => {
+  it('prints an isolated snapshot and removes the iframe after printing', () => {
+    vi.useFakeTimers()
+    const content = document.createElement('div')
+    content.textContent = 'Alice & Bob'
+    printTimesheet(content, 'Timesheets')
+    content.textContent = 'Changed after clicking print'
+    const frame = document.querySelector('iframe') as HTMLIFrameElement
+    const view = frame.contentWindow as Window
+    const print = vi.spyOn(view, 'print').mockImplementation(() => undefined)
+    vi.spyOn(view, 'focus').mockImplementation(() => undefined)
+    frame.dispatchEvent(new Event('load'))
+    expect(print).toHaveBeenCalledOnce()
+    expect(frame.contentDocument?.title).toBe('Timesheets')
+    expect(frame.contentDocument?.body.textContent).toBe('Alice & Bob')
+    expect(frame.contentDocument?.querySelector('style')?.textContent).toContain(
+      'table-header-group'
+    )
+    view.dispatchEvent(new Event('afterprint'))
+    expect(document.querySelector('iframe')).toBeNull()
+  })
+
+  it('cleans up when a browser does not emit afterprint', () => {
+    vi.useFakeTimers()
+    printTimesheet(document.createElement('div'), 'Timesheets')
+    expect(document.querySelector('iframe')).not.toBeNull()
+    vi.advanceTimersByTime(300_000)
+    expect(document.querySelector('iframe')).toBeNull()
+  })
+})

+ 355 - 0
src/__tests__/features/time-tracking/timesheet.test.ts

@@ -5,10 +5,13 @@ import {
   entryMinutes,
   formatElapsed,
   formatMinutes,
+  groupTimesheetEntries,
   minutesToBillableHours,
   sliceByDay,
+  sortTimesheetEntries,
   type SheetEntry,
 } from '@/features/time-tracking/Lib/timesheet'
+import { timesheetCsv } from '@/features/time-tracking/Lib/timesheet-csv'
 
 const TZ = 'Europe/Oslo'
 
@@ -40,6 +43,358 @@ function entry(overrides: Partial<SheetEntry> & { startedAt: string; endedAt: st
   } satisfies SheetEntry
 }
 
+describe('sortTimesheetEntries', () => {
+  function sortableEntry(
+    id: string,
+    job: Partial<SheetEntry['job']>,
+    startedAt = '2026-09-07T06:00:00Z'
+  ): SheetEntry {
+    const base = entry({ id, startedAt, endedAt: null })
+    return { ...base, job: { ...base.job, clientName: 'Same client', ...job } }
+  }
+
+  it.each(['asc', 'desc'] as const)('sorts clients %s independently of vehicles', (direction) => {
+    const entries = [
+      sortableEntry('zed', { clientName: 'Zed', vehicleLabel: 'Audi A3' }),
+      sortableEntry('alice', { clientName: 'Alice', vehicleLabel: 'Volvo V70' }),
+      sortableEntry('bob', { clientName: 'Bob', vehicleLabel: 'BMW X3' }),
+    ]
+
+    expect(sortTimesheetEntries(entries, 'client', direction, 'en').map((e) => e.id)).toEqual(
+      direction === 'asc' ? ['alice', 'bob', 'zed'] : ['zed', 'bob', 'alice']
+    )
+  })
+
+  it.each([
+    'asc',
+    'desc',
+  ] as const)('sorts vehicles %s by make/model, then numeric plate, ignoring clients', (direction) => {
+    const entries = [
+      sortableEntry('volvo', { clientName: 'Alice', vehicleLabel: 'Volvo V70' }),
+      sortableEntry('audi-10', { vehicleLabel: 'Audi A3', licensePlate: 'AB10' }),
+      sortableEntry('audi-a4', { vehicleLabel: 'Audi A4', licensePlate: 'AB1' }),
+      sortableEntry('audi-2', {
+        clientName: 'Zed',
+        vehicleLabel: 'Audi A3',
+        licensePlate: 'AB2',
+      }),
+    ]
+
+    expect(sortTimesheetEntries(entries, 'vehicle', direction, 'en').map((e) => e.id)).toEqual(
+      direction === 'asc'
+        ? ['audi-2', 'audi-10', 'audi-a4', 'volvo']
+        : ['volvo', 'audi-a4', 'audi-10', 'audi-2']
+    )
+  })
+
+  it.each([
+    'asc',
+    'desc',
+  ] as const)('prioritizes client before vehicle and plate in combined %s sorting', (direction) => {
+    const entries = [
+      sortableEntry('bob-audi', { clientName: 'Bob', vehicleLabel: 'Audi A3' }),
+      sortableEntry('alice-volvo', { clientName: 'Alice', vehicleLabel: 'Volvo V70' }),
+      sortableEntry('alice-audi-10', {
+        clientName: 'Alice',
+        vehicleLabel: 'Audi A3',
+        licensePlate: 'AB10',
+      }),
+      sortableEntry('alice-audi-2', {
+        clientName: 'Alice',
+        vehicleLabel: 'Audi A3',
+        licensePlate: 'AB2',
+      }),
+    ]
+
+    expect(
+      sortTimesheetEntries(entries, 'clientVehicle', direction, 'en').map((e) => e.id)
+    ).toEqual(
+      direction === 'asc'
+        ? ['alice-audi-2', 'alice-audi-10', 'alice-volvo', 'bob-audi']
+        : ['bob-audi', 'alice-volvo', 'alice-audi-10', 'alice-audi-2']
+    )
+  })
+
+  describe.each(['asc', 'desc'] as const)('missing values with %s sorting', (direction) => {
+    it.each([
+      'client',
+      'clientVehicle',
+    ] as const)('puts null and absent clients last for %s', (sort) => {
+      const entries = [
+        sortableEntry('null', { clientName: null }, '2026-09-07T07:00:00Z'),
+        sortableEntry('absent', { clientName: undefined }),
+        sortableEntry('named', { clientName: 'Zed' }),
+      ]
+
+      expect(sortTimesheetEntries(entries, sort, direction).map((e) => e.id)).toEqual([
+        'named',
+        'null',
+        'absent',
+      ])
+    })
+
+    it.each(['vehicle', 'clientVehicle'] as const)('puts missing vehicles last for %s', (sort) => {
+      const entries = [
+        sortableEntry('missing', { vehicleId: null, vehicleLabel: null, licensePlate: null }),
+        sortableEntry('volvo', { vehicleLabel: 'Volvo V70' }),
+        sortableEntry('audi', { vehicleLabel: 'Audi A3' }),
+      ]
+
+      expect(sortTimesheetEntries(entries, sort, direction).map((e) => e.id)).toEqual(
+        direction === 'asc' ? ['audi', 'volvo', 'missing'] : ['volvo', 'audi', 'missing']
+      )
+    })
+
+    it.each([
+      'vehicle',
+      'clientVehicle',
+    ] as const)('puts null plates last for matching vehicles in %s', (sort) => {
+      const entries = [
+        sortableEntry('missing', { licensePlate: null }),
+        sortableEntry('plate-10', { licensePlate: 'AB10' }),
+        sortableEntry('plate-2', { licensePlate: 'AB2' }),
+      ]
+
+      expect(sortTimesheetEntries(entries, sort, direction).map((e) => e.id)).toEqual(
+        direction === 'asc'
+          ? ['plate-2', 'plate-10', 'missing']
+          : ['plate-10', 'plate-2', 'missing']
+      )
+    })
+
+    it.each([
+      'default',
+      'client',
+      'vehicle',
+      'clientVehicle',
+    ] as const)('returns a new array without mutating entries for %s', (sort) => {
+      const entries = [
+        sortableEntry('zed', { clientName: 'Zed', vehicleLabel: 'Volvo V70' }),
+        sortableEntry('alice', { clientName: 'Alice', vehicleLabel: 'Audi A3' }),
+      ]
+      const before = structuredClone(entries)
+
+      const sorted = sortTimesheetEntries(entries, sort, direction)
+
+      expect(sorted).not.toBe(entries)
+      expect(entries).toEqual(before)
+      expect(sorted).toHaveLength(entries.length)
+      for (const original of entries) expect(sorted).toContain(original)
+      if (sort === 'default') expect(sorted).toEqual(entries)
+    })
+
+    it.each([
+      'client',
+      'vehicle',
+      'clientVehicle',
+    ] as const)('breaks %s ties by newest start first, then id', (sort) => {
+      const entries = [
+        sortableEntry('old', {}, '2026-09-07T06:00:00Z'),
+        sortableEntry('b', {}, '2026-09-07T08:00:00Z'),
+        sortableEntry('a', {}, '2026-09-07T08:00:00Z'),
+      ]
+
+      expect(sortTimesheetEntries(entries, sort, direction).map((e) => e.id)).toEqual([
+        'a',
+        'b',
+        'old',
+      ])
+    })
+  })
+
+  it('defaults to ascending order', () => {
+    const entries = [
+      sortableEntry('zed', { clientName: 'Zed' }),
+      sortableEntry('alice', { clientName: 'Alice' }),
+    ]
+
+    expect(sortTimesheetEntries(entries, 'client').map((e) => e.id)).toEqual(['alice', 'zed'])
+  })
+
+  it.each([
+    'client',
+    'vehicle',
+    'clientVehicle',
+  ] as const)('uses locale-aware %s collation', (sort) => {
+    const entries = [
+      sortableEntry('z', { clientName: 'Zebra', vehicleLabel: 'Zebra' }),
+      sortableEntry('a-umlaut', { clientName: 'Älg', vehicleLabel: 'Älg' }),
+    ]
+
+    expect(sortTimesheetEntries(entries, sort, 'asc', 'en').map((e) => e.id)).toEqual([
+      'a-umlaut',
+      'z',
+    ])
+    expect(sortTimesheetEntries(entries, sort, 'asc', 'sv').map((e) => e.id)).toEqual([
+      'z',
+      'a-umlaut',
+    ])
+    expect(sortTimesheetEntries(entries, sort, 'desc', 'sv').map((e) => e.id)).toEqual([
+      'a-umlaut',
+      'z',
+    ])
+  })
+
+  it('compares client names without case or accent sensitivity before breaking ties', () => {
+    const entries = [
+      sortableEntry('old', { clientName: 'Élodie' }, '2026-09-07T06:00:00Z'),
+      sortableEntry('new', { clientName: 'elodie' }, '2026-09-07T08:00:00Z'),
+    ]
+
+    expect(sortTimesheetEntries(entries, 'client', 'asc', 'en').map((e) => e.id)).toEqual([
+      'new',
+      'old',
+    ])
+    expect(sortTimesheetEntries(entries, 'client', 'desc', 'en').map((e) => e.id)).toEqual([
+      'new',
+      'old',
+    ])
+  })
+})
+
+describe('groupTimesheetEntries', () => {
+  const NOW = new Date('2026-09-08T00:00:00Z')
+  function grouped(
+    id: string,
+    job: Partial<SheetEntry['job']>,
+    overrides: Partial<SheetEntry> = {}
+  ): SheetEntry {
+    const base = entry({
+      id,
+      startedAt: '2026-09-07T06:00:00Z',
+      endedAt: '2026-09-07T07:00:00Z',
+      ...overrides,
+    })
+    return { ...base, job: { ...base.job, clientName: 'Alice', ...job } }
+  }
+
+  it('keeps one group per client in the order given, with whole-entry subtotals', () => {
+    const entries = [
+      grouped('a1', { clientName: 'Alice' }),
+      grouped('b1', { clientName: 'Bob' }),
+      grouped('a2', { clientName: 'Alice' }),
+      grouped('none', { clientName: null }),
+    ]
+
+    const groups = groupTimesheetEntries(entries, 'client', NOW)
+
+    expect(groups.map((g) => [g.clientName, g.entries.map((e) => e.id), g.minutes])).toEqual([
+      ['Alice', ['a1', 'a2'], 120],
+      ['Bob', ['b1'], 60],
+      [null, ['none'], 60],
+    ])
+    expect(groups.every((g) => g.vehicle === null && g.technicianName === null)).toBe(true)
+  })
+
+  it('groups vehicles by id, not label, and counter sales together', () => {
+    const entries = [
+      grouped('v1', { vehicleId: 'v1', vehicleLabel: 'Audi A3', licensePlate: 'AB1' }),
+      grouped('v2', { vehicleId: 'v2', vehicleLabel: 'Audi A3', licensePlate: 'AB2' }),
+      grouped('sale-1', { vehicleId: null, vehicleLabel: null, licensePlate: null }),
+      grouped('sale-2', {
+        clientName: 'Bob',
+        vehicleId: null,
+        vehicleLabel: null,
+        licensePlate: null,
+      }),
+    ]
+
+    const groups = groupTimesheetEntries(entries, 'vehicle', NOW)
+
+    expect(groups.map((g) => [g.vehicle, g.entries.map((e) => e.id)])).toEqual([
+      [{ label: 'Audi A3', licensePlate: 'AB1' }, ['v1']],
+      [{ label: 'Audi A3', licensePlate: 'AB2' }, ['v2']],
+      [{ label: null, licensePlate: null }, ['sale-1', 'sale-2']],
+    ])
+    expect(groups.every((g) => g.clientName === null)).toBe(true)
+  })
+
+  it('splits a client by vehicle for the combined sort', () => {
+    const entries = [
+      grouped('alice-v1', { vehicleId: 'v1' }),
+      grouped('alice-v2', { vehicleId: 'v2' }),
+      grouped('bob-v1', { clientName: 'Bob', vehicleId: 'v1' }),
+      grouped('alice-v1-again', { vehicleId: 'v1' }),
+    ]
+
+    expect(
+      groupTimesheetEntries(entries, 'clientVehicle', NOW).map((g) => [
+        g.clientName,
+        g.entries.map((e) => e.id),
+      ])
+    ).toEqual([
+      ['Alice', ['alice-v1', 'alice-v1-again']],
+      ['Alice', ['alice-v2']],
+      ['Bob', ['bob-v1']],
+    ])
+  })
+
+  it('groups by technician by default and counts a running clock up to now', () => {
+    const entries = [
+      grouped('kari', {}),
+      grouped(
+        'ola',
+        {},
+        {
+          technicianId: 't2',
+          technicianName: 'Ola',
+          startedAt: '2026-09-07T22:00:00Z',
+          endedAt: null,
+        }
+      ),
+    ]
+
+    expect(
+      groupTimesheetEntries(entries, 'default', NOW).map((g) => [g.technicianName, g.minutes])
+    ).toEqual([
+      ['Kari', 60],
+      ['Ola', 120],
+    ])
+  })
+})
+
+describe('timesheetCsv', () => {
+  const headers = [
+    'Technician',
+    'Date',
+    'Start',
+    'End',
+    'Duration',
+    'Hours',
+    'Job',
+    'Vehicle',
+    'Plate',
+    'Source',
+    'Edited by',
+    'Note',
+    'Client',
+  ]
+
+  it('appends client names as the last column and quotes commas and double quotes', () => {
+    const base = entry({
+      startedAt: '2026-09-07T06:00:00Z',
+      endedAt: '2026-09-07T07:00:00Z',
+      note: 'Checked, ready',
+    })
+    const entries = [
+      { ...base, job: { ...base.job, title: 'Brakes, front', clientName: 'Smith, "Auto"' } },
+      { ...base, id: 'e2', note: null, job: { ...base.job, clientName: 'Alice' } },
+      { ...base, id: 'e3', note: null, job: { ...base.job, clientName: null } },
+      { ...base, id: 'e4', note: null },
+    ]
+
+    expect(timesheetCsv(entries, TZ, new Date('2026-09-08T00:00:00Z'), headers)).toBe(
+      [
+        headers.join(','),
+        'Kari,2026-09-07,08:00,09:00,1h 00m,1.00,"Brakes, front",Volvo V70,AB12345,app,,"Checked, ready","Smith, ""Auto"""',
+        'Kari,2026-09-07,08:00,09:00,1h 00m,1.00,Brakes,Volvo V70,AB12345,app,,,Alice',
+        'Kari,2026-09-07,08:00,09:00,1h 00m,1.00,Brakes,Volvo V70,AB12345,app,,,',
+        'Kari,2026-09-07,08:00,09:00,1h 00m,1.00,Brakes,Volvo V70,AB12345,app,,,',
+      ].join('\n')
+    )
+  })
+})
+
 describe('sliceByDay', () => {
   it('keeps an ordinary stretch on one workshop day', () => {
     // 08:00 to 11:30 Oslo summer time is 06:00Z to 09:30Z.

+ 253 - 38
src/app/(authenticated)/timesheets/timesheets-client.tsx

@@ -1,12 +1,14 @@
 'use client'
 
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
 import Link from 'next/link'
 import { usePathname, useRouter } from 'next/navigation'
-import { useFormatter, useTranslations } from 'next-intl'
+import { useFormatter, useLocale, useTranslations } from 'next-intl'
 import { toast } from 'sonner'
 import type { DateRange } from 'react-day-picker'
 import {
+  ArrowDownWideNarrow,
+  ArrowUpNarrowWide,
   CalendarIcon,
   Clock,
   Download,
@@ -14,6 +16,7 @@ import {
   MoreHorizontal,
   Pencil,
   Plus,
+  Printer,
   Square,
   Timer,
   Trash2,
@@ -55,6 +58,11 @@ import {
   entryMinutes,
   formatElapsed,
   formatMinutes,
+  groupTimesheetEntries,
+  sortTimesheetEntries,
+  type EntryGroup,
+  type SortDirection,
+  type TimesheetSort,
   type SheetEntry,
   type TechnicianSheet,
   type Timesheet,
@@ -64,6 +72,8 @@ import { useClockEvents } from '@/features/time-tracking/Components/TimeClockPro
 import { useTick } from '@/features/time-tracking/hooks/useTick'
 import { SourceBadge } from '@/features/time-tracking/Components/SourceBadge'
 import { TimeEntryDialog } from '@/features/time-tracking/Components/TimeEntryDialog'
+import { TimesheetPrintSheet } from '@/features/time-tracking/Components/TimesheetPrintSheet'
+import { printTimesheet } from '@/features/time-tracking/Lib/print-timesheet'
 
 /** A day key as a Date that formats to that day in any zone within twelve hours of UTC. */
 function noonOf(dayKey: string): Date {
@@ -81,6 +91,8 @@ function localKey(date: Date): string {
   return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
 }
 
+const SORTS: TimesheetSort[] = ['default', 'client', 'vehicle', 'clientVehicle']
+
 type Preset = 'today' | 'yesterday' | 'thisWeek' | 'lastWeek' | 'thisMonth' | 'lastMonth'
 const PRESETS: Preset[] = ['today', 'yesterday', 'thisWeek', 'lastWeek', 'thisMonth', 'lastMonth']
 
@@ -139,6 +151,8 @@ export default function TimesheetsClient({
   initialError: string | null
 }) {
   const t = useTranslations('timeTracking.page')
+  const tDialog = useTranslations('timeTracking.dialog')
+  const locale = useLocale()
   const router = useRouter()
   const pathname = usePathname()
   const format = useFormatter()
@@ -150,6 +164,29 @@ export default function TimesheetsClient({
   const [error, setError] = useState<string | null>(initialError)
   const [loading, setLoading] = useState(false)
   const [technicianId, setTechnicianId] = useState<string>('all')
+  const [sortBy, setSortBy] = useState<TimesheetSort>('default')
+  const [sortDirection, setSortDirection] = useState<SortDirection>('asc')
+  // A new order is a new list, and usually a shorter one: left alone, the
+  // browser clamps the scroll to wherever the page now ends. If the reader was
+  // already down among the entries, put them at the start of the new list.
+  const entriesRef = useRef<HTMLDivElement>(null)
+  const lastSort = useRef(`${sortBy}:${sortDirection}`)
+  useLayoutEffect(() => {
+    const sort = `${sortBy}:${sortDirection}`
+    if (lastSort.current === sort) return
+    lastSort.current = sort
+    const el = entriesRef.current
+    if (el && el.getBoundingClientRect().top < 128) el.scrollIntoView({ block: 'start' })
+  }, [sortBy, sortDirection])
+
+  // The print copy exists only while a print is being handed to the browser.
+  const [printing, setPrinting] = useState(false)
+  const printRef = useRef<HTMLDivElement>(null)
+  useEffect(() => {
+    if (!printing) return
+    if (printRef.current) printTimesheet(printRef.current, t('title'))
+    setPrinting(false)
+  }, [printing, t])
   const [dialog, setDialog] = useState<{
     open: boolean
     entry?: SheetEntry | null
@@ -212,6 +249,38 @@ export default function TimesheetsClient({
     })
   }, [data, technicianId, now])
 
+  const sortedEntries = useMemo(
+    () =>
+      sortTimesheetEntries(
+        sheet?.technicians.flatMap((s) => s.entries) ?? [],
+        sortBy,
+        sortDirection,
+        locale
+      ),
+    [sheet, sortBy, sortDirection, locale]
+  )
+
+  // Per technician by default, which only the print copy reads.
+  const groups = useMemo(
+    () =>
+      sortBy === 'default' && !printing ? [] : groupTimesheetEntries(sortedEntries, sortBy, now),
+    [sortedEntries, sortBy, printing, now]
+  )
+
+  const groupLabel = (group: EntryGroup) =>
+    [
+      group.technicianName,
+      group.vehicle || group.technicianName
+        ? group.clientName
+        : (group.clientName ?? t('noClient')),
+      group.vehicle &&
+        [group.vehicle.label ?? t('table.counterSale'), group.vehicle.licensePlate]
+          .filter(Boolean)
+          .join(' · '),
+    ]
+      .filter(Boolean)
+      .join(' · ')
+
   const activePreset = useMemo<Preset | null>(() => {
     if (!data) return null
     for (const preset of PRESETS) {
@@ -275,8 +344,7 @@ export default function TimesheetsClient({
 
   const exportCsv = () => {
     if (!data || !sheet) return
-    const entries = sheet.technicians.flatMap((s) => s.entries)
-    const csv = timesheetCsv(entries, data.timeZone, now, [
+    const csv = timesheetCsv(sortedEntries, data.timeZone, now, [
       t('csv.technician'),
       t('csv.date'),
       t('csv.start'),
@@ -289,6 +357,7 @@ export default function TimesheetsClient({
       t('csv.source'),
       t('csv.editedBy'),
       t('csv.note'),
+      t('csv.client'),
     ])
     downloadText(`timesheet-${data.fromKey}-${data.toKey}.csv`, `${csv}`)
   }
@@ -308,7 +377,9 @@ export default function TimesheetsClient({
   return (
     <div className="space-y-4">
       {/* ── Toolbar ─────────────────────────────────────────────────────── */}
-      <div className="flex flex-wrap items-center gap-2">
+      {/* Pinned under the page header from md up; on a phone it wraps to
+          several rows and would take the screen with it. */}
+      <div className="-mx-4 flex flex-wrap items-center gap-2 bg-background px-4 py-2 md:sticky md:top-16 md:z-20 md:border-b">
         <div className="flex flex-wrap items-center gap-1 rounded-md border bg-muted/40 p-1">
           {PRESETS.map((preset) => (
             <Button
@@ -364,6 +435,47 @@ export default function TimesheetsClient({
               ))}
             </SelectContent>
           </Select>
+          <Select value={sortBy} onValueChange={(v) => setSortBy(v as TimesheetSort)}>
+            <SelectTrigger className="h-8 w-44" size="sm" aria-label={t('sortBy')}>
+              <SelectValue />
+            </SelectTrigger>
+            <SelectContent>
+              {SORTS.map((sort) => (
+                <SelectItem key={sort} value={sort}>
+                  {t(`sort.${sort}`)}
+                </SelectItem>
+              ))}
+            </SelectContent>
+          </Select>
+          {sortBy !== 'default' && (
+            <Button
+              type="button"
+              size="icon"
+              variant="outline"
+              className="size-8"
+              aria-label={`${t('sortDirection')}: ${t(sortDirection === 'asc' ? 'ascending' : 'descending')}`}
+              title={t(sortDirection === 'asc' ? 'ascending' : 'descending')}
+              onClick={() => setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc')}
+            >
+              {sortDirection === 'asc' ? (
+                <ArrowUpNarrowWide className="size-3.5" />
+              ) : (
+                <ArrowDownWideNarrow className="size-3.5" />
+              )}
+            </Button>
+          )}
+          <Button
+            type="button"
+            size="sm"
+            variant="outline"
+            className="h-8"
+            aria-label={t('print')}
+            onClick={() => setPrinting(true)}
+            disabled={loading || sortedEntries.length === 0}
+          >
+            <Printer className="size-3.5" />
+            <span className="hidden sm:inline">{t('print')}</span>
+          </Button>
           <Button
             type="button"
             size="sm"
@@ -497,44 +609,86 @@ export default function TimesheetsClient({
       )}
 
       {/* ── Entries ─────────────────────────────────────────────────────── */}
-      {sheet.totalMinutes === 0 && sheet.runningCount === 0 ? (
-        <div className="rounded-xl border border-dashed p-10 text-center">
-          <Timer className="mx-auto mb-3 size-8 text-muted-foreground/60" />
-          <p className="font-medium">{t('empty.title')}</p>
-          <p className="mx-auto mt-1 max-w-md text-sm text-muted-foreground">
-            {data.technicians.length === 0 ? t('empty.noTechnicians') : t('empty.body')}
-          </p>
-          {data.technicians.length === 0 && (
-            <Button asChild variant="outline" size="sm" className="mt-4">
-              <Link href="/settings/team">{t('empty.teamLink')}</Link>
-            </Button>
-          )}
-        </div>
-      ) : (
-        sheet.technicians
-          .filter((s) => s.entries.length > 0)
-          .map((s) => (
-            <TechnicianCard
-              key={s.technician.id}
-              sheet={s}
+      <div ref={entriesRef} className="scroll-mt-20 space-y-4 md:scroll-mt-32">
+        {sheet.totalMinutes === 0 && sheet.runningCount === 0 ? (
+          <div className="rounded-xl border border-dashed p-10 text-center">
+            <Timer className="mx-auto mb-3 size-8 text-muted-foreground/60" />
+            <p className="font-medium">{t('empty.title')}</p>
+            <p className="mx-auto mt-1 max-w-md text-sm text-muted-foreground">
+              {data.technicians.length === 0 ? t('empty.noTechnicians') : t('empty.body')}
+            </p>
+            {data.technicians.length === 0 && (
+              <Button asChild variant="outline" size="sm" className="mt-4">
+                <Link href="/settings/team">{t('empty.teamLink')}</Link>
+              </Button>
+            )}
+          </div>
+        ) : sortBy !== 'default' ? (
+          groups.map((group) => (
+            <GroupCard
+              key={group.key}
+              title={groupLabel(group)}
+              group={group}
               now={now}
-              timeZone={timeZone}
               canEdit={data.canEdit}
-              dayLabel={dayLabel}
+              formatDate={formatDate}
               formatTime={formatTime}
               stoppingId={stoppingId}
               onEdit={(entry) => setDialog({ open: true, entry })}
               onStop={(entry) => void handleStop(entry)}
               onDelete={(entry) => void handleDelete(entry)}
-              onAdd={(dayKey) =>
-                setDialog({
-                  open: true,
-                  entry: null,
-                  defaults: { technicianId: s.technician.id, dayKey },
-                })
-              }
             />
           ))
+        ) : (
+          sheet.technicians
+            .filter((s) => s.entries.length > 0)
+            .map((s) => (
+              <TechnicianCard
+                key={s.technician.id}
+                sheet={s}
+                now={now}
+                timeZone={timeZone}
+                canEdit={data.canEdit}
+                dayLabel={dayLabel}
+                formatTime={formatTime}
+                stoppingId={stoppingId}
+                onEdit={(entry) => setDialog({ open: true, entry })}
+                onStop={(entry) => void handleStop(entry)}
+                onDelete={(entry) => void handleDelete(entry)}
+                onAdd={(dayKey) =>
+                  setDialog({
+                    open: true,
+                    entry: null,
+                    defaults: { technicianId: s.technician.id, dayKey },
+                  })
+                }
+              />
+            ))
+        )}
+      </div>
+
+      {printing && (
+        <div className="hidden" aria-hidden="true">
+          <div ref={printRef}>
+            <TimesheetPrintSheet
+              title={t('title')}
+              range={
+                data.fromKey === data.toKey
+                  ? formatDate(noonOf(data.fromKey))
+                  : `${formatDate(noonOf(data.fromKey))} – ${formatDate(noonOf(data.toKey))}`
+              }
+              technician={
+                data.technicians.find((tech) => tech.id === technicianId)?.name ??
+                t('allTechnicians')
+              }
+              zoneNote={tDialog('timesInWorkshopZone', { zone: timeZone })}
+              groups={groups.map((group) => ({ ...group, label: groupLabel(group) }))}
+              now={now}
+              formatDate={formatDate}
+              formatTime={formatTime}
+            />
+          </div>
+        </div>
       )}
 
       <TimeEntryDialog
@@ -692,8 +846,9 @@ function DayGrid({
     return h === 0 ? `${m}m` : m === 0 ? `${h}h` : `${h}h${String(m).padStart(2, '0')}`
   }
 
+  // isolate: the pinned first column must not climb over the page header.
   return (
-    <div className="overflow-x-auto">
+    <div className="isolate overflow-x-auto">
       <table className="w-full border-collapse text-xs">
         <thead>
           <tr className="border-b">
@@ -855,7 +1010,7 @@ function TechnicianCard({
         <section
           key={day.key}
           id={`day-${sheet.technician.id}-${day.key}`}
-          className="scroll-mt-20"
+          className="scroll-mt-20 md:scroll-mt-32"
         >
           <div className="flex items-center gap-2 border-b bg-muted/30 px-3 py-1.5 text-xs">
             <span className="font-medium">{dayLabel(day.key, 'long')}</span>
@@ -923,12 +1078,60 @@ type RowProps = {
   now: Date
   canEdit: boolean
   formatTime: (date: string) => string
+  /** Given where a row is not already under its technician and day. */
+  formatDate?: (date: string) => string
   stoppingId: string | null
   onEdit: (entry: SheetEntry) => void
   onStop: (entry: SheetEntry) => void
   onDelete: (entry: SheetEntry) => void
 }
 
+/** One client's or vehicle's entries, across technicians and days. */
+function GroupCard({
+  title,
+  group,
+  ...row
+}: { title: string; group: EntryGroup } & Omit<RowProps, 'entry'>) {
+  return (
+    <AppCard title={title} badge={formatMinutes(group.minutes)} contentClassName="p-0">
+      {/* Table above md */}
+      <table className="hidden w-full text-sm md:table">
+        <tbody>
+          {group.entries.map((e) => (
+            <EntryRow key={e.id} entry={e} {...row} />
+          ))}
+        </tbody>
+      </table>
+
+      {/* Cards below md */}
+      <ul className="divide-y md:hidden">
+        {group.entries.map((e) => (
+          <EntryCard key={e.id} entry={e} {...row} />
+        ))}
+      </ul>
+    </AppCard>
+  )
+}
+
+function EntryWho({
+  entry,
+  formatDate,
+}: {
+  entry: SheetEntry
+  formatDate: (date: string) => string
+}) {
+  return (
+    <span className="inline-flex items-center gap-2 whitespace-nowrap">
+      <span
+        className="size-2 shrink-0 rounded-full"
+        style={{ backgroundColor: entry.technicianColor }}
+      />
+      <span className="font-medium">{entry.technicianName}</span>
+      <span className="text-xs text-muted-foreground">{formatDate(entry.startedAt)}</span>
+    </span>
+  )
+}
+
 function EntryActions({ entry, canEdit, stoppingId, onEdit, onStop, onDelete }: RowProps) {
   const t = useTranslations('timeTracking.page.table')
   if (!canEdit) return null
@@ -973,11 +1176,16 @@ function EntryActions({ entry, canEdit, stoppingId, onEdit, onStop, onDelete }:
 }
 
 function EntryRow(props: RowProps) {
-  const { entry, now, formatTime } = props
+  const { entry, now, formatTime, formatDate } = props
   const t = useTranslations('timeTracking.page.table')
   const running = !entry.endedAt
   return (
     <tr className={cn('border-b last:border-b-0', running && 'bg-primary/5')}>
+      {formatDate && (
+        <td className="px-3 py-2">
+          <EntryWho entry={entry} formatDate={formatDate} />
+        </td>
+      )}
       <td className="whitespace-nowrap px-3 py-2 font-mono text-xs tabular-nums text-muted-foreground">
         {formatTime(entry.startedAt)}
         <span className="mx-1 opacity-60">–</span>
@@ -996,6 +1204,9 @@ function EntryRow(props: RowProps) {
         <Link href={jobHref(entry)} className="block truncate hover:underline">
           {entry.job.title}
         </Link>
+        {entry.job.clientName && (
+          <div className="truncate text-xs text-muted-foreground">{entry.job.clientName}</div>
+        )}
       </td>
       <td className="whitespace-nowrap px-3 py-2 text-xs text-muted-foreground">
         {entry.job.licensePlate ? (
@@ -1026,12 +1237,13 @@ function EntryRow(props: RowProps) {
 }
 
 function EntryCard(props: RowProps) {
-  const { entry, now, formatTime } = props
+  const { entry, now, formatTime, formatDate } = props
   const t = useTranslations('timeTracking.page.table')
   const running = !entry.endedAt
   return (
     <li className={cn('flex items-start gap-3 px-3 py-2.5', running && 'bg-primary/5')}>
       <div className="min-w-0 flex-1 space-y-1">
+        {formatDate && <EntryWho entry={entry} formatDate={formatDate} />}
         <div className="flex items-center gap-2">
           <span className="font-mono text-xs tabular-nums text-muted-foreground">
             {formatTime(entry.startedAt)}
@@ -1051,6 +1263,9 @@ function EntryCard(props: RowProps) {
         <Link href={jobHref(entry)} className="block truncate text-sm hover:underline">
           {entry.job.title}
         </Link>
+        {entry.job.clientName && (
+          <div className="truncate text-xs text-muted-foreground">{entry.job.clientName}</div>
+        )}
         <div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
           {entry.job.licensePlate && (
             <span className="rounded border px-1 font-mono text-[11px] font-semibold uppercase text-foreground">

+ 127 - 0
src/features/time-tracking/Components/TimesheetPrintSheet.tsx

@@ -0,0 +1,127 @@
+'use client'
+
+import { useTranslations } from 'next-intl'
+import { entryMinutes, formatMinutes, type SheetEntry } from '../Lib/timesheet'
+
+export interface PrintGroup {
+  key: string
+  label: string
+  entries: SheetEntry[]
+  minutes: number
+}
+
+/**
+ * The timesheet as a paper form: the entries under their group headings with
+ * a subtotal each, a total that is the sum of the rows, and somewhere to sign.
+ * Styled by print-timesheet.ts, not by the app, so class names here are its.
+ */
+export function TimesheetPrintSheet({
+  title,
+  range,
+  technician,
+  zoneNote,
+  groups,
+  now,
+  formatDate,
+  formatTime,
+}: {
+  title: string
+  range: string
+  technician: string
+  zoneNote: string
+  groups: PrintGroup[]
+  now: Date
+  formatDate: (date: string) => string
+  formatTime: (date: string) => string
+}) {
+  const t = useTranslations('timeTracking.page')
+  const tSource = useTranslations('timeTracking.source')
+  const total = groups.reduce((sum, group) => sum + group.minutes, 0)
+  const sourceLabel = (entry: SheetEntry) => {
+    const source =
+      entry.source === 'manual'
+        ? tSource('manual')
+        : entry.source === 'web'
+          ? tSource('web')
+          : tSource('app')
+    return entry.editedByName && entry.source !== 'manual'
+      ? `${source} (${tSource('editedBy', { name: entry.editedByName })})`
+      : source
+  }
+  const endLabel = (entry: SheetEntry) => {
+    if (!entry.endedAt) return t('table.running')
+    const day = formatDate(entry.endedAt)
+    const time = formatTime(entry.endedAt)
+    return day === formatDate(entry.startedAt) ? time : `${day} ${time}`
+  }
+
+  return (
+    <div>
+      <h1>{title}</h1>
+      <p className="meta">
+        {range} · {technician}
+      </p>
+      <p className="meta">{zoneNote}</p>
+      <table>
+        <thead>
+          <tr>
+            <th scope="col">{t('csv.date')}</th>
+            <th scope="col">{t('table.time')}</th>
+            <th scope="col">{t('csv.technician')}</th>
+            <th scope="col">{t('client')}</th>
+            <th scope="col">{t('table.vehicle')}</th>
+            <th scope="col">{t('table.job')}</th>
+            <th scope="col">{t('table.source')}</th>
+            <th scope="col" className="num">
+              {t('table.duration')}
+            </th>
+            <th scope="col">{t('table.note')}</th>
+          </tr>
+        </thead>
+        {groups.map((group) => (
+          <tbody key={group.key}>
+            <tr className="group">
+              <th scope="rowgroup" colSpan={7}>
+                {group.label}
+              </th>
+              <th className="num">{formatMinutes(group.minutes)}</th>
+              <th />
+            </tr>
+            {group.entries.map((entry) => (
+              <tr key={entry.id}>
+                <td>{formatDate(entry.startedAt)}</td>
+                <td>
+                  {formatTime(entry.startedAt)} – {endLabel(entry)}
+                </td>
+                <td>{entry.technicianName}</td>
+                <td>{entry.job.clientName}</td>
+                <td>
+                  {[entry.job.vehicleLabel ?? t('table.counterSale'), entry.job.licensePlate]
+                    .filter(Boolean)
+                    .join(' · ')}
+                </td>
+                <td>{entry.job.title}</td>
+                <td>{sourceLabel(entry)}</td>
+                <td className="num">{formatMinutes(entryMinutes(entry, now))}</td>
+                <td className="note">{entry.note}</td>
+              </tr>
+            ))}
+          </tbody>
+        ))}
+        <tfoot>
+          <tr>
+            <th scope="row" colSpan={7}>
+              {t('stats.total')}
+            </th>
+            <th className="num">{formatMinutes(total)}</th>
+            <th />
+          </tr>
+        </tfoot>
+      </table>
+      <div className="sign">
+        <div>{t('csv.date')}</div>
+        <div>{t('signature')}</div>
+      </div>
+    </div>
+  )
+}

+ 48 - 0
src/features/time-tracking/Lib/print-timesheet.ts

@@ -0,0 +1,48 @@
+/** Print a snapshot in an isolated document, without the app's navigation or theme. */
+export function printTimesheet(content: HTMLElement, title: string) {
+  const frame = document.createElement('iframe')
+  frame.setAttribute('aria-hidden', 'true')
+  frame.style.cssText = 'position:fixed;width:0;height:0;border:0;'
+  const snapshot = content.cloneNode(true)
+  const cleanup = () => frame.remove()
+  frame.onload = () => {
+    const doc = frame.contentDocument
+    const view = frame.contentWindow
+    if (!doc || !view) {
+      cleanup()
+      return
+    }
+    doc.title = title
+    doc.documentElement.lang = document.documentElement.lang
+    const style = doc.createElement('style')
+    style.textContent = `
+      @page { size: landscape; margin: 12mm; }
+      body { font: 11px Arial, sans-serif; color: #111; background: #fff; }
+      h1 { font-size: 22px; margin: 0 0 8px; }
+      p { margin: 4px 0; }
+      table { width: 100%; border-collapse: collapse; margin-top: 16px; }
+      th, td { padding: 5px 6px; border-bottom: 1px solid #ccc; text-align: left; overflow-wrap: anywhere; vertical-align: top; }
+      thead { display: table-header-group; }
+      thead th { border-bottom: 2px solid #111; }
+      tr { break-inside: avoid; }
+      tr.group { break-after: avoid; }
+      tr.group th { background: #eee; padding-top: 8px; }
+      tfoot { display: table-row-group; }
+      tfoot th { border-top: 2px solid #111; border-bottom: 0; font-size: 12px; }
+      .num { text-align: right; white-space: nowrap; }
+      .note { white-space: pre-wrap; max-width: 60mm; }
+      .sign { display: flex; gap: 48px; margin-top: 56px; break-inside: avoid; }
+      .sign div { flex: 1; border-top: 1px solid #111; padding-top: 4px; }
+      .sign div:first-child { flex: 0 0 50mm; }
+    `
+    doc.head.append(style)
+    doc.body.append(snapshot)
+    view.addEventListener('afterprint', cleanup, { once: true })
+    view.focus()
+    view.print()
+  }
+  frame.srcdoc = '<!doctype html><html><head></head><body></body></html>'
+  document.body.append(frame)
+  // Also clean up in browsers that do not emit afterprint for an iframe.
+  window.setTimeout(cleanup, 300_000)
+}

+ 8 - 1
src/features/time-tracking/Lib/serialize.ts

@@ -18,7 +18,13 @@ export interface EntryRow {
     title: string
     status: string
     vehicleId: string | null
-    vehicle: { make: string; model: string; licensePlate: string | null } | null
+    customer?: { name: string } | null
+    vehicle: {
+      make: string
+      model: string
+      licensePlate: string | null
+      customer?: { name: string } | null
+    } | null
   }
 }
 
@@ -62,6 +68,7 @@ export async function toSheetEntries(rows: EntryRow[]): Promise<SheetEntry[]> {
         vehicleId: r.serviceRecord.vehicleId,
         vehicleLabel,
         licensePlate: v?.licensePlate ?? null,
+        clientName: v ? (v.customer?.name ?? null) : (r.serviceRecord.customer?.name ?? null),
       },
     }
   })

+ 9 - 1
src/features/time-tracking/Lib/timeEntries.ts

@@ -288,7 +288,15 @@ export const entrySelect = {
       title: true,
       status: true,
       vehicleId: true,
-      vehicle: { select: { make: true, model: true, licensePlate: true } },
+      customer: { select: { name: true } },
+      vehicle: {
+        select: {
+          make: true,
+          model: true,
+          licensePlate: true,
+          customer: { select: { name: true } },
+        },
+      },
     },
   },
 } as const

+ 1 - 0
src/features/time-tracking/Lib/timesheet-csv.ts

@@ -39,6 +39,7 @@ export function timesheetCsv(
       e.source,
       e.editedByName ?? '',
       e.note ?? '',
+      e.job.clientName ?? '',
     ]
   })
 

+ 79 - 0
src/features/time-tracking/Lib/timesheet.ts

@@ -31,9 +31,88 @@ export interface SheetEntry {
     vehicleId: string | null
     vehicleLabel: string | null
     licensePlate: string | null
+    clientName?: string | null
   }
 }
 
+export type TimesheetSort = 'default' | 'client' | 'vehicle' | 'clientVehicle'
+export type SortDirection = 'asc' | 'desc'
+
+/** Sort a copy, with missing clients/vehicles last in either direction. */
+export function sortTimesheetEntries(
+  entries: SheetEntry[],
+  sort: TimesheetSort,
+  direction: SortDirection = 'asc',
+  locale?: string
+): SheetEntry[] {
+  if (sort === 'default') return [...entries]
+  const collator = new Intl.Collator(locale, { sensitivity: 'base', numeric: true })
+  const compare = (a: string | null | undefined, b: string | null | undefined) => {
+    if (!a || !b) return a ? -1 : b ? 1 : 0
+    return collator.compare(a, b) * (direction === 'asc' ? 1 : -1)
+  }
+  return [...entries].sort((a, b) => {
+    const client = sort !== 'vehicle' ? compare(a.job.clientName, b.job.clientName) : 0
+    const vehicle =
+      sort !== 'client'
+        ? compare(a.job.vehicleLabel, b.job.vehicleLabel) ||
+          compare(a.job.licensePlate, b.job.licensePlate)
+        : 0
+    return client || vehicle || b.startedAt.localeCompare(a.startedAt) || a.id.localeCompare(b.id)
+  })
+}
+
+export interface EntryGroup {
+  key: string
+  /** Null when the grouping does not involve that field. */
+  technicianName: string | null
+  clientName: string | null
+  /** Set when the group is one vehicle, or the jobs with none. */
+  vehicle: { label: string | null; licensePlate: string | null } | null
+  entries: SheetEntry[]
+  minutes: number
+}
+
+/**
+ * Entries under the headings a sort implies: one group per client, per
+ * vehicle, or per client's vehicle, and per technician for the default.
+ * Groups come in the order their first entry does, so a sorted list stays
+ * sorted; minutes are whole entries, as the day lists and the CSV count them.
+ */
+export function groupTimesheetEntries(
+  entries: SheetEntry[],
+  sort: TimesheetSort,
+  now: Date
+): EntryGroup[] {
+  const byClient = sort === 'client' || sort === 'clientVehicle'
+  const byVehicle = sort === 'vehicle' || sort === 'clientVehicle'
+  const groups = new Map<string, EntryGroup>()
+  for (const entry of entries) {
+    const { job } = entry
+    const key =
+      sort === 'default'
+        ? entry.technicianId
+        : [byClient ? (job.clientName ?? '') : null, byVehicle ? (job.vehicleId ?? '') : null]
+            .filter((part) => part !== null)
+            .join('\u0000')
+    let group = groups.get(key)
+    if (!group) {
+      group = {
+        key,
+        technicianName: sort === 'default' ? entry.technicianName : null,
+        clientName: byClient ? (job.clientName ?? null) : null,
+        vehicle: byVehicle ? { label: job.vehicleLabel, licensePlate: job.licensePlate } : null,
+        entries: [],
+        minutes: 0,
+      }
+      groups.set(key, group)
+    }
+    group.entries.push(entry)
+    group.minutes += entryMinutes(entry, now)
+  }
+  return [...groups.values()]
+}
+
 export interface SheetTechnician {
   id: string
   name: string