Преглед изворни кода

Remove the classic work order layout (#504)

Bernt Christian Egeland пре 3 дана
родитељ
комит
885c13002e
43 измењених фајлова са 441 додато и 3135 уклоњено
  1. 2 3
      e2e/specs/work-orders/modern-layout.spec.ts
  2. 0 8
      messages/de/service.json
  3. 0 8
      messages/en/service.json
  4. 0 8
      messages/es/service.json
  5. 0 8
      messages/fr/service.json
  6. 0 8
      messages/it/service.json
  7. 0 8
      messages/lt/service.json
  8. 0 8
      messages/nb/service.json
  9. 0 8
      messages/nl/service.json
  10. 0 8
      messages/pl/service.json
  11. 0 8
      messages/pt-BR/service.json
  12. 0 8
      messages/pt-PT/service.json
  13. 0 8
      messages/ru/service.json
  14. 0 8
      messages/tr/service.json
  15. 0 31
      src/__tests__/features/tax/taxable-lines-editor.test.tsx
  16. 0 181
      src/__tests__/features/workorders/invoice-summary.test.tsx
  17. 10 17
      src/__tests__/lib/analytics.test.ts
  18. 7 7
      src/components/section-frame.tsx
  19. 7 7
      src/components/work-order-layout-context.tsx
  20. 0 141
      src/features/vehicles/Components/service-detail/InvoiceSummary.tsx
  21. 0 313
      src/features/vehicles/Components/service-detail/ServiceAttachments.tsx
  22. 0 100
      src/features/vehicles/Components/service-detail/ServiceDetailContent.tsx
  23. 0 332
      src/features/vehicles/Components/service-documents-manager.tsx
  24. 0 119
      src/features/vehicles/Components/service-edit/BasicInfoSection.tsx
  25. 0 302
      src/features/vehicles/Components/service-images-manager.tsx
  26. 0 261
      src/features/vehicles/Components/service-page/DetailsLeftColumn.tsx
  27. 0 231
      src/features/vehicles/Components/service-page/DetailsRightColumn.tsx
  28. 165 0
      src/features/vehicles/Components/service-page/ServiceHeaderActions.tsx
  29. 109 253
      src/features/vehicles/Components/service-page/ServicePageClient.tsx
  30. 0 4
      src/features/vehicles/Components/service-page/ServiceRecordPage.tsx
  31. 0 69
      src/features/vehicles/Components/service-page/TryNewLayoutBanner.tsx
  32. 0 342
      src/features/vehicles/Components/service-page/UnifiedServiceHeader.tsx
  33. 8 9
      src/features/vehicles/Components/service-page/modern/FilesMediaCard.tsx
  34. 5 6
      src/features/vehicles/Components/service-page/modern/JobFactsCard.tsx
  35. 3 3
      src/features/vehicles/Components/service-page/modern/MediaGrid.tsx
  36. 16 28
      src/features/vehicles/Components/service-page/modern/ModernDetails.tsx
  37. 1 1
      src/features/vehicles/Components/service-page/modern/ModernHero.tsx
  38. 98 0
      src/features/vehicles/Components/service-page/modern/details-props.ts
  39. 8 6
      src/features/vehicles/Components/service-page/service-page-types.ts
  40. 0 221
      src/features/vehicles/Components/service-video-manager.tsx
  41. 2 6
      src/lib/analytics.ts
  42. 0 14
      src/lib/work-order-layout.server.ts
  43. 0 24
      src/lib/work-order-layout.ts

+ 2 - 3
e2e/specs/work-orders/modern-layout.spec.ts

@@ -5,7 +5,7 @@ import { addPart, newWorkOrder, saveWorkOrder, seededVehicleUrl } from '../../su
 /**
  * The work order page every browser opens. It is one form, so what these
  * tests hold it to is each field once, and a save that keeps what was on
- * screen. The classic page is still offered in the menu but is not tested.
+ * screen.
  */
 
 test.describe.configure({ mode: 'serial' })
@@ -25,12 +25,11 @@ test.beforeAll(async ({ browser }) => {
 })
 
 test.describe('the work order page', () => {
-  test('opens for a browser that has not chosen a layout', async ({ page }) => {
+  test('opens on the work order', async ({ page }) => {
     await page.goto(jobUrl)
     await settle(page)
 
     await expect(page.getByTestId('service-layout-modern')).toBeVisible()
-    await expect(page.getByTestId('service-layout')).toHaveCount(0)
     // The top of the page is the work order's number and its status, then
     // what the job is called.
     const hero = page.getByTestId('service-hero')

+ 0 - 8
messages/de/service.json

@@ -678,7 +678,6 @@
   "modern": {
     "statusLabel": "Status des Arbeitsauftrags",
     "back": "Zurück",
-    "backToClassic": "Zurück zum klassischen Layout",
     "openCustomer": "Kunde öffnen",
     "serviceHistory": "Servicehistorie",
     "changeVehicle": "Fahrzeug ändern",
@@ -697,13 +696,6 @@
       "paymentTaken": "Zahlung von {amount} aufgezeichnet",
       "statusSet": "Status auf {status} gesetzt"
     },
-    "invite": {
-      "title": "Die Seite für Arbeitsaufträge wurde überarbeitet.",
-      "body": "Ein übersichtlicheres Layout mit denselben Feldern. Sie können jederzeit zurückwechseln.",
-      "try": "Jetzt ausprobieren",
-      "dismiss": "Nicht jetzt",
-      "menu": "Neues Layout ausprobieren"
-    },
     "promised": "Zugesagt für {date}",
     "promisedOverdue": "Zugesagt für {date}, überfällig",
     "opened": "Eröffnet am {date}",

+ 0 - 8
messages/en/service.json

@@ -678,7 +678,6 @@
   "modern": {
     "statusLabel": "Work order status",
     "back": "Back",
-    "backToClassic": "Back to the classic layout",
     "openCustomer": "Open customer",
     "serviceHistory": "Service history",
     "changeVehicle": "Change vehicle",
@@ -697,13 +696,6 @@
       "paymentTaken": "Payment of {amount} recorded",
       "statusSet": "Status set to {status}"
     },
-    "invite": {
-      "title": "The work order page has been overhauled.",
-      "body": "A clearer layout with the same fields. You can switch back at any time.",
-      "try": "Try it now",
-      "dismiss": "Not now",
-      "menu": "Try the new layout"
-    },
     "promised": "Promised {date}",
     "promisedOverdue": "Promised {date}, overdue",
     "opened": "Opened {date}",

+ 0 - 8
messages/es/service.json

@@ -678,7 +678,6 @@
   "modern": {
     "statusLabel": "Estado de la orden de trabajo",
     "back": "Atrás",
-    "backToClassic": "Volver al diseño clásico",
     "openCustomer": "Abrir cliente",
     "serviceHistory": "Historial de servicio",
     "changeVehicle": "Cambiar vehículo",
@@ -697,13 +696,6 @@
       "paymentTaken": "Pago de {amount} registrado",
       "statusSet": "Estado cambiado a {status}"
     },
-    "invite": {
-      "title": "La página de la orden de trabajo se ha renovado.",
-      "body": "Un diseño más claro con los mismos campos. Puedes volver al anterior cuando quieras.",
-      "try": "Probar ahora",
-      "dismiss": "Ahora no",
-      "menu": "Probar el nuevo diseño"
-    },
     "promised": "Prometido el {date}",
     "promisedOverdue": "Prometido el {date}, con retraso",
     "opened": "Abierta el {date}",

+ 0 - 8
messages/fr/service.json

@@ -678,7 +678,6 @@
   "modern": {
     "statusLabel": "Statut de l'ordre de travail",
     "back": "Retour",
-    "backToClassic": "Revenir à la présentation classique",
     "openCustomer": "Ouvrir le client",
     "serviceHistory": "Historique des services",
     "changeVehicle": "Changer de véhicule",
@@ -697,13 +696,6 @@
       "paymentTaken": "Paiement de {amount} enregistré",
       "statusSet": "Statut passé à {status}"
     },
-    "invite": {
-      "title": "La page de l'ordre de travail a été refaite.",
-      "body": "Une présentation plus claire, avec les mêmes champs. Vous pouvez revenir à l'ancienne à tout moment.",
-      "try": "Essayer maintenant",
-      "dismiss": "Plus tard",
-      "menu": "Essayer la nouvelle présentation"
-    },
     "promised": "Promis pour le {date}",
     "promisedOverdue": "Promis pour le {date}, en retard",
     "opened": "Ouvert le {date}",

+ 0 - 8
messages/it/service.json

@@ -678,7 +678,6 @@
   "modern": {
     "statusLabel": "Stato dell'ordine di lavoro",
     "back": "Indietro",
-    "backToClassic": "Torna al layout classico",
     "openCustomer": "Apri cliente",
     "serviceHistory": "Storico interventi",
     "changeVehicle": "Cambia veicolo",
@@ -697,13 +696,6 @@
       "paymentTaken": "Pagamento di {amount} registrato",
       "statusSet": "Stato impostato su {status}"
     },
-    "invite": {
-      "title": "La pagina dell'ordine di lavoro è stata rinnovata.",
-      "body": "Un layout più chiaro con gli stessi campi. Puoi tornare indietro in qualsiasi momento.",
-      "try": "Provalo ora",
-      "dismiss": "Non ora",
-      "menu": "Prova il nuovo layout"
-    },
     "promised": "Promesso per il {date}",
     "promisedOverdue": "Promesso per il {date}, in ritardo",
     "opened": "Aperto il {date}",

+ 0 - 8
messages/lt/service.json

@@ -678,7 +678,6 @@
   "modern": {
     "statusLabel": "Darbo užsakymo būsena",
     "back": "Atgal",
-    "backToClassic": "Grįžti į klasikinį išdėstymą",
     "openCustomer": "Atidaryti klientą",
     "serviceHistory": "Paslaugų istorija",
     "changeVehicle": "Keisti transporto priemonę",
@@ -697,13 +696,6 @@
       "paymentTaken": "Užregistruotas {amount} mokėjimas",
       "statusSet": "Būsena pakeista į {status}"
     },
-    "invite": {
-      "title": "Darbo užsakymo puslapis atnaujintas.",
-      "body": "Aiškesnis išdėstymas, tie patys laukai. Grįžti galite bet kada.",
-      "try": "Išbandyti dabar",
-      "dismiss": "Ne dabar",
-      "menu": "Išbandyti naują išdėstymą"
-    },
     "promised": "Pažadėta {date}",
     "promisedOverdue": "Pažadėta {date}, vėluoja",
     "opened": "Atidaryta {date}",

+ 0 - 8
messages/nb/service.json

@@ -678,7 +678,6 @@
   "modern": {
     "statusLabel": "Status for arbeidsordren",
     "back": "Tilbake",
-    "backToClassic": "Tilbake til den klassiske visningen",
     "openCustomer": "Åpne kunde",
     "serviceHistory": "Servicehistorikk",
     "changeVehicle": "Bytt kjøretøy",
@@ -697,13 +696,6 @@
       "paymentTaken": "Betaling på {amount} registrert",
       "statusSet": "Status satt til {status}"
     },
-    "invite": {
-      "title": "Arbeidsordresiden er fornyet.",
-      "body": "En ryddigere visning med de samme feltene. Du kan bytte tilbake når som helst.",
-      "try": "Prøv nå",
-      "dismiss": "Ikke nå",
-      "menu": "Prøv den nye visningen"
-    },
     "promised": "Lovet {date}",
     "promisedOverdue": "Lovet {date}, forsinket",
     "opened": "Åpnet {date}",

+ 0 - 8
messages/nl/service.json

@@ -678,7 +678,6 @@
   "modern": {
     "statusLabel": "Status van de werkorder",
     "back": "Terug",
-    "backToClassic": "Terug naar de klassieke indeling",
     "openCustomer": "Klant openen",
     "serviceHistory": "Servicegeschiedenis",
     "changeVehicle": "Voertuig wijzigen",
@@ -697,13 +696,6 @@
       "paymentTaken": "Betaling van {amount} geregistreerd",
       "statusSet": "Status gezet op {status}"
     },
-    "invite": {
-      "title": "De werkorderpagina is vernieuwd.",
-      "body": "Een overzichtelijkere indeling met dezelfde velden. U kunt altijd terugschakelen.",
-      "try": "Nu proberen",
-      "dismiss": "Niet nu",
-      "menu": "Nieuwe indeling proberen"
-    },
     "promised": "Toegezegd voor {date}",
     "promisedOverdue": "Toegezegd voor {date}, te laat",
     "opened": "Geopend op {date}",

+ 0 - 8
messages/pl/service.json

@@ -678,7 +678,6 @@
   "modern": {
     "statusLabel": "Status zlecenia",
     "back": "Wstecz",
-    "backToClassic": "Wróć do klasycznego układu",
     "openCustomer": "Otwórz klienta",
     "serviceHistory": "Historia serwisowa",
     "changeVehicle": "Zmień pojazd",
@@ -697,13 +696,6 @@
       "paymentTaken": "Zarejestrowano płatność {amount}",
       "statusSet": "Status ustawiony na {status}"
     },
-    "invite": {
-      "title": "Strona zlecenia została przebudowana.",
-      "body": "Czytelniejszy układ z tymi samymi polami. Możesz wrócić w każdej chwili.",
-      "try": "Wypróbuj teraz",
-      "dismiss": "Nie teraz",
-      "menu": "Wypróbuj nowy układ"
-    },
     "promised": "Obiecano na {date}",
     "promisedOverdue": "Obiecano na {date}, po terminie",
     "opened": "Otwarto {date}",

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

@@ -678,7 +678,6 @@
   "modern": {
     "statusLabel": "Status da ordem de serviço",
     "back": "Voltar",
-    "backToClassic": "Voltar ao layout clássico",
     "openCustomer": "Abrir cliente",
     "serviceHistory": "Histórico de serviços",
     "changeVehicle": "Trocar veículo",
@@ -697,13 +696,6 @@
       "paymentTaken": "Pagamento de {amount} registrado",
       "statusSet": "Status alterado para {status}"
     },
-    "invite": {
-      "title": "A página da ordem de serviço foi reformulada.",
-      "body": "Um layout mais claro com os mesmos campos. Você pode voltar ao anterior a qualquer momento.",
-      "try": "Experimentar agora",
-      "dismiss": "Agora não",
-      "menu": "Experimentar o novo layout"
-    },
     "promised": "Prometida para {date}",
     "promisedOverdue": "Prometida para {date}, atrasada",
     "opened": "Aberta em {date}",

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

@@ -678,7 +678,6 @@
   "modern": {
     "statusLabel": "Estado da ordem de serviço",
     "back": "Voltar",
-    "backToClassic": "Voltar ao layout clássico",
     "openCustomer": "Abrir cliente",
     "serviceHistory": "Histórico de serviços",
     "changeVehicle": "Mudar de viatura",
@@ -697,13 +696,6 @@
       "paymentTaken": "Pagamento de {amount} registado",
       "statusSet": "Estado alterado para {status}"
     },
-    "invite": {
-      "title": "A página da ordem de serviço foi renovada.",
-      "body": "Um layout mais claro com os mesmos campos. Pode voltar ao anterior a qualquer momento.",
-      "try": "Experimentar agora",
-      "dismiss": "Agora não",
-      "menu": "Experimentar o novo layout"
-    },
     "promised": "Prometida para {date}",
     "promisedOverdue": "Prometida para {date}, em atraso",
     "opened": "Aberta a {date}",

+ 0 - 8
messages/ru/service.json

@@ -678,7 +678,6 @@
   "modern": {
     "statusLabel": "Статус заказ-наряда",
     "back": "Назад",
-    "backToClassic": "Вернуться к классическому виду",
     "openCustomer": "Открыть клиента",
     "serviceHistory": "История обслуживания",
     "changeVehicle": "Сменить транспортное средство",
@@ -697,13 +696,6 @@
       "paymentTaken": "Записан платёж {amount}",
       "statusSet": "Статус изменён на {status}"
     },
-    "invite": {
-      "title": "Страница заказ-наряда обновлена.",
-      "body": "Более понятная компоновка с теми же полями. Вернуться можно в любой момент.",
-      "try": "Попробовать",
-      "dismiss": "Не сейчас",
-      "menu": "Попробовать новый вид"
-    },
     "promised": "Обещано {date}",
     "promisedOverdue": "Обещано {date}, просрочено",
     "opened": "Открыт {date}",

+ 0 - 8
messages/tr/service.json

@@ -678,7 +678,6 @@
   "modern": {
     "statusLabel": "İş emri durumu",
     "back": "Geri",
-    "backToClassic": "Klasik düzene dön",
     "openCustomer": "Müşteriyi aç",
     "serviceHistory": "Servis geçmişi",
     "changeVehicle": "Aracı değiştir",
@@ -697,13 +696,6 @@
       "paymentTaken": "{amount} tutarında ödeme kaydedildi",
       "statusSet": "Durum {status} olarak ayarlandı"
     },
-    "invite": {
-      "title": "İş emri sayfası yenilendi.",
-      "body": "Aynı alanlarla daha net bir düzen. İstediğiniz zaman eskisine dönebilirsiniz.",
-      "try": "Şimdi dene",
-      "dismiss": "Şimdi değil",
-      "menu": "Yeni düzeni dene"
-    },
     "promised": "{date} için söz verildi",
     "promisedOverdue": "{date} için söz verildi, gecikti",
     "opened": "{date} tarihinde açıldı",

+ 0 - 31
src/__tests__/features/tax/taxable-lines-editor.test.tsx

@@ -14,7 +14,6 @@ import { describe, expect, it, vi } from 'vitest'
 import { TooltipProvider } from '@/components/ui/tooltip'
 import { LineTaxProvider } from '@/features/settings/Components/LineTaxContext'
 import { LineTaxToggle } from '@/features/settings/Components/LineTaxToggle'
-import { InvoiceSummary } from '@/features/vehicles/Components/service-detail/InvoiceSummary'
 import { LaborEditor } from '@/features/vehicles/Components/service-edit/LaborEditor'
 import { PartsEditor } from '@/features/vehicles/Components/service-edit/PartsEditor'
 import { TotalsSection } from '@/features/vehicles/Components/service-edit/TotalsSection'
@@ -341,34 +340,4 @@ describe('the row that says what the tax was charged on', () => {
     totals(null)
     expect(screen.queryByTestId('taxable-amount-row')).toBeNull()
   })
-
-  it('is on the invoice summary too, and the summary takes tax out of taxed lines only', () => {
-    render(
-      <InvoiceSummary
-        hasPartItems
-        hasLaborItems
-        // Prices entered with tax in them: 216 of taxed parts, 300 of untaxed labor.
-        partsSubtotal={216}
-        laborSubtotal={300}
-        subtotal={516}
-        discountAmount={0}
-        discountType={null}
-        discountValue={0}
-        taxRate={8}
-        taxAmount={16}
-        taxInclusive
-        taxableShare={{ parts: 216, labor: 0, subtotal: 216, taxableAmount: 200 }}
-        displayTotal={516}
-        totalPaid={0}
-        balanceDue={516}
-        hasPayments={false}
-        currencyCode="USD"
-      />
-    )
-    expect(screen.getByTestId('taxable-amount-row')).toHaveTextContent('200.00')
-    // Labor reads 300, not 277.78: there was never any tax in it.
-    expect(screen.getByText('Labor').parentElement).toHaveTextContent('300.00')
-    expect(screen.getByText('Parts').parentElement).toHaveTextContent('200.00')
-    expect(screen.getByText('Subtotal').parentElement).toHaveTextContent('500.00')
-  })
 })

+ 0 - 181
src/__tests__/features/workorders/invoice-summary.test.tsx

@@ -1,181 +0,0 @@
-/**
- * Tests for InvoiceSummary — the live preview pane on the work order edit page.
- *
- * Critical behaviour: this component must use the universal display (net per
- * category, net subtotal, separate tax line, gross total) regardless of
- * whether the record was entered in inclusive or exclusive mode.
- */
-
-import { describe, it, expect, vi } from 'vitest'
-import { render, screen } from '@testing-library/react'
-import { NextIntlClientProvider } from 'next-intl'
-
-vi.mock('lucide-react', () => ({}))
-
-import { InvoiceSummary } from '@/features/vehicles/Components/service-detail/InvoiceSummary'
-
-const messages = {
-  service: {
-    invoice: {
-      title: 'Invoice Summary',
-      parts: 'Parts',
-      labor: 'Labor',
-      subtotal: 'Subtotal',
-      subtotalInclTax: 'Subtotal (incl. tax)',
-      discount: 'Discount',
-      tax: 'Tax ({rate}%)',
-      taxIncluded: 'Tax (incl., {rate}%)',
-      total: 'Total',
-      paid: 'Paid',
-      balanceDue: 'Balance Due',
-      paidBadge: 'PAID',
-      entityLabel: 'Invoice',
-      emailSubject: '',
-      statusEmailSubject: '',
-    },
-  },
-}
-
-function renderSummary(props: Parameters<typeof InvoiceSummary>[0]) {
-  return render(
-    <NextIntlClientProvider locale="en" messages={messages as any}>
-      <InvoiceSummary {...props} />
-    </NextIntlClientProvider>
-  )
-}
-
-describe('InvoiceSummary — universal display', () => {
-  it('shows net values + tax line + gross total for an exclusive record', () => {
-    renderSummary({
-      hasPartItems: true,
-      hasLaborItems: true,
-      partsSubtotal: 100,
-      laborSubtotal: 200,
-      subtotal: 300,
-      discountAmount: 0,
-      discountType: null,
-      discountValue: 0,
-      taxRate: 25,
-      taxAmount: 75,
-      taxInclusive: false,
-      displayTotal: 375,
-      totalPaid: 0,
-      balanceDue: 375,
-      hasPayments: false,
-      currencyCode: 'USD',
-    })
-
-    // Net subtotal 300, tax 75, total 375
-    expect(screen.getAllByText(/300\.00/).length).toBeGreaterThanOrEqual(1)
-    expect(screen.getAllByText(/75\.00/).length).toBeGreaterThanOrEqual(1)
-    expect(screen.getAllByText(/375\.00/).length).toBeGreaterThanOrEqual(1)
-    expect(screen.getByText('Tax (25%)')).toBeInTheDocument()
-  })
-
-  it('back-calculates net values for an inclusive record but shows the same gross total', () => {
-    renderSummary({
-      hasPartItems: true,
-      hasLaborItems: true,
-      // Stored gross subtotals (the equivalent of the exclusive case scaled by 1.25)
-      partsSubtotal: 125,
-      laborSubtotal: 250,
-      subtotal: 375,
-      discountAmount: 0,
-      discountType: null,
-      discountValue: 0,
-      taxRate: 25,
-      taxAmount: 75,
-      taxInclusive: true,
-      displayTotal: 375, // gross customer total
-      totalPaid: 0,
-      balanceDue: 375,
-      hasPayments: false,
-      currencyCode: 'USD',
-    })
-
-    // Should display net parts (100), net labor (200), net subtotal (300),
-    // separate tax line (75), gross total (375).
-    expect(screen.getAllByText(/100\.00/).length).toBeGreaterThanOrEqual(1)
-    expect(screen.getAllByText(/200\.00/).length).toBeGreaterThanOrEqual(1)
-    expect(screen.getAllByText(/300\.00/).length).toBeGreaterThanOrEqual(1)
-    expect(screen.getAllByText(/75\.00/).length).toBeGreaterThanOrEqual(1)
-    expect(screen.getAllByText(/375\.00/).length).toBeGreaterThanOrEqual(1)
-    // The tax line label is universal — NOT "Tax (incl., 25%)"
-    expect(screen.getByText('Tax (25%)')).toBeInTheDocument()
-  })
-
-  it('nets the discount line in inclusive mode', () => {
-    renderSummary({
-      hasPartItems: true,
-      hasLaborItems: false,
-      partsSubtotal: 250,
-      laborSubtotal: 0,
-      subtotal: 250, // gross
-      discountAmount: 25, // gross discount
-      discountType: 'fixed',
-      discountValue: 25,
-      taxRate: 25,
-      taxAmount: 45,
-      taxInclusive: true,
-      displayTotal: 225, // gross customer total after discount
-      totalPaid: 0,
-      balanceDue: 225,
-      hasPayments: false,
-      currencyCode: 'USD',
-    })
-
-    // Net values: subtotal 200, discount 20, tax 45, total 225
-    expect(screen.getAllByText(/200\.00/).length).toBeGreaterThanOrEqual(1)
-    expect(screen.getAllByText(/20\.00/).length).toBeGreaterThanOrEqual(1)
-    expect(screen.getAllByText(/45\.00/).length).toBeGreaterThanOrEqual(1)
-    expect(screen.getAllByText(/225\.00/).length).toBeGreaterThanOrEqual(1)
-  })
-
-  it('equivalent invoices show identical visible numbers in both modes', () => {
-    // Render exclusive
-    const { unmount } = renderSummary({
-      hasPartItems: true,
-      hasLaborItems: false,
-      partsSubtotal: 100,
-      laborSubtotal: 0,
-      subtotal: 100,
-      discountAmount: 0,
-      discountType: null,
-      discountValue: 0,
-      taxRate: 25,
-      taxAmount: 25,
-      taxInclusive: false,
-      displayTotal: 125,
-      totalPaid: 0,
-      balanceDue: 125,
-      hasPayments: false,
-      currencyCode: 'USD',
-    })
-    const exclusiveText = screen.getAllByText(/100\.00|25\.00|125\.00/).map((el) => el.textContent)
-    unmount()
-
-    // Render the equivalent inclusive
-    renderSummary({
-      hasPartItems: true,
-      hasLaborItems: false,
-      partsSubtotal: 125,
-      laborSubtotal: 0,
-      subtotal: 125,
-      discountAmount: 0,
-      discountType: null,
-      discountValue: 0,
-      taxRate: 25,
-      taxAmount: 25,
-      taxInclusive: true,
-      displayTotal: 125,
-      totalPaid: 0,
-      balanceDue: 125,
-      hasPayments: false,
-      currencyCode: 'USD',
-    })
-    const inclusiveText = screen.getAllByText(/100\.00|25\.00|125\.00/).map((el) => el.textContent)
-
-    // Both modes should display the same set of monetary values.
-    expect(inclusiveText.sort()).toEqual(exclusiveText.sort())
-  })
-})

+ 10 - 17
src/__tests__/lib/analytics.test.ts

@@ -31,15 +31,15 @@ describe('analytics', () => {
   it('holds events until PostHog has started, then sends them in order', async () => {
     const { track, registerAnalyticsProperties, analyticsStarted } = await freshAnalytics()
 
-    registerAnalyticsProperties({ work_order_layout: 'modern' })
-    track('work_order:page_view', { layout: 'modern' })
+    registerAnalyticsProperties({ setup_step: 'workshop' })
+    track('workshop:setup_start')
     expect(posthog.capture).not.toHaveBeenCalled()
     expect(posthog.register).not.toHaveBeenCalled()
 
     analyticsStarted()
-    expect(posthog.register).toHaveBeenCalledWith({ work_order_layout: 'modern' })
-    expect(posthog.capture).toHaveBeenCalledWith('work_order:page_view', { layout: 'modern' })
-    // The layout is registered before the view is sent, so the view carries it.
+    expect(posthog.register).toHaveBeenCalledWith({ setup_step: 'workshop' })
+    expect(posthog.capture).toHaveBeenCalledWith('workshop:setup_start', undefined)
+    // The step is registered before the event is sent, so the event carries it.
     expect(posthog.register.mock.invocationCallOrder[0]).toBeLessThan(
       posthog.capture.mock.invocationCallOrder[0]
     )
@@ -47,27 +47,20 @@ describe('analytics', () => {
 
   it('sends straight away once PostHog has started, and sends nothing twice', async () => {
     const { track, analyticsStarted } = await freshAnalytics()
-    track('work_order:layout_invite_dismiss')
+    track('customer:create')
     analyticsStarted()
-    track('work_order:layout_switch', {
-      from_layout: 'classic',
-      to_layout: 'modern',
-      source: 'banner',
-    })
+    track('document:email_send', { kind: 'invoice' })
     analyticsStarted()
 
     expect(posthog.capture.mock.calls).toEqual([
-      ['work_order:layout_invite_dismiss', undefined],
-      [
-        'work_order:layout_switch',
-        { from_layout: 'classic', to_layout: 'modern', source: 'banner' },
-      ],
+      ['customer:create', undefined],
+      ['document:email_send', { kind: 'invoice' }],
     ])
   })
 
   it('sends nothing where PostHog never starts, and holds only a few events', async () => {
     const { track, analyticsStarted } = await freshAnalytics()
-    for (let i = 0; i < 100; i++) track('work_order:page_view', { layout: 'classic' })
+    for (let i = 0; i < 100; i++) track('work_order:page_view')
     expect(posthog.capture).not.toHaveBeenCalled()
 
     // Were it ever to start, the backlog is capped rather than a hundred views.

+ 7 - 7
src/components/section-frame.tsx

@@ -5,13 +5,13 @@ import { AppCard } from '@/components/app-card'
 import { useModernWorkOrder } from '@/components/work-order-layout-context'
 
 /**
- * One section, two frames. On the classic work order page (and on the quote
- * page, and anywhere else with no layout provider) it draws exactly what the
- * section drew before: the caller's own wrapper classes and its own header,
- * untouched. On the overhauled page the same children sit in the house card.
+ * One section, two frames. On the quote page, and anywhere else with no
+ * layout provider, it draws the caller's own wrapper classes and its own
+ * header, untouched. On the work order page the same children sit in the
+ * house card.
  *
  * The children are the same nodes either way, which is the point: a field
- * lives in one component, so both layouts save the same form.
+ * lives in one component, so both pages save the same form.
  */
 export function SectionFrame({
   className,
@@ -26,9 +26,9 @@ export function SectionFrame({
   testId,
   children,
 }: {
-  /** The classic wrapper's classes, as they were. */
+  /** The wrapper's classes outside the work order page. */
   className: string
-  /** The classic header, as it was. */
+  /** The header outside the work order page. */
   header: ReactNode
   icon?: ComponentType<{ className?: string }>
   title: ReactNode

+ 7 - 7
src/components/work-order-layout-context.tsx

@@ -1,18 +1,18 @@
 'use client'
 
 import { createContext, useContext } from 'react'
-import type { WorkOrderLayout } from '@/lib/work-order-layout'
 
 /**
- * The sections of the work order page are shared by both layouts, and by the
- * quote page, so they ask here which frame to draw. Outside a provider the
- * answer is always classic: the quote page and every other host keep the look
- * they have.
+ * Whether the sections are being drawn on the work order page.
+ *
+ * The sections of the work order page are shared with the quote page, so
+ * they ask here which frame to draw. Outside a provider the answer is no:
+ * the quote page and every other host keep the look they have.
  */
-const WorkOrderLayoutContext = createContext<WorkOrderLayout>('classic')
+const WorkOrderLayoutContext = createContext(false)
 
 export const WorkOrderLayoutProvider = WorkOrderLayoutContext.Provider
 
 export function useModernWorkOrder(): boolean {
-  return useContext(WorkOrderLayoutContext) === 'modern'
+  return useContext(WorkOrderLayoutContext)
 }

+ 0 - 141
src/features/vehicles/Components/service-detail/InvoiceSummary.tsx

@@ -1,141 +0,0 @@
-'use client'
-
-import { useTranslations } from 'next-intl'
-import { Separator } from '@/components/ui/separator'
-import { useFormatCurrency } from '@/components/currency-settings-context'
-import { taxDisplay, type TaxComponent } from '@/lib/tax'
-import type { TaxableShare } from '@/lib/taxable-lines'
-import { taxComponentLabel } from '@/lib/tax-components'
-
-interface InvoiceSummaryProps {
-  hasPartItems: boolean
-  hasLaborItems: boolean
-  partsSubtotal: number
-  laborSubtotal: number
-  subtotal: number
-  discountAmount: number
-  discountType: string | null
-  discountValue: number
-  taxRate: number
-  taxAmount: number
-  taxInclusive: boolean
-  taxComponents?: TaxComponent[] | null
-  /** Set when some lines are left out of the tax; adds the "Taxable amount" row. */
-  taxableShare?: TaxableShare | null
-  displayTotal: number
-  totalPaid: number
-  balanceDue: number
-  hasPayments: boolean
-  currencyCode: string
-}
-
-export function InvoiceSummary({
-  hasPartItems,
-  hasLaborItems,
-  partsSubtotal,
-  laborSubtotal,
-  subtotal,
-  discountAmount,
-  discountType,
-  discountValue,
-  taxRate,
-  taxAmount,
-  taxInclusive,
-  taxComponents,
-  taxableShare = null,
-  displayTotal,
-  totalPaid,
-  balanceDue,
-  hasPayments,
-  currencyCode,
-}: InvoiceSummaryProps) {
-  const formatCurrency = useFormatCurrency()
-  const t = useTranslations('service.invoice')
-
-  // Universal display: net per category, net subtotal, net discount, tax, gross total.
-  // No-op for exclusive records; back-calculates the net portions for inclusive records.
-  // A line left out of the tax holds none, so only the taxable part of each
-  // figure is taken net; with every line taxed that is the whole figure.
-  const display = taxDisplay({ taxRate, taxInclusive })
-  const displayPartsSubtotal = display.mixed(partsSubtotal, taxableShare?.parts)
-  const displayLaborSubtotal = display.mixed(laborSubtotal, taxableShare?.labor)
-  const displaySubtotal = display.mixed(subtotal, taxableShare?.subtotal)
-  const displayDiscountAmount = display.discount(discountAmount, subtotal, taxableShare?.subtotal)
-
-  return (
-    <div className="rounded-lg border p-3">
-      <h3 className="mb-2 text-sm font-semibold">{t('title')}</h3>
-      <div className="space-y-1.5">
-        {hasPartItems && (
-          <div className="flex justify-between text-sm">
-            <span className="text-muted-foreground">{t('parts')}</span>
-            <span>{formatCurrency(displayPartsSubtotal, currencyCode)}</span>
-          </div>
-        )}
-        {hasLaborItems && (
-          <div className="flex justify-between text-sm">
-            <span className="text-muted-foreground">{t('labor')}</span>
-            <span>{formatCurrency(displayLaborSubtotal, currencyCode)}</span>
-          </div>
-        )}
-        {displaySubtotal > 0 &&
-          ((hasPartItems && hasLaborItems) || displayDiscountAmount > 0 || taxRate > 0) && (
-            <div className="flex justify-between text-sm">
-              <span className="text-muted-foreground">{t('subtotal')}</span>
-              <span>{formatCurrency(displaySubtotal, currencyCode)}</span>
-            </div>
-          )}
-        {displayDiscountAmount > 0 && (
-          <div className="flex justify-between text-sm">
-            <span className="text-muted-foreground">
-              {t('discount')}
-              {discountType === 'percentage' ? ` (${discountValue}%)` : ''}
-            </span>
-            <span className="text-destructive">
-              {formatCurrency(-displayDiscountAmount, currencyCode)}
-            </span>
-          </div>
-        )}
-        {taxableShare && taxRate > 0 && (
-          <div className="flex justify-between text-sm" data-testid="taxable-amount-row">
-            <span className="text-muted-foreground">{t('taxableAmount')}</span>
-            <span>{formatCurrency(taxableShare.taxableAmount, currencyCode)}</span>
-          </div>
-        )}
-        {taxComponents && taxComponents.length > 0 ? (
-          taxComponents.map((component) => (
-            <div key={component.name} className="flex justify-between text-sm">
-              <span className="text-muted-foreground">{taxComponentLabel(component)}</span>
-              <span>{formatCurrency(component.amount, currencyCode)}</span>
-            </div>
-          ))
-        ) : taxRate > 0 ? (
-          <div className="flex justify-between text-sm">
-            <span className="text-muted-foreground">{t('tax', { rate: taxRate })}</span>
-            <span>{formatCurrency(taxAmount, currencyCode)}</span>
-          </div>
-        ) : null}
-        <Separator />
-        <div className="flex justify-between font-bold">
-          <span>{t('total')}</span>
-          <span>{formatCurrency(displayTotal, currencyCode)}</span>
-        </div>
-        {hasPayments && (
-          <>
-            <div className="flex justify-between text-sm text-emerald-600">
-              <span>{t('paid')}</span>
-              <span>{formatCurrency(-totalPaid, currencyCode)}</span>
-            </div>
-            <Separator />
-            <div className="flex justify-between font-bold">
-              <span>{t('balanceDue')}</span>
-              <span className={balanceDue <= 0 ? 'text-emerald-600' : ''}>
-                {balanceDue <= 0 ? t('paidBadge') : formatCurrency(balanceDue, currencyCode)}
-              </span>
-            </div>
-          </>
-        )}
-      </div>
-    </div>
-  )
-}

+ 0 - 313
src/features/vehicles/Components/service-detail/ServiceAttachments.tsx

@@ -1,313 +0,0 @@
-'use client'
-
-import { useState } from 'react'
-import { useTranslations } from 'next-intl'
-import { Button } from '@/components/ui/button'
-import {
-  Camera,
-  Download,
-  FileText,
-  Image as ImageIcon,
-  Loader2,
-  Paperclip,
-  Play,
-  Trash2,
-  Video,
-  X,
-} from 'lucide-react'
-import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
-import { formatFileSize } from './types'
-import type { Attachment } from './types'
-
-function getFileIcon(type: string) {
-  if (type === 'application/pdf') return <FileText className="h-5 w-5 text-red-500" />
-  if (type.startsWith('image/')) return <ImageIcon className="h-5 w-5 text-blue-500" />
-  return <Paperclip className="h-5 w-5 text-muted-foreground" />
-}
-
-function AttachmentRow({
-  attachment,
-  onDelete,
-  deleting,
-  tDownload,
-  tDelete,
-}: {
-  attachment: Attachment
-  onDelete: (id: string) => void
-  deleting: boolean
-  tDownload: string
-  tDelete: string
-}) {
-  return (
-    <div className="grid grid-cols-[2rem_1fr_auto] items-center gap-2 rounded border p-2">
-      <div className="flex h-8 w-8 items-center justify-center rounded bg-muted/50">
-        {getFileIcon(attachment.fileType)}
-      </div>
-      <div className="overflow-hidden">
-        <p className="truncate text-xs font-medium" title={attachment.fileName}>
-          {attachment.fileName}
-        </p>
-        <p className="text-xs text-muted-foreground">{formatFileSize(attachment.fileSize)}</p>
-      </div>
-      <div className="flex items-center gap-0.5">
-        <Tooltip>
-          <TooltipTrigger asChild>
-            <Button variant="ghost" size="icon" className="h-6 w-6" asChild>
-              <a href={attachment.fileUrl} download={attachment.fileName} aria-label={tDownload}>
-                <Download className="h-3 w-3" />
-              </a>
-            </Button>
-          </TooltipTrigger>
-          <TooltipContent>{tDownload}</TooltipContent>
-        </Tooltip>
-        <Tooltip>
-          <TooltipTrigger asChild>
-            <Button
-              variant="ghost"
-              size="icon"
-              className="h-6 w-6 text-muted-foreground hover:text-destructive"
-              disabled={deleting}
-              onClick={() => onDelete(attachment.id)}
-              aria-label={tDelete}
-            >
-              {deleting ? (
-                <Loader2 className="h-3 w-3 animate-spin" />
-              ) : (
-                <Trash2 className="h-3 w-3" />
-              )}
-            </Button>
-          </TooltipTrigger>
-          <TooltipContent>{tDelete}</TooltipContent>
-        </Tooltip>
-      </div>
-    </div>
-  )
-}
-
-interface ServiceAttachmentsProps {
-  attachments: Attachment[]
-  imageAttachments: Attachment[]
-  onImageClick: (index: number) => void
-  onDeleteAttachment: (id: string) => void
-  deletingAttachment: string | null
-}
-
-export function ServiceAttachments({
-  attachments,
-  imageAttachments,
-  onImageClick,
-  onDeleteAttachment,
-  deletingAttachment,
-}: ServiceAttachmentsProps) {
-  const t = useTranslations('service.attachments')
-  const tButtons = useTranslations('common.buttons')
-  const tDownload = t('download')
-  const tDelete = tButtons('delete')
-  const tClose = t('close')
-  const diagnostics = attachments.filter((a) => a.category === 'diagnostic')
-  const documents = attachments.filter((a) => a.category === 'document')
-  const videos = attachments.filter((a) => a.category === 'video')
-  const [activeVideo, setActiveVideo] = useState<Attachment | null>(null)
-
-  return (
-    <TooltipProvider>
-      <>
-        {imageAttachments.length > 0 && (
-          <div className="rounded-lg border p-3">
-            <h3 className="mb-2 flex items-center gap-2 text-sm font-semibold">
-              <Camera className="h-3.5 w-3.5" />
-              {t('images', { count: imageAttachments.length })}
-            </h3>
-            <div className="grid grid-cols-3 gap-2">
-              {imageAttachments.map((attachment, idx) => (
-                <div key={attachment.id} className="group overflow-hidden rounded border">
-                  <div className="relative">
-                    <button
-                      type="button"
-                      onClick={() => onImageClick(idx)}
-                      className="block w-full cursor-zoom-in"
-                    >
-                      <img
-                        src={attachment.fileUrl}
-                        alt={attachment.description || attachment.fileName}
-                        className="aspect-square w-full object-cover"
-                      />
-                    </button>
-                    <div className="absolute right-0.5 top-0.5 flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
-                      <Tooltip>
-                        <TooltipTrigger asChild>
-                          <Button variant="secondary" size="icon" className="h-5 w-5" asChild>
-                            <a
-                              href={attachment.fileUrl}
-                              download={attachment.fileName}
-                              aria-label={tDownload}
-                            >
-                              <Download className="h-2.5 w-2.5" />
-                            </a>
-                          </Button>
-                        </TooltipTrigger>
-                        <TooltipContent>{tDownload}</TooltipContent>
-                      </Tooltip>
-                      <Tooltip>
-                        <TooltipTrigger asChild>
-                          <Button
-                            variant="secondary"
-                            size="icon"
-                            className="h-5 w-5 hover:text-destructive"
-                            disabled={deletingAttachment === attachment.id}
-                            onClick={() => onDeleteAttachment(attachment.id)}
-                            aria-label={tDelete}
-                          >
-                            {deletingAttachment === attachment.id ? (
-                              <Loader2 className="h-2.5 w-2.5 animate-spin" />
-                            ) : (
-                              <Trash2 className="h-2.5 w-2.5" />
-                            )}
-                          </Button>
-                        </TooltipTrigger>
-                        <TooltipContent>{tDelete}</TooltipContent>
-                      </Tooltip>
-                    </div>
-                  </div>
-                </div>
-              ))}
-            </div>
-          </div>
-        )}
-
-        {videos.length > 0 && (
-          <div className="min-w-0 overflow-hidden rounded-lg border p-3">
-            <h3 className="mb-2 flex items-center gap-2 text-sm font-semibold">
-              <Video className="h-3.5 w-3.5" />
-              {t('videos', { count: videos.length })}
-            </h3>
-            <div className="space-y-1.5">
-              {videos.map((a) => (
-                <div
-                  key={a.id}
-                  className="grid grid-cols-[2rem_1fr_auto] items-center gap-2 rounded border p-2"
-                >
-                  <button
-                    type="button"
-                    onClick={() => setActiveVideo(a)}
-                    className="flex h-8 w-8 items-center justify-center rounded bg-muted/50 transition-colors hover:bg-muted"
-                  >
-                    <Play className="h-4 w-4 text-primary" />
-                  </button>
-                  <button
-                    type="button"
-                    onClick={() => setActiveVideo(a)}
-                    className="overflow-hidden text-left"
-                  >
-                    <p className="truncate text-xs font-medium" title={a.fileName}>
-                      {a.fileName}
-                    </p>
-                    <p className="text-xs text-muted-foreground">{formatFileSize(a.fileSize)}</p>
-                  </button>
-                  <div className="flex items-center gap-0.5">
-                    <Tooltip>
-                      <TooltipTrigger asChild>
-                        <Button variant="ghost" size="icon" className="h-6 w-6" asChild>
-                          <a href={a.fileUrl} download={a.fileName} aria-label={tDownload}>
-                            <Download className="h-3 w-3" />
-                          </a>
-                        </Button>
-                      </TooltipTrigger>
-                      <TooltipContent>{tDownload}</TooltipContent>
-                    </Tooltip>
-                    <Tooltip>
-                      <TooltipTrigger asChild>
-                        <Button
-                          variant="ghost"
-                          size="icon"
-                          className="h-6 w-6 text-muted-foreground hover:text-destructive"
-                          disabled={deletingAttachment === a.id}
-                          onClick={() => onDeleteAttachment(a.id)}
-                          aria-label={tDelete}
-                        >
-                          {deletingAttachment === a.id ? (
-                            <Loader2 className="h-3 w-3 animate-spin" />
-                          ) : (
-                            <Trash2 className="h-3 w-3" />
-                          )}
-                        </Button>
-                      </TooltipTrigger>
-                      <TooltipContent>{tDelete}</TooltipContent>
-                    </Tooltip>
-                  </div>
-                </div>
-              ))}
-            </div>
-          </div>
-        )}
-
-        {diagnostics.length > 0 && (
-          <div className="min-w-0 overflow-hidden rounded-lg border p-3">
-            <h3 className="mb-2 flex items-center gap-2 text-sm font-semibold">
-              <FileText className="h-3.5 w-3.5" />
-              {t('diagnostics', { count: diagnostics.length })}
-            </h3>
-            <div className="space-y-1.5">
-              {diagnostics.map((a) => (
-                <AttachmentRow
-                  key={a.id}
-                  attachment={a}
-                  onDelete={onDeleteAttachment}
-                  deleting={deletingAttachment === a.id}
-                  tDownload={tDownload}
-                  tDelete={tDelete}
-                />
-              ))}
-            </div>
-          </div>
-        )}
-
-        {documents.length > 0 && (
-          <div className="min-w-0 overflow-hidden rounded-lg border p-3">
-            <h3 className="mb-2 flex items-center gap-2 text-sm font-semibold">
-              <Paperclip className="h-3.5 w-3.5" />
-              {t('documents', { count: documents.length })}
-            </h3>
-            <div className="space-y-1.5">
-              {documents.map((a) => (
-                <AttachmentRow
-                  key={a.id}
-                  attachment={a}
-                  onDelete={onDeleteAttachment}
-                  deleting={deletingAttachment === a.id}
-                  tDownload={tDownload}
-                  tDelete={tDelete}
-                />
-              ))}
-            </div>
-          </div>
-        )}
-
-        {/* Fullscreen video player */}
-        {activeVideo && (
-          <div
-            className="fixed inset-0 z-50 flex items-center justify-center bg-black/90"
-            onClick={() => setActiveVideo(null)}
-          >
-            <Button
-              variant="ghost"
-              size="icon"
-              className="absolute right-4 top-4 h-10 w-10 text-white hover:bg-white/10"
-              onClick={() => setActiveVideo(null)}
-              aria-label={tClose}
-            >
-              <X className="h-6 w-6" />
-            </Button>
-            <video
-              src={activeVideo.fileUrl}
-              controls
-              autoPlay
-              className="max-h-[90vh] max-w-[90vw] rounded-lg"
-              onClick={(e) => e.stopPropagation()}
-            />
-          </div>
-        )}
-      </>
-    </TooltipProvider>
-  )
-}

+ 0 - 100
src/features/vehicles/Components/service-detail/ServiceDetailContent.tsx

@@ -1,100 +0,0 @@
-'use client'
-
-import { useEffect, useState } from 'react'
-
-const SIDEBAR_MIN_W = 280
-
-interface ServiceDetailContentProps {
-  leftColumn: React.ReactNode
-  rightColumn: React.ReactNode
-}
-
-/**
- * The layout shell of the work order page: the job on the left, the sidebar on
- * the right, stacked on a narrow screen.
- *
- * Both columns are rendered **once**. They used to be rendered twice, one
- * layer per breakpoint with the other hidden, which put two of every field in
- * the document under the same id and name. The form then submitted the hidden
- * copy's values, native validation objected to controls the browser would not
- * focus and abandoned the submit in silence, and every row was mounted and
- * hydrated twice. One copy, and CSS decides where it sits.
- *
- * The height still comes from the box and never from the content. The single
- * layer is `position: absolute; inset: 0`, so its size is dictated by this
- * relative parent; from `lg` it becomes the grid and each column scrolls
- * inside its own track, whose height is pinned by `minmax(0, 1fr)`. That is
- * what sidesteps the `min-height: auto` flex-item rule which once let tall
- * sidebar content push the body past the viewport — an auto grid row would
- * bring it straight back.
- */
-export function ServiceDetailContent({ leftColumn, rightColumn }: ServiceDetailContentProps) {
-  const [sidebarWidth, setSidebarWidth] = useState<number | null>(null)
-  const [isDragging, setIsDragging] = useState(false)
-
-  useEffect(() => {
-    if (!isDragging) return
-    const onMouseMove = (e: MouseEvent) => {
-      const newWidth = window.innerWidth - e.clientX
-      setSidebarWidth(Math.max(SIDEBAR_MIN_W, Math.min(newWidth, window.innerWidth * 0.6)))
-    }
-    const onMouseUp = () => setIsDragging(false)
-    document.body.style.cursor = 'col-resize'
-    document.body.style.userSelect = 'none'
-    window.addEventListener('mousemove', onMouseMove)
-    window.addEventListener('mouseup', onMouseUp)
-    return () => {
-      document.body.style.cursor = ''
-      document.body.style.userSelect = ''
-      window.removeEventListener('mousemove', onMouseMove)
-      window.removeEventListener('mouseup', onMouseUp)
-    }
-  }, [isDragging])
-
-  const rightColTrack =
-    sidebarWidth != null ? `${sidebarWidth}px` : `minmax(${SIDEBAR_MIN_W}px, 22vw)`
-
-  return (
-    <div className="relative min-h-0 flex-1">
-      {/* Below lg: this layer is the one scroller and the columns stack inside
-          it. From lg: a three-track grid (job | handle | sidebar), each column
-          scrolling in its own track. The grid properties are inert while the
-          layer is a flex column. */}
-      <div
-        data-testid="service-layout"
-        className="absolute inset-0 flex flex-col gap-3 overflow-y-auto overscroll-contain p-4 pb-40 lg:grid lg:gap-0 lg:overflow-hidden lg:p-0 lg:pb-0"
-        style={{
-          gridTemplateColumns: `minmax(0, 1fr) 6px ${rightColTrack}`,
-          gridTemplateRows: 'minmax(0, 1fr)',
-        }}
-      >
-        <div
-          data-testid="service-main"
-          className="space-y-3 lg:h-full lg:min-h-0 lg:overflow-y-auto lg:overscroll-contain lg:p-4 lg:pr-2 lg:pb-40"
-        >
-          {leftColumn}
-        </div>
-
-        {/* Nothing to drag while the columns are stacked. */}
-        <div
-          data-testid="service-resize"
-          className="relative hidden cursor-col-resize bg-border transition-colors hover:bg-primary/30 lg:block"
-          onMouseDown={() => setIsDragging(true)}
-        >
-          <div className="absolute top-1/2 left-1/2 flex h-8 w-4 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-sm border bg-background shadow-sm">
-            <svg width="6" height="14" viewBox="0 0 6 14" className="text-muted-foreground">
-              <path d="M1 0v14M5 0v14" stroke="currentColor" strokeWidth="1" />
-            </svg>
-          </div>
-        </div>
-
-        <div
-          data-testid="service-sidebar"
-          className="space-y-3 lg:h-full lg:min-h-0 lg:overflow-y-auto lg:overscroll-contain lg:p-4 lg:pl-2 lg:pb-40"
-        >
-          {rightColumn}
-        </div>
-      </div>
-    </div>
-  )
-}

+ 0 - 332
src/features/vehicles/Components/service-documents-manager.tsx

@@ -1,332 +0,0 @@
-'use client'
-
-import { interactiveRow } from '@/lib/interactive-row'
-import { useState, useCallback, useRef } from 'react'
-import { useTranslations } from 'next-intl'
-import { Button } from '@/components/ui/button'
-import { Switch } from '@/components/ui/switch'
-import { AppCard } from '@/components/app-card'
-import { toast } from 'sonner'
-import { FileText, Image as ImageIcon, Loader2, Paperclip, Upload, X } from 'lucide-react'
-import { addServiceAttachment } from '@/features/vehicles/Actions/addServiceAttachment'
-import { updateServiceAttachment } from '@/features/vehicles/Actions/updateServiceAttachment'
-import { deleteServiceAttachment } from '@/features/vehicles/Actions/serviceActions'
-
-interface Attachment {
-  id: string
-  fileName: string
-  fileUrl: string
-  fileType: string
-  fileSize: number
-  category: string
-  description: string | null
-  includeInInvoice: boolean
-}
-
-interface ServiceDocumentsManagerProps {
-  serviceRecordId: string
-  initialDocuments: Attachment[]
-  maxDiagnostics?: number
-  maxDocuments?: number
-}
-
-function formatFileSize(bytes: number) {
-  if (bytes < 1024) return `${bytes} B`
-  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
-  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
-}
-
-function getFileIcon(type: string) {
-  if (type === 'application/pdf') return <FileText className="h-4 w-4 text-red-500" />
-  if (type.startsWith('image/')) return <ImageIcon className="h-4 w-4 text-blue-500" />
-  return <Paperclip className="h-4 w-4 text-muted-foreground" />
-}
-
-export function ServiceDocumentsManager({
-  serviceRecordId,
-  initialDocuments,
-  maxDiagnostics,
-  maxDocuments,
-}: ServiceDocumentsManagerProps) {
-  const t = useTranslations('service')
-  const [files, setFiles] = useState<Attachment[]>(initialDocuments)
-  const [uploadingReports, setUploadingReports] = useState(false)
-  const [uploadingDocuments, setUploadingDocuments] = useState(false)
-  const reportInputRef = useRef<HTMLInputElement>(null)
-  const documentInputRef = useRef<HTMLInputElement>(null)
-
-  const diagnosticReports = files.filter((f) => f.category === 'diagnostic')
-  const documents = files.filter((f) => f.category === 'document')
-  const diagnosticsAtLimit =
-    maxDiagnostics !== undefined && diagnosticReports.length >= maxDiagnostics
-  const documentsAtLimit = maxDocuments !== undefined && documents.length >= maxDocuments
-
-  const handleUpload = useCallback(
-    async (fileList: FileList | File[], category: 'diagnostic' | 'document') => {
-      const maxLimit = category === 'diagnostic' ? maxDiagnostics : maxDocuments
-      const currentItems = category === 'diagnostic' ? diagnosticReports : documents
-      let fileArr = Array.from(fileList)
-      if (maxLimit !== undefined) {
-        const remaining = maxLimit - currentItems.length
-        if (remaining <= 0) {
-          const label =
-            category === 'diagnostic'
-              ? t('documents.diagnosticLabel')
-              : t('documents.documentLabel')
-          toast.error(
-            t('documents.limitReachedToast', { label, count: currentItems.length, max: maxLimit })
-          )
-          return
-        }
-        if (fileArr.length > remaining) {
-          fileArr = fileArr.slice(0, remaining)
-          toast.warning(t('documents.onlyUploading', { count: remaining }))
-        }
-      }
-      const setUploading = category === 'diagnostic' ? setUploadingReports : setUploadingDocuments
-      setUploading(true)
-      const toastId = toast.loading(t('documents.uploadingCount', { count: fileArr.length }))
-      let successCount = 0
-
-      for (const file of fileArr) {
-        const formData = new FormData()
-        formData.append('file', file)
-
-        try {
-          const res = await fetch('/api/protected/upload/service-files', {
-            method: 'POST',
-            body: formData,
-          })
-          if (!res.ok) {
-            const err = await res.json()
-            toast.error(err.error || `Failed to upload ${file.name}`)
-            continue
-          }
-          const data = await res.json()
-
-          const result = await addServiceAttachment({
-            serviceRecordId,
-            attachment: {
-              fileName: data.fileName,
-              fileUrl: data.url,
-              fileType: data.fileType,
-              fileSize: data.fileSize,
-              category,
-              includeInInvoice: true,
-            },
-          })
-
-          if (result.success && result.data) {
-            setFiles((prev) => [...prev, result.data as Attachment])
-            successCount++
-          } else {
-            toast.error(result.error || `Failed to save ${file.name}`)
-          }
-        } catch {
-          toast.error(`Failed to upload ${file.name}`)
-        }
-      }
-
-      if (successCount > 0) {
-        toast.success(t('documents.uploadedCount', { count: successCount }), { id: toastId })
-      } else {
-        toast.error(t('documents.uploadFailed'), { id: toastId })
-      }
-      setUploading(false)
-    },
-    [serviceRecordId, maxDiagnostics, maxDocuments, diagnosticReports, documents, t]
-  )
-
-  const handleDelete = useCallback(
-    async (attachmentId: string) => {
-      const result = await deleteServiceAttachment(attachmentId)
-      if (result.success) {
-        setFiles((prev) => prev.filter((f) => f.id !== attachmentId))
-        toast.success(t('documents.deleted'))
-      } else {
-        toast.error(result.error || t('documents.failedDelete'))
-      }
-    },
-    [t]
-  )
-
-  const handleToggleInvoice = useCallback(
-    async (attachmentId: string, checked: boolean) => {
-      setFiles((prev) =>
-        prev.map((f) => (f.id === attachmentId ? { ...f, includeInInvoice: checked } : f))
-      )
-      const result = await updateServiceAttachment({
-        id: attachmentId,
-        includeInInvoice: checked,
-      })
-      if (!result.success) {
-        setFiles((prev) =>
-          prev.map((f) => (f.id === attachmentId ? { ...f, includeInInvoice: !checked } : f))
-        )
-        toast.error(result.error || t('documents.failedUpdate'))
-      }
-    },
-    [t]
-  )
-
-  const renderFileList = (items: Attachment[]) =>
-    items.length > 0 && (
-      <div className="space-y-2">
-        {items.map((file) => (
-          <div key={file.id} className="flex items-center gap-3 rounded-md border p-2.5">
-            <a
-              href={file.fileUrl}
-              target="_blank"
-              rel="noopener noreferrer"
-              className="flex min-w-0 flex-1 items-center gap-3 hover:underline"
-            >
-              {getFileIcon(file.fileType)}
-              <div className="min-w-0 flex-1">
-                <p className="truncate text-sm font-medium">{file.fileName}</p>
-                <p className="text-xs text-muted-foreground">{formatFileSize(file.fileSize)}</p>
-              </div>
-            </a>
-            <label className="flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground">
-              <Switch
-                checked={file.includeInInvoice}
-                onCheckedChange={(checked) => handleToggleInvoice(file.id, checked)}
-                className="scale-75"
-              />
-              {t('documents.invoice')}
-            </label>
-            <Button
-              type="button"
-              variant="ghost"
-              size="icon"
-              className="h-7 w-7 text-muted-foreground hover:text-destructive"
-              onClick={() => handleDelete(file.id)}
-              aria-label={t('header.delete')}
-            >
-              <X className="h-3.5 w-3.5" />
-            </Button>
-          </div>
-        ))}
-      </div>
-    )
-
-  return (
-    <div className="space-y-6">
-      {/* Diagnostic Reports */}
-      <AppCard
-        icon={FileText}
-        title={t('documents.diagnosticTitle')}
-        badge={
-          maxDiagnostics !== undefined
-            ? `${diagnosticReports.length} / ${maxDiagnostics}`
-            : undefined
-        }
-        contentClassName="space-y-4"
-      >
-        {diagnosticsAtLimit ? (
-          <div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/25 p-6">
-            <p className="text-sm font-medium text-muted-foreground">
-              {t('documents.diagnosticLimitReached', {
-                count: diagnosticReports.length,
-                max: maxDiagnostics,
-              })}
-            </p>
-            <p className="mt-1 text-xs text-muted-foreground">{t('documents.upgradePrompt')}</p>
-          </div>
-        ) : (
-          <div
-            onDrop={(e) => {
-              e.preventDefault()
-              if (e.dataTransfer.files.length > 0) handleUpload(e.dataTransfer.files, 'diagnostic')
-            }}
-            onDragOver={(e) => e.preventDefault()}
-            {...interactiveRow(() => reportInputRef.current?.click())}
-            className="flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/25 p-6 transition-colors hover:border-muted-foreground/50"
-          >
-            <Upload className="mb-2 h-8 w-8 text-muted-foreground/50" />
-            <p className="text-sm font-medium">
-              {uploadingReports ? t('documents.uploading') : t('documents.dropzone')}
-            </p>
-            <p className="mt-1 text-xs text-muted-foreground">{t('documents.diagnosticFormats')}</p>
-            <input
-              ref={reportInputRef}
-              type="file"
-              multiple
-              accept=".pdf,.csv,.txt"
-              className="hidden"
-              onChange={(e) => {
-                if (e.target.files && e.target.files.length > 0) {
-                  handleUpload(e.target.files, 'diagnostic')
-                  e.target.value = ''
-                }
-              }}
-            />
-          </div>
-        )}
-
-        {uploadingReports && (
-          <div className="flex items-center gap-2 text-sm text-muted-foreground">
-            <Loader2 className="h-4 w-4 animate-spin" />
-            {t('documents.uploadingReports')}
-          </div>
-        )}
-
-        {renderFileList(diagnosticReports)}
-      </AppCard>
-
-      {/* Documents */}
-      <AppCard
-        icon={Paperclip}
-        title={t('documents.documentsTitle')}
-        badge={maxDocuments !== undefined ? `${documents.length} / ${maxDocuments}` : undefined}
-        contentClassName="space-y-4"
-      >
-        {documentsAtLimit ? (
-          <div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/25 p-6">
-            <p className="text-sm font-medium text-muted-foreground">
-              {t('documents.documentLimitReached', { count: documents.length, max: maxDocuments })}
-            </p>
-            <p className="mt-1 text-xs text-muted-foreground">{t('documents.upgradePrompt')}</p>
-          </div>
-        ) : (
-          <div
-            onDrop={(e) => {
-              e.preventDefault()
-              if (e.dataTransfer.files.length > 0) handleUpload(e.dataTransfer.files, 'document')
-            }}
-            onDragOver={(e) => e.preventDefault()}
-            {...interactiveRow(() => documentInputRef.current?.click())}
-            className="flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/25 p-6 transition-colors hover:border-muted-foreground/50"
-          >
-            <Upload className="mb-2 h-8 w-8 text-muted-foreground/50" />
-            <p className="text-sm font-medium">
-              {uploadingDocuments ? t('documents.uploading') : t('documents.dropzone')}
-            </p>
-            <p className="mt-1 text-xs text-muted-foreground">{t('documents.documentFormats')}</p>
-            <input
-              ref={documentInputRef}
-              type="file"
-              multiple
-              accept=".pdf,.doc,.docx,.xls,.xlsx,.csv,.txt"
-              className="hidden"
-              onChange={(e) => {
-                if (e.target.files && e.target.files.length > 0) {
-                  handleUpload(e.target.files, 'document')
-                  e.target.value = ''
-                }
-              }}
-            />
-          </div>
-        )}
-
-        {uploadingDocuments && (
-          <div className="flex items-center gap-2 text-sm text-muted-foreground">
-            <Loader2 className="h-4 w-4 animate-spin" />
-            {t('documents.uploadingDocuments')}
-          </div>
-        )}
-
-        {renderFileList(documents)}
-      </AppCard>
-    </div>
-  )
-}

+ 0 - 119
src/features/vehicles/Components/service-edit/BasicInfoSection.tsx

@@ -1,119 +0,0 @@
-'use client'
-
-import { useTranslations } from 'next-intl'
-import { Input } from '@/components/ui/input'
-import { Label } from '@/components/ui/label'
-import Link from 'next/link'
-import { ExternalLink } from 'lucide-react'
-import { useServiceType } from '@/components/service-type-context'
-import type { InitialData } from './form-types'
-import { VehicleCombobox } from '@/features/quotes/Components/VehicleCombobox'
-
-interface CustomerInfo {
-  id: string
-  name: string
-  company: string | null
-}
-
-interface BasicInfoSectionProps {
-  initialData: InitialData
-  vehicleId: string | null
-  vehicleName: string
-  selectedVehicleId: string | null
-  setSelectedVehicleId: (id: string) => void
-  techName: string
-  customer?: CustomerInfo | null
-  initialVehicle?: {
-    id: string
-    make: string
-    model: string
-    year: number
-    licensePlate: string | null
-  } | null
-}
-
-export function BasicInfoSection({
-  initialData,
-  vehicleName,
-  selectedVehicleId,
-  setSelectedVehicleId,
-  techName,
-  customer,
-  initialVehicle,
-}: BasicInfoSectionProps) {
-  const t = useTranslations('service.basicInfo')
-  const serviceType = useServiceType()
-
-  return (
-    <div className="rounded-lg border p-3 space-y-3">
-      <h3 className="text-sm font-semibold">{t('customerInfo')}</h3>
-
-      <div className="space-y-1">
-        <div className="flex items-center justify-between">
-          <Label className="text-xs">{t('vehicle')}</Label>
-          {selectedVehicleId && (
-            <Link
-              href={`/vehicles/${selectedVehicleId}`}
-              className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
-            >
-              {t('open')}
-              <ExternalLink className="h-3 w-3" />
-            </Link>
-          )}
-        </div>
-        <VehicleCombobox
-          value={selectedVehicleId ?? ''}
-          initialVehicle={
-            initialVehicle ? { ...initialVehicle, customerId: null, customer: null } : null
-          }
-          placeholder={vehicleName || t('searchVehicles')}
-          noneLabel={t('noVehicleFound')}
-          onChange={(id) => {
-            if (id) setSelectedVehicleId(id)
-          }}
-        />
-      </div>
-
-      {customer && (
-        <div className="flex items-center justify-between rounded-md border px-3 py-2">
-          <div className="min-w-0">
-            <p className="text-xs text-muted-foreground">{t('customer')}</p>
-            <Link
-              href={`/customers/${customer.id}`}
-              className="text-sm font-medium hover:underline"
-            >
-              {customer.name}
-            </Link>
-            {customer.company && (
-              <p className="text-xs text-muted-foreground">{customer.company}</p>
-            )}
-          </div>
-          <Link
-            href={`/customers/${customer.id}`}
-            className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
-          >
-            {t('open')}
-            <ExternalLink className="h-3 w-3" />
-          </Link>
-        </div>
-      )}
-
-      {selectedVehicleId && (
-        <div className="space-y-1">
-          <Label htmlFor="mileage" className="text-xs">
-            {serviceType === 'marine' ? t('mileageMarine') : t('mileage')}
-          </Label>
-          <Input
-            id="mileage"
-            name="mileage"
-            type="number"
-            placeholder="50000"
-            defaultValue={initialData.mileage ?? ''}
-          />
-        </div>
-      )}
-
-      <input type="hidden" name="techName" value={techName} />
-    </div>
-  )
-}

+ 0 - 302
src/features/vehicles/Components/service-images-manager.tsx

@@ -1,302 +0,0 @@
-'use client'
-
-import { interactiveRow } from '@/lib/interactive-row'
-import { useEffect, useState, useRef, useCallback } from 'react'
-import { useTranslations } from 'next-intl'
-import { Button } from '@/components/ui/button'
-import { Input } from '@/components/ui/input'
-import { Switch } from '@/components/ui/switch'
-import { AppCard } from '@/components/app-card'
-import { toast } from 'sonner'
-import { Camera, Image as ImageIcon, Loader2, X } from 'lucide-react'
-import { compressImage } from '@/lib/compress-image'
-import { addServiceAttachment } from '@/features/vehicles/Actions/addServiceAttachment'
-import { updateServiceAttachment } from '@/features/vehicles/Actions/updateServiceAttachment'
-import { deleteServiceAttachment } from '@/features/vehicles/Actions/serviceActions'
-import { ImageCarousel } from '@/features/vehicles/Components/service-detail/ImageCarousel'
-import { SendPhotoToWhatsapp } from '@/features/whatsapp/Components/SendPhotoToWhatsapp'
-
-interface Attachment {
-  id: string
-  fileName: string
-  fileUrl: string
-  fileType: string
-  fileSize: number
-  category: string
-  description: string | null
-  includeInInvoice: boolean
-}
-
-interface ServiceImagesManagerProps {
-  serviceRecordId: string
-  initialImages: Attachment[]
-  maxImages?: number
-  /** Enables sending a photo straight to the customer; omitted on jobs with none. */
-  customerId?: string | null
-}
-
-export function ServiceImagesManager({
-  serviceRecordId,
-  initialImages,
-  maxImages,
-  customerId,
-}: ServiceImagesManagerProps) {
-  const t = useTranslations('service')
-  const [images, setImages] = useState<Attachment[]>(initialImages)
-  // The list follows the job: a file added elsewhere on the page (from a
-  // concern's row) arrives with the refreshed record and belongs here too.
-  const savedKey = initialImages.map((a) => a.id).join(',')
-  useEffect(() => {
-    setImages(initialImages)
-  }, [savedKey]) // eslint-disable-line react-hooks/exhaustive-deps -- keyed on the ids, not the array's identity
-  const atLimit = maxImages !== undefined && images.length >= maxImages
-  const [uploading, setUploading] = useState(false)
-  const [carouselIndex, setCarouselIndex] = useState<number | null>(null)
-  const inputRef = useRef<HTMLInputElement>(null)
-
-  const handleUpload = useCallback(
-    async (files: FileList | File[]) => {
-      const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']
-      let fileArr = Array.from(files)
-      const rejected = fileArr.filter((f) => !allowedTypes.includes(f.type))
-      if (rejected.length > 0) {
-        toast.error(t('images.onlyAllowed', { names: rejected.map((f) => f.name).join(', ') }))
-        fileArr = fileArr.filter((f) => allowedTypes.includes(f.type))
-        if (fileArr.length === 0) return
-      }
-      if (maxImages !== undefined) {
-        const remaining = maxImages - images.length
-        if (remaining <= 0) {
-          toast.error(t('images.limitReached', { count: images.length, max: maxImages }))
-          return
-        }
-        if (fileArr.length > remaining) {
-          fileArr = fileArr.slice(0, remaining)
-          toast.warning(t('images.onlyUploading', { count: remaining }))
-        }
-      }
-      setUploading(true)
-      const toastId = toast.loading(t('images.uploadingCount', { count: fileArr.length }))
-      let successCount = 0
-
-      for (let file of fileArr) {
-        if (file.type.startsWith('image/')) {
-          file = await compressImage(file)
-        }
-        const formData = new FormData()
-        formData.append('file', file)
-
-        try {
-          const res = await fetch('/api/protected/upload/service-files', {
-            method: 'POST',
-            body: formData,
-          })
-          if (!res.ok) {
-            const err = await res.json()
-            toast.error(err.error || `Failed to upload ${file.name}`)
-            continue
-          }
-          const data = await res.json()
-
-          const result = await addServiceAttachment({
-            serviceRecordId,
-            attachment: {
-              fileName: data.fileName,
-              fileUrl: data.url,
-              fileType: data.fileType,
-              fileSize: data.fileSize,
-              category: 'image',
-              includeInInvoice: true,
-            },
-          })
-
-          if (result.success && result.data) {
-            setImages((prev) => [...prev, result.data as Attachment])
-            successCount++
-          } else {
-            toast.error(result.error || `Failed to save ${file.name}`)
-          }
-        } catch {
-          toast.error(`Failed to upload ${file.name}`)
-        }
-      }
-
-      if (successCount > 0) {
-        toast.success(t('images.uploadedCount', { count: successCount }), { id: toastId })
-      } else {
-        toast.error(t('images.uploadFailed'), { id: toastId })
-      }
-      setUploading(false)
-    },
-    [serviceRecordId, maxImages, images.length, t]
-  )
-
-  const handleDrop = useCallback(
-    (e: React.DragEvent) => {
-      e.preventDefault()
-      if (e.dataTransfer.files.length > 0) {
-        handleUpload(e.dataTransfer.files)
-      }
-    },
-    [handleUpload]
-  )
-
-  const handleDelete = useCallback(
-    async (attachmentId: string) => {
-      const result = await deleteServiceAttachment(attachmentId)
-      if (result.success) {
-        setImages((prev) => prev.filter((img) => img.id !== attachmentId))
-        toast.success(t('images.deleted'))
-      } else {
-        toast.error(result.error || t('images.failedDelete'))
-      }
-    },
-    [t]
-  )
-
-  const handleToggleInvoice = useCallback(
-    async (attachmentId: string, checked: boolean) => {
-      setImages((prev) =>
-        prev.map((img) => (img.id === attachmentId ? { ...img, includeInInvoice: checked } : img))
-      )
-      const result = await updateServiceAttachment({
-        id: attachmentId,
-        includeInInvoice: checked,
-      })
-      if (!result.success) {
-        setImages((prev) =>
-          prev.map((img) =>
-            img.id === attachmentId ? { ...img, includeInInvoice: !checked } : img
-          )
-        )
-        toast.error(result.error || t('images.failedUpdate'))
-      }
-    },
-    [t]
-  )
-
-  const handleDescriptionChange = useCallback(async (attachmentId: string, description: string) => {
-    setImages((prev) =>
-      prev.map((img) => (img.id === attachmentId ? { ...img, description } : img))
-    )
-  }, [])
-
-  const handleDescriptionBlur = useCallback(async (attachmentId: string, description: string) => {
-    await updateServiceAttachment({ id: attachmentId, description })
-  }, [])
-
-  return (
-    <AppCard
-      icon={Camera}
-      title={t('images.title')}
-      badge={maxImages !== undefined ? `${images.length} / ${maxImages}` : undefined}
-      contentClassName="space-y-4"
-    >
-      {atLimit ? (
-        <div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/25 p-6">
-          <p className="text-sm font-medium text-muted-foreground">
-            {t('images.limitReached', { count: images.length, max: maxImages })}
-          </p>
-          <p className="mt-1 text-xs text-muted-foreground">{t('images.upgradePrompt')}</p>
-        </div>
-      ) : (
-        <div
-          onDrop={handleDrop}
-          onDragOver={(e) => e.preventDefault()}
-          {...interactiveRow(() => inputRef.current?.click())}
-          className="flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/25 p-6 transition-colors hover:border-muted-foreground/50"
-        >
-          <ImageIcon className="mb-2 h-8 w-8 text-muted-foreground/50" />
-          <p className="text-sm font-medium">
-            {uploading ? t('images.uploading') : t('images.dropzone')}
-          </p>
-          <p className="mt-1 text-xs text-muted-foreground">{t('images.formats')}</p>
-          <input
-            ref={inputRef}
-            type="file"
-            multiple
-            accept=".jpg,.jpeg,.png,.webp"
-            className="hidden"
-            onChange={(e) => {
-              if (e.target.files && e.target.files.length > 0) {
-                handleUpload(e.target.files)
-                e.target.value = ''
-              }
-            }}
-          />
-        </div>
-      )}
-
-      {uploading && (
-        <div className="flex items-center gap-2 text-sm text-muted-foreground">
-          <Loader2 className="h-4 w-4 animate-spin" />
-          {t('images.uploadingImages')}
-        </div>
-      )}
-
-      {images.length > 0 && (
-        <div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-6">
-          {images.map((file) => (
-            <div key={file.id} className="group overflow-hidden rounded-lg border">
-              <div className="relative">
-                <img
-                  src={file.fileUrl}
-                  alt={file.description || file.fileName}
-                  className="aspect-square w-full cursor-pointer object-cover"
-                  onClick={() => setCarouselIndex(images.indexOf(file))}
-                />
-                <Button
-                  type="button"
-                  variant="secondary"
-                  size="icon"
-                  className="absolute right-1 top-1 h-6 w-6 transition-opacity [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover:opacity-100"
-                  onClick={() => handleDelete(file.id)}
-                  aria-label={t('header.delete')}
-                >
-                  <X className="h-3 w-3" />
-                </Button>
-              </div>
-              <div className="space-y-1 p-1.5">
-                <Input
-                  placeholder={t('images.description')}
-                  value={file.description || ''}
-                  onChange={(e) => handleDescriptionChange(file.id, e.target.value)}
-                  onBlur={(e) => handleDescriptionBlur(file.id, e.target.value)}
-                  className="h-7 text-xs"
-                />
-                <div className="flex items-center justify-between gap-1">
-                  <label className="flex items-center gap-1.5 text-xs text-muted-foreground">
-                    <Switch
-                      checked={file.includeInInvoice}
-                      onCheckedChange={(checked) => handleToggleInvoice(file.id, checked)}
-                      className="scale-75"
-                    />
-                    {t('images.invoice')}
-                  </label>
-                  {customerId && (
-                    <SendPhotoToWhatsapp
-                      customerId={customerId}
-                      fileUrl={file.fileUrl}
-                      fileName={file.fileName}
-                      serviceRecordId={serviceRecordId}
-                    />
-                  )}
-                </div>
-              </div>
-            </div>
-          ))}
-        </div>
-      )}
-
-      <ImageCarousel
-        images={images.map((img) => ({
-          ...img,
-          createdAt: new Date(),
-        }))}
-        currentIndex={carouselIndex}
-        onClose={() => setCarouselIndex(null)}
-        onChangeIndex={setCarouselIndex}
-      />
-    </AppCard>
-  )
-}

+ 0 - 261
src/features/vehicles/Components/service-page/DetailsLeftColumn.tsx

@@ -1,261 +0,0 @@
-import { type ReactNode, useMemo } from 'react'
-import { PartsEditor } from '../service-edit/PartsEditor'
-import { LaborEditor } from '../service-edit/LaborEditor'
-import { servicePartFromFluid } from '../labor-guide/guide-lines'
-import { ConcernsSection } from '../service-edit/ConcernsSection'
-import { NotesSection } from '../service-edit/NotesSection'
-import { PaymentsSection } from '../service-detail/PaymentsSection'
-import { InvoiceSummary } from '../service-detail/InvoiceSummary'
-import { ServiceFindingsSection } from '../service-detail/ServiceFindingsSection'
-import type { useServiceFormState } from './useServiceFormState'
-import type { useServiceActions } from './useServiceActions'
-import type { ServiceDetail } from '../service-detail/types'
-import {
-  TireSetBanner,
-  type TireSetBannerData,
-} from '@/features/tire-hotel/Components/TireSetBanner'
-import { StoreTiresButton } from '@/features/tire-hotel/Components/StoreTiresButton'
-import type { InventoryPartOption } from '../service-edit/form-types'
-import { useTranslations } from 'next-intl'
-import { JobClockSection } from '@/features/time-tracking/Components/JobClockSection'
-import type { JobClock } from '@/features/time-tracking/Actions/timeClockActions'
-
-interface DetailsLeftColumnProps {
-  formState: ReturnType<typeof useServiceFormState>
-  actions: ReturnType<typeof useServiceActions>
-  record: ServiceDetail
-  /** Present only when the job came out of the tire hotel. */
-  tireSet?: TireSetBannerData | null
-  tireHotelEnabled?: boolean
-  tireThresholds?: { summerReplace: number; winterReplace: number; warnMargin: number }
-  unitSystem?: 'metric' | 'imperial'
-  currencyCode: string
-  defaultLaborRate: number
-  inventoryParts: InventoryPartOption[]
-  defaultMarkupPercent?: number
-  markupAppliesToInventory?: boolean
-  hasPresets?: boolean
-  onOpenPresets?: () => void
-  onScanBarcode?: () => void
-  aiEnabled?: boolean
-  vehicleId: string | null
-  findings?: {
-    id: string
-    description: string
-    severity: string
-    status: string
-    notes: string | null
-    concernId?: string | null
-  }[]
-  onAddFinding?: () => void
-  onEditFinding?: (finding: {
-    id: string
-    description: string
-    severity: string
-    status: string
-    notes: string | null
-  }) => void
-  openObservationsCount?: number
-  onShowExistingObservations?: () => void
-  jobClock: JobClock
-  /** A locked invoice: the notes editors go read-only, which the fieldset cannot do for them. */
-  locked?: boolean
-  /** The pay code button, when online payment is connected. Drawn where payments are taken. */
-  payCode?: ReactNode
-}
-
-export function DetailsLeftColumn({
-  formState,
-  actions,
-  record,
-  tireSet = null,
-  tireHotelEnabled = false,
-  tireThresholds,
-  unitSystem = 'metric',
-  currencyCode,
-  defaultLaborRate,
-  inventoryParts,
-  defaultMarkupPercent = 0,
-  markupAppliesToInventory = false,
-  hasPresets,
-  onOpenPresets,
-  onScanBarcode,
-  aiEnabled,
-  vehicleId,
-  findings = [],
-  onAddFinding,
-  onEditFinding,
-  openObservationsCount = 0,
-  onShowExistingObservations,
-  jobClock,
-  locked = false,
-  payCode = null,
-}: DetailsLeftColumnProps) {
-  const tClock = useTranslations('timeTracking.job')
-  // What the labor guide matches a fluid against, priced the way a pick from stock is here.
-  const guideStock = useMemo(
-    () => ({ parts: inventoryParts, pricing: { defaultMarkupPercent, markupAppliesToInventory } }),
-    [inventoryParts, defaultMarkupPercent, markupAppliesToInventory]
-  )
-  // Which concerns somebody has actually looked at. Counted here rather than
-  // queried, because the findings are already loaded for the section below.
-  const answeredCounts = findings.reduce<Record<string, number>>((counts, finding) => {
-    if (finding.concernId) counts[finding.concernId] = (counts[finding.concernId] ?? 0) + 1
-    return counts
-  }, {})
-
-  const concerns = (
-    <ConcernsSection
-      concerns={formState.concerns}
-      setConcerns={formState.setConcerns}
-      onChange={formState.markDirty}
-      answeredCounts={answeredCounts}
-    />
-  )
-
-  // The tires that came off the car are standing in the corner while the desk
-  // writes the job up, so the offer to store them belongs here rather than
-  // three screens away. Rendered even once the job has its set, when it shows
-  // no button: the label dialog it opens has to outlive that moment.
-  const storeTires =
-    tireHotelEnabled && record.vehicle ? (
-      <StoreTiresButton
-        serviceRecordId={record.id}
-        hasSet={!!tireSet}
-        canBill={!locked}
-        vehicle={{
-          id: record.vehicle.id,
-          make: record.vehicle.make,
-          model: record.vehicle.model,
-          year: record.vehicle.year,
-          licensePlate: record.vehicle.licensePlate ?? null,
-          customerId: record.customer?.id ?? null,
-        }}
-        imperial={unitSystem === 'imperial'}
-        thresholds={tireThresholds}
-      />
-    ) : null
-
-  return (
-    <div className="space-y-3">
-      {/* First thing on the job, above the work itself: why the car is here,
-          in the customer's words. Above the parts, inside the working column:
-          the tires are the first thing this job needs and the last thing the
-          invoice sidebar cares about, so they belong here rather than spanning
-          both columns. */}
-      {concerns}
-      {storeTires}
-
-      {tireSet && (
-        <TireSetBanner set={tireSet} serviceRecordId={record.id} thresholds={tireThresholds} />
-      )}
-
-      <PartsEditor
-        partItems={formState.partItems}
-        setPartItems={formState.dirtySetPartItems}
-        updatePart={formState.updatePart}
-        partsSubtotal={formState.partsSubtotal}
-        currencyCode={currencyCode}
-        hasInventory={inventoryParts.length > 0}
-        inventoryParts={inventoryParts}
-        onOpenInventory={() => formState.setShowInventoryPicker(true)}
-        onScanBarcode={onScanBarcode}
-        defaultMarkupPercent={defaultMarkupPercent}
-        markupAppliesToInventory={markupAppliesToInventory}
-      />
-      <LaborEditor
-        laborItems={formState.laborItems}
-        setLaborItems={formState.dirtySetLaborItems}
-        updateLabor={formState.updateLabor}
-        laborSubtotal={formState.laborSubtotal}
-        currencyCode={currencyCode}
-        defaultLaborRate={defaultLaborRate}
-        hasPresets={hasPresets}
-        onOpenPresets={onOpenPresets}
-        onAddFinding={onAddFinding}
-        openObservationsCount={openObservationsCount}
-        onShowExistingObservations={onShowExistingObservations}
-        vehicleId={record.vehicle?.id ?? null}
-        vehicleLabel={
-          record.vehicle
-            ? `${record.vehicle.year} ${record.vehicle.make} ${record.vehicle.model}`
-            : undefined
-        }
-        guideStock={guideStock}
-        onAddGuidePart={(line) =>
-          formState.dirtySetPartItems((prev) => [servicePartFromFluid(line), ...prev])
-        }
-      />
-      <JobClockSection
-        serviceRecordId={record.id}
-        initial={jobClock}
-        onAddLabor={(hours) =>
-          formState.dirtySetLaborItems((prev) => [
-            ...prev,
-            {
-              description: tClock('laborDescription'),
-              hours,
-              rate: defaultLaborRate,
-              total: Math.round(hours * defaultLaborRate * 100) / 100,
-              pricingType: 'hourly' as const,
-            },
-          ])
-        }
-      />
-      <NotesSection
-        initialData={formState.initialData}
-        onNotesChange={formState.handleNotesChange}
-        serviceRecordId={record.id}
-        aiEnabled={aiEnabled}
-        publicLocked={locked}
-        internalLocked={locked}
-      />
-      {vehicleId && (
-        <ServiceFindingsSection
-          vehicleId={vehicleId}
-          serviceRecordId={record.id}
-          findings={findings}
-          onAddFinding={onAddFinding}
-          onEditFinding={onEditFinding}
-        />
-      )}
-      <div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
-        <PaymentsSection
-          payments={record.payments || []}
-          paymentStatus={formState.paymentStatus}
-          manuallyPaid={record.manuallyPaid}
-          totalPaid={formState.totalPaid}
-          displayTotal={formState.displayTotal}
-          balanceDue={formState.balanceDue}
-          currencyCode={currencyCode}
-          onCreatePayment={actions.handleCreatePayment}
-          onDeletePayment={actions.handleDeletePayment}
-          onTogglePaid={actions.handleTogglePaid}
-          paymentLoading={actions.paymentLoading}
-          deletingPayment={actions.deletingPayment}
-          extraAction={payCode}
-        />
-        <InvoiceSummary
-          hasPartItems={formState.partItems.length > 0}
-          hasLaborItems={formState.laborItems.length > 0}
-          partsSubtotal={formState.partsSubtotal}
-          laborSubtotal={formState.laborSubtotal}
-          subtotal={formState.subtotal}
-          discountAmount={formState.discountAmount}
-          discountType={formState.discountType === 'none' ? null : formState.discountType}
-          discountValue={formState.discountValue}
-          taxRate={formState.taxRate}
-          taxAmount={formState.taxAmount}
-          taxInclusive={formState.taxInclusive}
-          taxComponents={formState.taxComponents}
-          taxableShare={formState.taxableShare}
-          displayTotal={formState.displayTotal}
-          totalPaid={formState.totalPaid}
-          balanceDue={formState.balanceDue}
-          hasPayments={(record.payments?.length ?? 0) > 0}
-          currencyCode={currencyCode}
-        />
-      </div>
-    </div>
-  )
-}

+ 0 - 231
src/features/vehicles/Components/service-page/DetailsRightColumn.tsx

@@ -1,231 +0,0 @@
-import { useRouter } from 'next/navigation'
-import type { DesignAutoRule } from '@/features/invoice-designer/Lib/designRules'
-import { useTranslations } from 'next-intl'
-import { MessageSquare } from 'lucide-react'
-import { SharedLinkCard } from '@/components/shared-link-card'
-import { InvoiceDetailsSection, type DesignOption } from '../service-edit/InvoiceDetailsSection'
-import { BasicInfoSection } from '../service-edit/BasicInfoSection'
-import { ScheduleTimesSection } from '../service-edit/ScheduleTimesSection'
-import { TotalsSection } from '../service-edit/TotalsSection'
-import { ServiceAttachments } from '../service-detail/ServiceAttachments'
-import { CustomFieldsForm } from '@/features/custom-fields/Components/CustomFieldsForm'
-import { WarrantySection } from './WarrantySection'
-import { VideoCallSection } from './VideoCallSection'
-import type { ServiceVideoCall } from '@/features/integrations/Actions/integrationActions'
-import { revokePublicLink } from '@/features/vehicles/Actions/serviceActions'
-import type { useServiceFormState } from './useServiceFormState'
-import type { useServiceActions } from './useServiceActions'
-import type { ServiceDetail } from '../service-detail/types'
-import type { WarrantyTexts } from '@/lib/warranty'
-import type { BoardTechnicianOption, OrgMemberOption, WorkBayOption } from './service-page-types'
-
-interface DetailsRightColumnProps {
-  formState: ReturnType<typeof useServiceFormState>
-  actions: ReturnType<typeof useServiceActions>
-  record: ServiceDetail
-  vehicleId: string | null
-  organizationId: string
-  currencyCode: string
-  unitSystem: 'metric' | 'imperial'
-  warrantyTexts: WarrantyTexts
-  taxEnabled: boolean
-  initialVehicle: {
-    id: string
-    make: string
-    model: string
-    year: number
-    licensePlate: string | null
-  } | null
-  boardTechnicians: BoardTechnicianOption[]
-  workBays?: WorkBayOption[]
-  orgMembers?: OrgMemberOption[]
-  notificationHistory?: {
-    id: string
-    body: string
-    status: string
-    createdAt: string
-    toNumber: string
-  }[]
-  videoCall?: ServiceVideoCall
-  smsEnabled?: boolean
-  emailEnabled?: boolean
-  telegramEnabled?: boolean
-  designOptions?: DesignOption[]
-  designFollowsName?: string | null
-  designFollowsRule?: DesignAutoRule | null
-}
-
-export function DetailsRightColumn({
-  formState,
-  actions,
-  record,
-  vehicleId,
-  organizationId,
-  currencyCode,
-  unitSystem,
-  warrantyTexts,
-  taxEnabled,
-  initialVehicle,
-  boardTechnicians,
-  workBays,
-  orgMembers,
-  notificationHistory = [],
-  videoCall,
-  smsEnabled = false,
-  emailEnabled = false,
-  telegramEnabled = false,
-  designOptions = [],
-  designFollowsName = null,
-  designFollowsRule = null,
-}: DetailsRightColumnProps) {
-  const router = useRouter()
-
-  return (
-    <div className="space-y-3">
-      {record.publicToken && (
-        <SharedLinkCard
-          publicToken={record.publicToken}
-          organizationId={organizationId}
-          type="invoice"
-          sharedAt={record.sharedAt}
-          viewCount={record.viewCount}
-          lastViewedAt={record.lastViewedAt}
-          onRevoke={async () => {
-            await revokePublicLink(record.id)
-            router.refresh()
-          }}
-        />
-      )}
-      <InvoiceDetailsSection
-        initialData={formState.initialData}
-        showType={!!record.vehicle}
-        type={formState.type}
-        setType={formState.dirtySetType}
-        status={formState.status}
-        setStatus={formState.dirtySetStatus}
-        onDirty={formState.markDirty}
-        paymentStatus={formState.paymentStatus}
-        onTogglePaid={actions.handleTogglePaid}
-        paymentLoading={actions.paymentLoading}
-        designOptions={designOptions}
-        designId={record.designId ?? null}
-        designFollowsName={designFollowsName}
-        designFollowsRule={designFollowsRule}
-      />
-      <BasicInfoSection
-        initialData={formState.initialData}
-        vehicleId={vehicleId}
-        vehicleName={formState.vehicleName}
-        selectedVehicleId={formState.selectedVehicleId}
-        setSelectedVehicleId={formState.dirtySetSelectedVehicleId}
-        techName={formState.techName}
-        customer={record.customer ?? record.vehicle?.customer}
-        initialVehicle={initialVehicle}
-      />
-      <ScheduleTimesSection
-        serviceRecordId={record.id}
-        technicians={boardTechnicians}
-        workBays={workBays}
-        orgMembers={orgMembers}
-        initialStartDateTime={formState.initialData.startDateTime}
-        initialEndDateTime={formState.initialData.endDateTime}
-        initialTechnicianId={record.technicianId}
-        initialWorkBayId={record.workBayId}
-        initialPromisedAt={record.promisedAt ? new Date(record.promisedAt).toISOString() : null}
-        onSaved={formState.flashSaved}
-      />
-      {videoCall && (
-        <VideoCallSection
-          serviceRecordId={record.id}
-          videoCall={videoCall}
-          scheduled={Boolean(formState.initialData.startDateTime)}
-          customer={record.customer ?? record.vehicle?.customer ?? null}
-          smsEnabled={smsEnabled}
-          emailEnabled={emailEnabled}
-          telegramEnabled={telegramEnabled}
-        />
-      )}
-      <TotalsSection
-        partsSubtotal={formState.partsSubtotal}
-        partsCostSubtotal={formState.partsCostSubtotal}
-        laborSubtotal={formState.laborSubtotal}
-        subtotal={formState.subtotal}
-        discountType={formState.discountType}
-        setDiscountType={formState.dirtySetDiscountType}
-        discountValue={formState.discountValue}
-        setDiscountValue={formState.dirtySetDiscountValue}
-        discountAmount={formState.discountAmount}
-        taxEnabled={taxEnabled}
-        taxRate={formState.taxRate}
-        setTaxRate={formState.dirtySetTaxRate}
-        taxAmount={formState.taxAmount}
-        taxInclusive={formState.taxInclusive}
-        taxComponents={formState.taxComponents}
-        taxableShare={formState.taxableShare}
-        totalAmount={formState.totalAmount}
-        currencyCode={currencyCode}
-      />
-      <WarrantySection
-        value={formState.warranty}
-        onChange={formState.dirtySetWarranty}
-        texts={warrantyTexts}
-        distanceUnit={unitSystem === 'metric' ? 'km' : 'mi'}
-        serviceDate={formState.initialData.serviceDate}
-      />
-      <ServiceAttachments
-        attachments={record.attachments || []}
-        imageAttachments={formState.imageAttachments}
-        onImageClick={actions.onImageClick}
-        onDeleteAttachment={actions.handleDeleteAttachment}
-        deletingAttachment={actions.deletingAttachment}
-      />
-      <CustomFieldsForm
-        entityId={record.id}
-        entityType="service_record"
-        onValuesReady={formState.onCustomFieldsReady}
-        onChange={formState.markDirty}
-      />
-      {notificationHistory.length > 0 && (
-        <NotificationHistory notifications={notificationHistory} />
-      )}
-    </div>
-  )
-}
-
-function NotificationHistory({
-  notifications,
-}: {
-  notifications: { id: string; body: string; status: string; createdAt: string; toNumber: string }[]
-}) {
-  const t = useTranslations('service.notifications')
-
-  function timeAgo(dateStr: string) {
-    const diff = Date.now() - new Date(dateStr).getTime()
-    const mins = Math.floor(diff / 60000)
-    if (mins < 1) return t('justNow')
-    if (mins < 60) return t('minutesAgo', { count: mins })
-    const hours = Math.floor(mins / 60)
-    if (hours < 24) return t('hoursAgo', { count: hours })
-    const days = Math.floor(hours / 24)
-    return t('daysAgo', { count: days })
-  }
-
-  return (
-    <div className="rounded-lg border p-3">
-      <div className="mb-2 flex items-center gap-2">
-        <MessageSquare className="h-3.5 w-3.5 text-muted-foreground" />
-        <h3 className="text-sm font-semibold">{t('title')}</h3>
-      </div>
-      <div className="space-y-2">
-        {notifications.map((n) => (
-          <div key={n.id} className="text-xs">
-            <p className="text-muted-foreground">
-              {timeAgo(n.createdAt)} · {t('viaSms')}
-            </p>
-            <p className="mt-0.5 truncate">{n.body}</p>
-          </div>
-        ))}
-      </div>
-    </div>
-  )
-}

+ 165 - 0
src/features/vehicles/Components/service-page/ServiceHeaderActions.tsx

@@ -0,0 +1,165 @@
+'use client'
+
+import { useTranslations } from 'next-intl'
+import { ButtonGroup } from '@/components/ui/button-group'
+import { IconActionButton } from '@/components/icon-action-button'
+import { Button } from '@/components/ui/button'
+import {
+  DropdownMenu,
+  DropdownMenuContent,
+  DropdownMenuItem,
+  DropdownMenuSeparator,
+  DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
+import {
+  Download,
+  Eye,
+  Globe,
+  Mail,
+  MessageSquare,
+  MoreVertical,
+  Save,
+  Trash2,
+  Video,
+  Printer,
+} from 'lucide-react'
+
+export interface ServiceHeaderActionsProps {
+  /** Save belongs to the details form; the sections that save on their own do not need it. */
+  showSave: boolean
+  downloading: boolean
+  saving: boolean
+  hasUnsavedChanges?: boolean
+  showSaved?: boolean
+  onDownloadPDF: () => void
+  onPreviewPDF: () => void
+  /** The work order as a sheet to print and sign, opened in the preview. */
+  onPrintWorkOrder?: () => void
+  onDelete: () => void
+  onShowEmail: () => void
+  onShowShare: () => void
+  onNotifyCustomer?: () => void
+  hasCustomer?: boolean
+  /** The invoice-design submenu, when this invoice has a frozen look to change. */
+  designMenu?: React.ReactNode
+  /** Video call link from a connected calendar, when one exists. */
+  meetingUrl?: string | null
+}
+
+/**
+ * Everything that can be done with the job as a whole: save it, look at the
+ * invoice, send it, tell the customer, and the rest behind the menu. Drawn in
+ * the page's header.
+ */
+export function ServiceHeaderActions({
+  showSave,
+  downloading,
+  saving,
+  hasUnsavedChanges = false,
+  showSaved = false,
+  onDownloadPDF,
+  onPreviewPDF,
+  onPrintWorkOrder,
+  onDelete,
+  onShowEmail,
+  onShowShare,
+  onNotifyCustomer,
+  hasCustomer = false,
+  designMenu,
+  meetingUrl = null,
+}: ServiceHeaderActionsProps) {
+  const t = useTranslations('service.header')
+  const tPreview = useTranslations('common.pdfPreview')
+
+  return (
+    <div className="flex shrink-0 flex-wrap items-center gap-2">
+      {showSave && (
+        <>
+          {hasUnsavedChanges && (
+            <span className="text-xs font-medium text-amber-600 dark:text-amber-400">
+              {t('unsavedChanges')}
+            </span>
+          )}
+          {showSaved && !hasUnsavedChanges && (
+            <span className="text-xs font-medium text-green-600 dark:text-green-400">
+              {t('saved')}
+            </span>
+          )}
+          <IconActionButton
+            type="submit"
+            form="service-record-form"
+            label={t('save')}
+            icon={Save}
+            loading={saving}
+            variant={hasUnsavedChanges ? 'default' : 'outline'}
+            className={hasUnsavedChanges ? 'animate-pulse' : ''}
+          />
+        </>
+      )}
+      <ButtonGroup>
+        <IconActionButton label={tPreview('preview')} icon={Eye} onClick={onPreviewPDF} />
+        <IconActionButton
+          label={t('pdf')}
+          icon={Download}
+          loading={downloading}
+          onClick={onDownloadPDF}
+        />
+        <IconActionButton label={t('email')} icon={Mail} onClick={onShowEmail} />
+        <IconActionButton label={t('share')} icon={Globe} onClick={onShowShare} />
+        {meetingUrl && (
+          <IconActionButton
+            label={t('joinCall')}
+            icon={Video}
+            onClick={() => window.open(meetingUrl, '_blank', 'noopener')}
+          />
+        )}
+        {hasCustomer && onNotifyCustomer && (
+          <IconActionButton label={t('notify')} icon={MessageSquare} onClick={onNotifyCustomer} />
+        )}
+        {/* The row already carries six actions in twelve languages. New
+            ones go in here rather than widening it, and Delete moved in
+            with them: a destructive button one pixel from Share is a
+            misclick waiting to happen. */}
+        <DropdownMenu>
+          <Tooltip>
+            <TooltipTrigger asChild>
+              <DropdownMenuTrigger asChild>
+                <Button
+                  type="button"
+                  variant="outline"
+                  size="icon-sm"
+                  aria-label={t('moreActions')}
+                >
+                  <MoreVertical className="size-4" aria-hidden="true" />
+                </Button>
+              </DropdownMenuTrigger>
+            </TooltipTrigger>
+            <TooltipContent>{t('moreActions')}</TooltipContent>
+          </Tooltip>
+          <DropdownMenuContent align="end" className="min-w-56">
+            {onPrintWorkOrder && (
+              <>
+                <DropdownMenuItem onClick={onPrintWorkOrder}>
+                  <Printer className="mr-2 size-4" aria-hidden="true" />
+                  {t('printWorkOrder')}
+                </DropdownMenuItem>
+                <DropdownMenuSeparator />
+              </>
+            )}
+            {designMenu && (
+              <>
+                {designMenu}
+                <DropdownMenuSeparator />
+              </>
+            )}
+            <DropdownMenuItem className="text-destructive" onClick={onDelete}>
+              <Trash2 className="mr-2 size-4" aria-hidden="true" />
+              {t('delete')}
+            </DropdownMenuItem>
+          </DropdownMenuContent>
+        </DropdownMenu>
+      </ButtonGroup>
+    </div>
+  )
+}

+ 109 - 253
src/features/vehicles/Components/service-page/ServicePageClient.tsx

@@ -12,7 +12,7 @@ import { setInvoiceEditUnlocked } from '@/features/settings/Actions/documentLock
 import { InvoiceDesignMenu } from './InvoiceDesignMenu'
 import { PayCodeButton } from './PayCodeButton'
 import type { WorkOrderStatusOption } from '@/features/work-order-statuses/Lib/stages'
-import { useState, useCallback, useMemo, useRef, useEffect, type ComponentProps } from 'react'
+import { useState, useCallback, useMemo, useRef, useEffect } from 'react'
 import { useRouter } from 'next/navigation'
 import { sendInvoiceEmail } from '@/features/email/Actions/emailActions'
 import {
@@ -23,7 +23,6 @@ import { useConfirm } from '@/components/confirm-dialog'
 import { SendEmailDialog } from '@/features/email/Components/SendEmailDialog'
 import { useTranslations } from 'next-intl'
 
-import { ServiceDetailContent } from '../service-detail/ServiceDetailContent'
 import { ImageCarousel } from '../service-detail/ImageCarousel'
 import { ShareDialog } from '../service-detail/ShareDialog'
 import { NotifyCustomerDialog } from '@/components/notify-customer-dialog'
@@ -53,27 +52,21 @@ import { SMS_TEMPLATE_DEFAULTS, interpolateSmsTemplate } from '@/lib/sms-templat
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { LaborPresetPickerDialog } from '@/features/labor-presets/Components/LaborPresetPickerDialog'
 import type { LaborPresetOption } from './service-page-types'
-import { ServiceImagesManager } from '../service-images-manager'
-import { ServiceVideoManager } from '../service-video-manager'
-import { ServiceDocumentsManager } from '../service-documents-manager'
 import { StatusReportList } from '@/features/status-reports/Components/StatusReportList'
-import { ServiceHeaderActions, UnifiedServiceHeader, type ServiceTab } from './UnifiedServiceHeader'
+import { ServiceHeaderActions } from './ServiceHeaderActions'
 
 import { useServiceFormState } from './useServiceFormState'
 import { useServiceActions } from './useServiceActions'
-import { DetailsLeftColumn } from './DetailsLeftColumn'
-import { DetailsRightColumn } from './DetailsRightColumn'
 import { ObservationsManager, type ObservationsControls } from './ObservationsManager'
-import type { ServicePageClientProps } from './service-page-types'
+import type { ServicePageClientProps, ServiceTab } from './service-page-types'
 import { WorkOrderLayoutProvider } from '@/components/work-order-layout-context'
-import { rememberWorkOrderLayout, type WorkOrderLayout } from '@/lib/work-order-layout'
-import { registerAnalyticsProperties, track } from '@/lib/analytics'
-import { TryNewLayoutBanner } from './TryNewLayoutBanner'
+import { track } from '@/lib/analytics'
 import { LaborAddedBanner } from './LaborAddedBanner'
 import { useLiveRecord } from '@/features/realtime/hooks'
 import { PresenceChips } from '@/features/realtime/Components/PresenceChips'
 import { ModernDetails } from './modern/ModernDetails'
 import { ModernHero } from './modern/ModernHero'
+import type { WorkOrderJobProps, WorkOrderSidebarProps } from './modern/details-props'
 import { lineTotal, resolvePartPrice } from '@/features/inventory/Lib/partPricing'
 
 export type { ServicePageClientProps, BoardTechnicianOption } from './service-page-types'
@@ -134,7 +127,6 @@ export function ServicePageClient({
   designPinnedAt = null,
   designFollowsRule = null,
   jobClock = { entries: [], viewerTechnicianIds: [], canEdit: false, timeZone: 'UTC' },
-  initialLayout = 'modern',
 }: ServicePageClientProps) {
   const t = useTranslations('service')
   const router = useRouter()
@@ -148,11 +140,7 @@ export function ServicePageClient({
       ? (initialTab as ServiceTab)
       : 'details'
 
-  const [storedTab, setActiveTab] = useState<ServiceTab>(resolvedInitialTab)
-
-  // Classic until somebody asks for the overhauled page; the server already
-  // read this browser's choice, so the first paint is the right one.
-  const [layout, setLayout] = useState<WorkOrderLayout>(initialLayout)
+  const [requestedTab, setRequestedTab] = useState<ServiceTab>(resolvedInitialTab)
 
   // The overhauled page edits the title in its header, outside the form, so
   // the page holds it and the form carries it as a hidden field. It follows
@@ -161,37 +149,22 @@ export function ServicePageClient({
   useEffect(() => {
     setTitle(record.title)
   }, [record.title])
-  const modern = layout === 'modern'
 
-  // One view per work order opened, in the layout it opened in; a switch on
-  // the page is its own event and does not count as a second view. The
-  // layout is also put on every later event from this browser, so any chart
-  // (autocaptured clicks included) can be split by it.
-  const layoutRef = useRef(layout)
-  layoutRef.current = layout
+  // One view per work order opened.
   useEffect(() => {
-    registerAnalyticsProperties({ work_order_layout: layoutRef.current })
-    track('work_order:page_view', { layout: layoutRef.current })
+    track('work_order:page_view')
   }, [record.id])
-  // The overhauled page has no tabs: photos, video, documents and status
-  // reports are all on the job itself. A link to one of the old tabs lands on
-  // the page with that section open or scrolled to.
-  const isFileTab = storedTab === 'images' || storedTab === 'video' || storedTab === 'documents'
-  const activeTab: ServiceTab = modern ? 'details' : storedTab
+  // The page has no tabs: photos, video, documents and status reports are all
+  // on the job itself. A link with `?tab=` lands on the page with that
+  // section open or scrolled to.
+  const isFileTab =
+    requestedTab === 'images' || requestedTab === 'video' || requestedTab === 'documents'
   // A link to this record with another tab (feedback on a status report, say)
   // changes only the query, so the page is not remounted and has to follow it.
   useEffect(() => {
-    setActiveTab(resolvedInitialTab)
+    setRequestedTab(resolvedInitialTab)
   }, [resolvedInitialTab])
 
-  const handleTabChange = useCallback((tab: ServiceTab) => {
-    setActiveTab(tab)
-    const url = new URL(window.location.href)
-    if (tab === 'details') url.searchParams.delete('tab')
-    else url.searchParams.set('tab', tab)
-    window.history.replaceState(null, '', url.pathname + url.search)
-  }, [])
-
   // Date check dialog state
   const [showDateCheck, setShowDateCheck] = useState(false)
   const dateCheckResolveRef = useRef<((proceed: boolean) => void) | null>(null)
@@ -292,7 +265,7 @@ export function ServicePageClient({
     [formState, t, defaultMarkupPercent, markupAppliesToInventory]
   )
 
-  useHardwareScanner({ onScan: handleBarcodeScan, enabled: activeTab === 'details' })
+  useHardwareScanner({ onScan: handleBarcodeScan })
 
   // Notify customer
   const statusTemplateKeys: Record<string, string> = {
@@ -457,27 +430,6 @@ export function ServicePageClient({
     if (formState.hasUnsavedChanges) return actions.saveNow()
   })
 
-  // Both layouts are the same form, but the plain inputs (title, invoice
-  // number, mileage) are remounted by the switch and would come back showing
-  // the saved value. So what has been typed is saved first.
-  const switchLayout = useCallback(
-    async (next: WorkOrderLayout, source: 'banner' | 'menu' | 'footer') => {
-      // Which way people go, and from where, is what decides when the classic
-      // page can be retired: a switch back is the signal that something is
-      // missing from the new one.
-      track('work_order:layout_switch', {
-        from_layout: next === 'modern' ? 'classic' : 'modern',
-        to_layout: next,
-        source,
-      })
-      registerAnalyticsProperties({ work_order_layout: next })
-      if (formState.hasUnsavedChanges) await actions.saveNow()
-      rememberWorkOrderLayout(next)
-      setLayout(next)
-    },
-    [formState.hasUnsavedChanges, actions]
-  )
-
   const previewInvoice = async () => {
     // No expired-dates prompt here: previewing is just looking, and the
     // check still runs on download, email, and share.
@@ -491,8 +443,7 @@ export function ServicePageClient({
     actions.setShowShareDialog(true)
   }
 
-  // The classic page gives status reports a tab; the overhauled one has no
-  // tabs and draws them as a section under the job. Same list either way.
+  // Status reports are a section under the job, outside the form.
   const statusReportList = (
     <StatusReportList
       serviceRecordId={record.id}
@@ -516,8 +467,7 @@ export function ServicePageClient({
     />
   )
 
-  // What can be done with the job as a whole. The classic header and the
-  // overhauled page's own top carry the same cluster, fed from here.
+  // What can be done with the job as a whole, drawn in the header.
   const headerActionProps = {
     meetingUrl: videoCall.link?.url ?? null,
     downloading: actions.downloading,
@@ -547,16 +497,11 @@ export function ServicePageClient({
     designMenu: canChangeIssuedDesign ? (
       <InvoiceDesignMenu recordId={record.id} designFollowsName={designFollowsName} />
     ) : undefined,
-    layout,
-    onSwitchLayout: () => void switchLayout(modern ? 'classic' : 'modern', 'menu'),
   }
 
-  // One set of props for each column, handed to whichever layout is showing:
-  // the classic two columns take them as they are, the overhauled page takes
-  // both and lays the same sections out its own way.
   // The invoice's link as a code for the customer at the desk. One element,
-  // drawn wherever a payment is taken: beside "Record payment" on both
-  // layouts, and on the overhauled page's money bar.
+  // drawn wherever a payment is taken: beside "Record payment" and on the
+  // money bar.
   const payCode = onlinePayments ? (
     <PayCodeButton
       serviceRecordId={record.id}
@@ -567,7 +512,8 @@ export function ServicePageClient({
     />
   ) : null
 
-  const leftColumnProps: ComponentProps<typeof DetailsLeftColumn> = {
+  // The page's body in two halves: the job itself, and the sidebar beside it.
+  const jobProps: WorkOrderJobProps = {
     formState,
     actions,
     record,
@@ -595,7 +541,7 @@ export function ServicePageClient({
     payCode,
   }
 
-  const rightColumnProps: ComponentProps<typeof DetailsRightColumn> = {
+  const sidebarProps: WorkOrderSidebarProps = {
     videoCall,
     smsEnabled,
     emailEnabled,
@@ -641,59 +587,37 @@ export function ServicePageClient({
       <LineTaxProvider value={{ ...lineTax, untaxedDocument: formState.taxRate <= 0 }}>
         <WorkshopTaxOfferProvider value={taxOfferValue}>
           <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
-            {modern ? (
-              <ModernHero
-                record={record}
-                status={formState.status}
-                paymentStatus={formState.paymentStatus}
-                warranty={formState.warranty}
-                actions={
-                  <>
-                    <PresenceChips kind="serviceRecord" id={record.id} className="mr-1" />
-                    <ServiceHeaderActions showSave {...headerActionProps} />
-                  </>
-                }
-                title={title}
-                onTitleChange={(next) => {
-                  setTitle(next)
-                  // Saved the moment the field is left, on its own, not with the
-                  // form's autosave five seconds later: a rename followed by the
-                  // back arrow inside those seconds was lost. Only a refused save
-                  // marks the form dirty, so the ordinary path can try again.
-                  void updateServiceRecordTitle(record.id, next).then((result) => {
-                    if (result.success) {
-                      formState.flashSaved()
-                    } else {
-                      toast.error(result.error || t('page.failedUpdate'))
-                      formState.markDirty()
-                    }
-                  })
-                }}
-                type={record.vehicle ? formState.type : undefined}
-                onTypeChange={formState.dirtySetType}
-                locked={lockState.locked}
-              />
-            ) : (
-              <UnifiedServiceHeader
-                presence={<PresenceChips kind="serviceRecord" id={record.id} />}
-                vehicleId={vehicleId}
-                vehicleName={formState.vehicleName}
-                title={record.title}
-                status={formState.status}
-                paymentStatus={formState.paymentStatus}
-                activeTab={activeTab}
-                onTabChange={handleTabChange}
-                tabCounts={{
-                  images: imageAttachmentsForManager.length,
-                  video: videoAttachments.length,
-                  documents: documentAttachments.length,
-                  statusReports: statusReports.length,
-                }}
-                {...headerActionProps}
-              />
-            )}
-
-            {!modern && <TryNewLayoutBanner onTry={() => void switchLayout('modern', 'banner')} />}
+            <ModernHero
+              record={record}
+              status={formState.status}
+              paymentStatus={formState.paymentStatus}
+              warranty={formState.warranty}
+              actions={
+                <>
+                  <PresenceChips kind="serviceRecord" id={record.id} className="mr-1" />
+                  <ServiceHeaderActions showSave {...headerActionProps} />
+                </>
+              }
+              title={title}
+              onTitleChange={(next) => {
+                setTitle(next)
+                // Saved the moment the field is left, on its own, not with the
+                // form's autosave five seconds later: a rename followed by the
+                // back arrow inside those seconds was lost. Only a refused save
+                // marks the form dirty, so the ordinary path can try again.
+                void updateServiceRecordTitle(record.id, next).then((result) => {
+                  if (result.success) {
+                    formState.flashSaved()
+                  } else {
+                    toast.error(result.error || t('page.failedUpdate'))
+                    formState.markDirty()
+                  }
+                })
+              }}
+              type={record.vehicle ? formState.type : undefined}
+              onTypeChange={formState.dirtySetType}
+              locked={lockState.locked}
+            />
 
             <LaborAddedBanner
               count={formState.laborAddedElsewhere.length}
@@ -711,132 +635,64 @@ export function ServicePageClient({
               </div>
             )}
 
-            {activeTab === 'details' && (
-              <WorkOrderLayoutProvider value={layout}>
-                {modern ? (
-                  <ModernDetails
-                    {...leftColumnProps}
-                    {...rightColumnProps}
-                    workOrderStatuses={workOrderStatuses}
-                    canAddStatus={canAddStatus}
-                    onNotifyForStatus={handleNotifyForStatus}
-                    locked={lockState.locked}
-                    lockedLabel={t('invoice.lockedFieldsetLabel')}
-                    form={{
-                      id: 'service-record-form',
-                      ref: formState.formRef,
-                      onSubmit: actions.handleSubmit,
-                      onInput: formState.markDirty,
-                    }}
-                    files={{
-                      images: imageAttachmentsForManager,
-                      dropoff: dropoffAttachments,
-                      conditionMap,
-                      videos: videoAttachments,
-                      documents: documentAttachments,
-                      maxImages: maxImagesPerService,
-                      maxDiagnostics: maxDiagnosticsPerService,
-                      maxDocuments: maxDocumentsPerService,
-                      // A link to one of the classic tabs opens that tab here.
-                      initialTab:
-                        isFileTab || storedTab === 'statusReports' ? storedTab : undefined,
-                      statusReports: { count: statusReports.length, list: statusReportList },
-                    }}
-                    onPreviewInvoice={previewInvoice}
-                    onSendToCustomer={shareInvoice}
-                    onBackToClassic={() => void switchLayout('classic', 'footer')}
-                    title={title}
-                    aiTranscription={aiTranscription}
-                    dictationMode={dictationMode}
-                    onAddFindingForConcern={(concernId) =>
-                      obsControlsRef.current?.onAddFinding(concernId)
-                    }
-                    scrollToFiles={isFileTab || storedTab === 'statusReports'}
-                  />
-                ) : (
-                  // noValidate, and the rules checked in handleSubmit instead. An
-                  // autosave submits through requestSubmit, which native validation
-                  // stops with no message and no request; and the rules that matter
-                  // most here — a priced part with no name, labour with hours and no
-                  // description — are not ones a `required` attribute can state.
-                  <form
-                    id="service-record-form"
-                    ref={formState.formRef}
-                    onSubmit={actions.handleSubmit}
-                    onInput={formState.markDirty}
-                    className="flex min-h-0 flex-1 flex-col"
-                    noValidate
-                  >
-                    {/* A locked invoice offers no editing at all, rather than letting
-                  someone retype a line and meet the refusal on save. The
-                  fieldset disables every control inside it natively;
-                  display:contents keeps the layout exactly as it was. */}
-                    <fieldset
-                      disabled={lockState.locked}
-                      className="contents"
-                      aria-label={lockState.locked ? t('invoice.lockedFieldsetLabel') : undefined}
-                    >
-                      <ServiceDetailContent
-                        leftColumn={<DetailsLeftColumn {...leftColumnProps} />}
-                        rightColumn={<DetailsRightColumn {...rightColumnProps} />}
-                      />
-                    </fieldset>
-                  </form>
-                )}
-                {vehicleId && (
-                  <ObservationsManager
-                    vehicleId={vehicleId}
-                    serviceRecordId={record.id}
-                    openObservations={openObservations}
-                    onAddObservations={handleAddObservationsToWorkOrder}
-                    addingObservations={addingObservations}
-                    // Saved concerns only: a row still being typed has no id yet, so
-                    // there is nothing a finding could point at.
-                    concerns={formState.concerns.filter(
-                      (c): c is { id: string; description: string; sortOrder: number } =>
-                        Boolean(c.id) && Boolean(c.description.trim())
-                    )}
-                    onControlsReady={(c) => {
-                      obsControlsRef.current = c
-                    }}
-                  />
-                )}
-              </WorkOrderLayoutProvider>
-            )}
-
-            {activeTab === 'images' && (
-              <div className="flex-1 overflow-y-auto overscroll-contain p-4">
-                <ServiceImagesManager
-                  serviceRecordId={record.id}
-                  initialImages={imageAttachmentsForManager}
-                  maxImages={maxImagesPerService}
-                  customerId={customer?.id}
-                />
-              </div>
-            )}
-
-            {activeTab === 'video' && (
-              <div className="flex-1 overflow-y-auto overscroll-contain p-4">
-                <ServiceVideoManager serviceRecordId={record.id} initialVideos={videoAttachments} />
-              </div>
-            )}
-
-            {activeTab === 'documents' && (
-              <div className="flex-1 overflow-y-auto overscroll-contain p-4">
-                <ServiceDocumentsManager
+            <WorkOrderLayoutProvider value={true}>
+              <ModernDetails
+                {...jobProps}
+                {...sidebarProps}
+                workOrderStatuses={workOrderStatuses}
+                canAddStatus={canAddStatus}
+                onNotifyForStatus={handleNotifyForStatus}
+                locked={lockState.locked}
+                lockedLabel={t('invoice.lockedFieldsetLabel')}
+                form={{
+                  id: 'service-record-form',
+                  ref: formState.formRef,
+                  onSubmit: actions.handleSubmit,
+                  onInput: formState.markDirty,
+                }}
+                files={{
+                  images: imageAttachmentsForManager,
+                  dropoff: dropoffAttachments,
+                  conditionMap,
+                  videos: videoAttachments,
+                  documents: documentAttachments,
+                  maxImages: maxImagesPerService,
+                  maxDiagnostics: maxDiagnosticsPerService,
+                  maxDocuments: maxDocumentsPerService,
+                  // A link with `?tab=` opens that tab here.
+                  initialTab:
+                    isFileTab || requestedTab === 'statusReports' ? requestedTab : undefined,
+                  statusReports: { count: statusReports.length, list: statusReportList },
+                }}
+                onPreviewInvoice={previewInvoice}
+                onSendToCustomer={shareInvoice}
+                title={title}
+                aiTranscription={aiTranscription}
+                dictationMode={dictationMode}
+                onAddFindingForConcern={(concernId) =>
+                  obsControlsRef.current?.onAddFinding(concernId)
+                }
+                scrollToFiles={isFileTab || requestedTab === 'statusReports'}
+              />
+              {vehicleId && (
+                <ObservationsManager
+                  vehicleId={vehicleId}
                   serviceRecordId={record.id}
-                  initialDocuments={documentAttachments}
-                  maxDiagnostics={maxDiagnosticsPerService}
-                  maxDocuments={maxDocumentsPerService}
+                  openObservations={openObservations}
+                  onAddObservations={handleAddObservationsToWorkOrder}
+                  addingObservations={addingObservations}
+                  // Saved concerns only: a row still being typed has no id yet, so
+                  // there is nothing a finding could point at.
+                  concerns={formState.concerns.filter(
+                    (c): c is { id: string; description: string; sortOrder: number } =>
+                      Boolean(c.id) && Boolean(c.description.trim())
+                  )}
+                  onControlsReady={(c) => {
+                    obsControlsRef.current = c
+                  }}
                 />
-              </div>
-            )}
-
-            {activeTab === 'statusReports' && (
-              <div className="flex-1 overflow-y-auto overscroll-contain p-4">
-                {statusReportList}
-              </div>
-            )}
+              )}
+            </WorkOrderLayoutProvider>
 
             <InventoryPickerDialog
               open={formState.showInventoryPicker}

+ 0 - 4
src/features/vehicles/Components/service-page/ServiceRecordPage.tsx

@@ -52,7 +52,6 @@ import { PageHeader } from '@/components/page-header'
 import { getLocale, getTranslations } from 'next-intl/server'
 import { workshopTimeZone } from '@/lib/workshop-timezone'
 import { addZonedDays, zonedDayKey } from '@/lib/timezone'
-import { resolveWorkOrderLayout } from '@/lib/work-order-layout.server'
 
 /**
  * Shared server component behind both service-record routes:
@@ -81,7 +80,6 @@ export async function ServiceRecordPage({
     videoCallResult,
     designOptionsResult,
     jobClockResult,
-    initialLayout,
     workOrderStatusesResult,
   ] = await Promise.all([
     getServiceRecord(serviceId),
@@ -111,7 +109,6 @@ export async function ServiceRecordPage({
     getServiceVideoCall(serviceId),
     listDesignOptions('invoice'),
     getJobClock(serviceId),
-    resolveWorkOrderLayout(),
     listWorkOrderStatuses(),
   ])
 
@@ -495,7 +492,6 @@ export async function ServiceRecordPage({
             : { entries: [], viewerTechnicianIds: [], canEdit: false, timeZone: 'UTC' }
         }
         designPinnedAt={designPinnedAt}
-        initialLayout={initialLayout}
       />
     </div>
   )

+ 0 - 69
src/features/vehicles/Components/service-page/TryNewLayoutBanner.tsx

@@ -1,69 +0,0 @@
-'use client'
-
-import { useEffect, useState } from 'react'
-import { useTranslations } from 'next-intl'
-import { Sparkles, X } from 'lucide-react'
-import { Button } from '@/components/ui/button'
-import { track } from '@/lib/analytics'
-
-const DISMISSED_KEY = 'torqvoice:workOrderLayoutHintDismissed'
-
-/**
- * The invitation on the classic work order page, which a browser only shows
- * once it has asked to go back to it. Closing it is remembered in this
- * browser; the way in stays in the page's "more" menu for whoever changes
- * their mind.
- *
- * Drawn only after mount: whether it was dismissed is in localStorage, which
- * the server cannot see, and a banner that renders and then vanishes on
- * hydration would shift the whole form down and back.
- */
-export function TryNewLayoutBanner({ onTry }: { onTry: () => void }) {
-  const t = useTranslations('service.modern.invite')
-  const [visible, setVisible] = useState(false)
-
-  useEffect(() => {
-    try {
-      setVisible(localStorage.getItem(DISMISSED_KEY) !== 'true')
-    } catch {
-      setVisible(true)
-    }
-  }, [])
-
-  if (!visible) return null
-
-  const dismiss = () => {
-    setVisible(false)
-    // Closed without trying it: the other half of how well the invitation works.
-    track('work_order:layout_invite_dismiss')
-    try {
-      localStorage.setItem(DISMISSED_KEY, 'true')
-    } catch {
-      // Private mode: it comes back next visit, which is no harm.
-    }
-  }
-
-  return (
-    <div
-      data-testid="try-new-layout"
-      className="flex shrink-0 flex-wrap items-center gap-x-3 gap-y-2 border-b border-primary/20 bg-primary/10 px-4 py-2"
-    >
-      <Sparkles className="h-4 w-4 shrink-0 text-primary" aria-hidden="true" />
-      <p className="min-w-0 flex-1 text-sm">
-        <span className="font-medium">{t('title')}</span>{' '}
-        <span className="text-muted-foreground">{t('body')}</span>
-      </p>
-      <Button type="button" size="sm" className="h-8" onClick={onTry}>
-        {t('try')}
-      </Button>
-      <button
-        type="button"
-        onClick={dismiss}
-        aria-label={t('dismiss')}
-        className="cursor-pointer rounded-md p-1 text-muted-foreground transition-colors hover:bg-background/60 hover:text-foreground"
-      >
-        <X className="h-4 w-4" />
-      </button>
-    </div>
-  )
-}

+ 0 - 342
src/features/vehicles/Components/service-page/UnifiedServiceHeader.tsx

@@ -1,342 +0,0 @@
-'use client'
-
-import { useTranslations } from 'next-intl'
-import Link from 'next/link'
-import { Badge } from '@/components/ui/badge'
-import { ButtonGroup } from '@/components/ui/button-group'
-import { IconActionButton } from '@/components/icon-action-button'
-import { Button } from '@/components/ui/button'
-import {
-  DropdownMenu,
-  DropdownMenuContent,
-  DropdownMenuItem,
-  DropdownMenuSeparator,
-  DropdownMenuTrigger,
-} from '@/components/ui/dropdown-menu'
-import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
-import { cn } from '@/lib/utils'
-import {
-  ArrowLeft,
-  Download,
-  Eye,
-  Globe,
-  LayoutTemplate,
-  Mail,
-  MessageSquare,
-  MoreVertical,
-  Save,
-  Trash2,
-  Video,
-  Printer,
-} from 'lucide-react'
-import {
-  paymentStatusColors,
-  paymentStatusLabels,
-  statusColors,
-  statusMessageKeys,
-} from '../service-detail/types'
-import type { WorkOrderLayout } from '@/lib/work-order-layout'
-
-export type ServiceTab = 'details' | 'images' | 'video' | 'documents' | 'statusReports'
-
-export interface TabCounts {
-  images: number
-  video: number
-  documents: number
-  statusReports: number
-}
-
-interface UnifiedServiceHeaderProps {
-  vehicleId: string | null
-  vehicleName: string
-  title: string
-  status: string
-  paymentStatus: string
-  activeTab: ServiceTab
-  onTabChange: (tab: ServiceTab) => void
-  tabCounts: TabCounts
-  downloading: boolean
-  saving: boolean
-  hasUnsavedChanges?: boolean
-  showSaved?: boolean
-  onDownloadPDF: () => void
-  onPreviewPDF: () => void
-  /** The work order as a sheet to print and sign, opened in the preview. */
-  onPrintWorkOrder?: () => void
-  onDelete: () => void
-  onShowEmail: () => void
-  onShowShare: () => void
-  onNotifyCustomer?: () => void
-  hasCustomer?: boolean
-  /** The invoice-design submenu, when this invoice has a frozen look to change. */
-  designMenu?: React.ReactNode
-  /** Who else has this job open, drawn before the actions. */
-  presence?: React.ReactNode
-  /** Video call link from a connected calendar, when one exists. */
-  meetingUrl?: string | null
-  /** Which page this sits on, so the menu can offer the way to the other one. */
-  layout?: WorkOrderLayout
-  /** Moves this browser to the other layout. */
-  onSwitchLayout?: () => void
-}
-
-export function UnifiedServiceHeader({
-  vehicleId,
-  vehicleName,
-  title,
-  status,
-  paymentStatus,
-  activeTab,
-  onTabChange,
-  tabCounts,
-  downloading,
-  saving,
-  hasUnsavedChanges = false,
-  showSaved = false,
-  onDownloadPDF,
-  onPreviewPDF,
-  onPrintWorkOrder,
-  onDelete,
-  onShowEmail,
-  onShowShare,
-  onNotifyCustomer,
-  hasCustomer = false,
-  designMenu,
-  presence,
-  meetingUrl = null,
-  layout = 'classic',
-  onSwitchLayout,
-}: UnifiedServiceHeaderProps) {
-  const t = useTranslations('service.header')
-  const tPreview = useTranslations('common.pdfPreview')
-  const tStatus = useTranslations('service.basicInfo.statusOptions')
-  const tabs: { label: string; value: ServiceTab }[] = [
-    { label: t('tabs.details'), value: 'details' },
-    { label: t('tabs.images'), value: 'images' },
-    { label: t('tabs.video'), value: 'video' },
-    { label: t('tabs.documents'), value: 'documents' },
-    { label: t('tabs.statusReports'), value: 'statusReports' },
-  ]
-
-  return (
-    <div className="shrink-0 border-b bg-background px-4 pt-2 pb-0">
-      <div className="mb-2 flex flex-wrap items-center justify-between gap-2">
-        <Link
-          href={vehicleId ? `/vehicles/${vehicleId}` : '/work-orders'}
-          className="flex min-w-0 items-center gap-3 text-foreground transition-colors hover:text-muted-foreground"
-        >
-          <ArrowLeft className="h-4 w-4 shrink-0" />
-          <div className="min-w-0">
-            <div className="flex flex-wrap items-center gap-1.5">
-              <Badge
-                variant="outline"
-                className={`shrink-0 text-xs ${paymentStatusColors[paymentStatus] || ''}`}
-              >
-                {paymentStatusLabels[paymentStatus] || t('unpaid')}
-              </Badge>
-              <Badge variant="outline" className={`shrink-0 text-xs ${statusColors[status] || ''}`}>
-                {statusMessageKeys[status] ? tStatus(statusMessageKeys[status]) : status}
-              </Badge>
-              <h1 className="truncate text-lg font-semibold leading-tight">{title}</h1>
-            </div>
-            <p className="truncate text-xs text-muted-foreground">{vehicleName}</p>
-          </div>
-        </Link>
-        {presence}
-        <ServiceHeaderActions
-          showSave={activeTab === 'details'}
-          downloading={downloading}
-          saving={saving}
-          hasUnsavedChanges={hasUnsavedChanges}
-          showSaved={showSaved}
-          onDownloadPDF={onDownloadPDF}
-          onPreviewPDF={onPreviewPDF}
-          onPrintWorkOrder={onPrintWorkOrder}
-          onDelete={onDelete}
-          onShowEmail={onShowEmail}
-          onShowShare={onShowShare}
-          onNotifyCustomer={onNotifyCustomer}
-          hasCustomer={hasCustomer}
-          designMenu={designMenu}
-          meetingUrl={meetingUrl}
-          layout={layout}
-          onSwitchLayout={onSwitchLayout}
-        />
-      </div>
-      <nav className="flex gap-1 border-b overflow-x-auto scrollbar-none">
-        {tabs.map((tab) => (
-          <button
-            key={tab.value}
-            type="button"
-            onClick={() => onTabChange(tab.value)}
-            className={cn(
-              'cursor-pointer shrink-0 whitespace-nowrap px-3 py-1.5 text-sm font-medium transition-colors -mb-px border-b-2',
-              activeTab === tab.value
-                ? 'border-primary text-foreground'
-                : 'border-transparent text-muted-foreground hover:text-foreground hover:border-muted-foreground/50'
-            )}
-          >
-            {tab.label}
-            {tab.value !== 'details' && tabCounts[tab.value] > 0 && (
-              <span className="ml-1 text-xs text-muted-foreground">({tabCounts[tab.value]})</span>
-            )}
-          </button>
-        ))}
-      </nav>
-    </div>
-  )
-}
-
-type ServiceHeaderActionsProps = Pick<
-  UnifiedServiceHeaderProps,
-  | 'downloading'
-  | 'saving'
-  | 'hasUnsavedChanges'
-  | 'showSaved'
-  | 'onDownloadPDF'
-  | 'onPreviewPDF'
-  | 'onPrintWorkOrder'
-  | 'onDelete'
-  | 'onShowEmail'
-  | 'onShowShare'
-  | 'onNotifyCustomer'
-  | 'hasCustomer'
-  | 'designMenu'
-  | 'meetingUrl'
-  | 'layout'
-  | 'onSwitchLayout'
-> & {
-  /** Save belongs to the details form; the other tabs save as they go. */
-  showSave: boolean
-}
-
-/**
- * Everything that can be done with the job as a whole: save it, look at the
- * invoice, send it, tell the customer, and the rest behind the menu. One
- * component, because the classic header and the overhauled page's own top
- * both carry it and must not drift apart.
- */
-export function ServiceHeaderActions({
-  showSave,
-  downloading,
-  saving,
-  hasUnsavedChanges = false,
-  showSaved = false,
-  onDownloadPDF,
-  onPreviewPDF,
-  onPrintWorkOrder,
-  onDelete,
-  onShowEmail,
-  onShowShare,
-  onNotifyCustomer,
-  hasCustomer = false,
-  designMenu,
-  meetingUrl = null,
-  layout = 'classic',
-  onSwitchLayout,
-}: ServiceHeaderActionsProps) {
-  const t = useTranslations('service.header')
-  const tPreview = useTranslations('common.pdfPreview')
-  const tLayout = useTranslations('service.modern')
-  const modern = layout === 'modern'
-
-  return (
-    <div className="flex shrink-0 flex-wrap items-center gap-2">
-      {showSave && (
-        <>
-          {hasUnsavedChanges && (
-            <span className="text-xs font-medium text-amber-600 dark:text-amber-400">
-              {t('unsavedChanges')}
-            </span>
-          )}
-          {showSaved && !hasUnsavedChanges && (
-            <span className="text-xs font-medium text-green-600 dark:text-green-400">
-              {t('saved')}
-            </span>
-          )}
-          <IconActionButton
-            type="submit"
-            form="service-record-form"
-            label={t('save')}
-            icon={Save}
-            loading={saving}
-            variant={hasUnsavedChanges ? 'default' : 'outline'}
-            className={hasUnsavedChanges ? 'animate-pulse' : ''}
-          />
-        </>
-      )}
-      <ButtonGroup>
-        <IconActionButton label={tPreview('preview')} icon={Eye} onClick={onPreviewPDF} />
-        <IconActionButton
-          label={t('pdf')}
-          icon={Download}
-          loading={downloading}
-          onClick={onDownloadPDF}
-        />
-        <IconActionButton label={t('email')} icon={Mail} onClick={onShowEmail} />
-        <IconActionButton label={t('share')} icon={Globe} onClick={onShowShare} />
-        {meetingUrl && (
-          <IconActionButton
-            label={t('joinCall')}
-            icon={Video}
-            onClick={() => window.open(meetingUrl, '_blank', 'noopener')}
-          />
-        )}
-        {hasCustomer && onNotifyCustomer && (
-          <IconActionButton label={t('notify')} icon={MessageSquare} onClick={onNotifyCustomer} />
-        )}
-        {/* The row already carries six actions in twelve languages. New
-            ones go in here rather than widening it, and Delete moved in
-            with them: a destructive button one pixel from Share is a
-            misclick waiting to happen. */}
-        <DropdownMenu>
-          <Tooltip>
-            <TooltipTrigger asChild>
-              <DropdownMenuTrigger asChild>
-                <Button
-                  type="button"
-                  variant="outline"
-                  size="icon-sm"
-                  aria-label={t('moreActions')}
-                >
-                  <MoreVertical className="size-4" aria-hidden="true" />
-                </Button>
-              </DropdownMenuTrigger>
-            </TooltipTrigger>
-            <TooltipContent>{t('moreActions')}</TooltipContent>
-          </Tooltip>
-          <DropdownMenuContent align="end" className="min-w-56">
-            {onPrintWorkOrder && (
-              <>
-                <DropdownMenuItem onClick={onPrintWorkOrder}>
-                  <Printer className="mr-2 size-4" aria-hidden="true" />
-                  {t('printWorkOrder')}
-                </DropdownMenuItem>
-                <DropdownMenuSeparator />
-              </>
-            )}
-            {designMenu && (
-              <>
-                {designMenu}
-                <DropdownMenuSeparator />
-              </>
-            )}
-            {onSwitchLayout && (
-              <>
-                <DropdownMenuItem onClick={onSwitchLayout}>
-                  <LayoutTemplate className="mr-2 size-4" aria-hidden="true" />
-                  {modern ? tLayout('backToClassic') : tLayout('invite.menu')}
-                </DropdownMenuItem>
-                <DropdownMenuSeparator />
-              </>
-            )}
-            <DropdownMenuItem className="text-destructive" onClick={onDelete}>
-              <Trash2 className="mr-2 size-4" aria-hidden="true" />
-              {t('delete')}
-            </DropdownMenuItem>
-          </DropdownMenuContent>
-        </DropdownMenu>
-      </ButtonGroup>
-    </div>
-  )
-}

+ 8 - 9
src/features/vehicles/Components/service-page/modern/FilesMediaCard.tsx

@@ -48,8 +48,7 @@ interface FilesMediaCardProps {
   /**
    * Status reports: the photos and video the workshop sends the customer
    * while the job is open. They are media about the job, so they live here
-   * with the rest of it. The list is the same one the classic page gives a
-   * tab of its own.
+   * with the rest of it. The list is the one a `?tab=statusReports` link opens.
    */
   statusReports?: { count: number; list: ReactNode }
 }
@@ -58,16 +57,16 @@ interface FilesMediaCardProps {
  * Everything filed on the job in one card: photos, documents, diagnostic
  * reports, video, and the status reports sent to the customer. It opens on
  * all of the files together, since the question is usually "what is on this
- * job", and the tabs after it are the same files one kind at a time. The classic
- * page gives most of these a tab of its own at the top of the page; here they
- * are tabs of one card, each a grid of tiles with the way to add one at the end.
+ * job", and the tabs after it are the same files one kind at a time. They used
+ * to be tabs at the top of the page; here they are tabs of one card, each a
+ * grid of tiles with the way to add one at the end.
  *
  * The card is drawn in the middle of the work order form without belonging to
  * it. A file is saved the moment it is chosen and a caption the moment it is
  * left, so neither may mark the job as having unsaved changes, Enter in a
  * caption must not save the job, and a status report's own form must not
  * submit this one; all three stop here. It also sits outside the form's
- * fieldset: a locked invoice still takes photos, as the classic tabs allowed.
+ * fieldset: a locked invoice still takes photos, as the old tabs allowed.
  */
 export function FilesMediaCard({
   serviceRecordId,
@@ -127,8 +126,8 @@ export function FilesMediaCard({
     }
   }
 
-  // The page hands over diagnostics and documents as one list, as the classic
-  // tab shows them; here each has a tab of its own.
+  // The page hands over diagnostics and documents as one list, as the old
+  // documents tab showed them; here each has a tab of its own.
   const diagnostics = documents.filter((file) => file.category === 'diagnostic')
   const otherDocuments = documents.filter((file) => file.category !== 'diagnostic')
 
@@ -139,7 +138,7 @@ export function FilesMediaCard({
     (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
   )
 
-  // A tab under a plan cap says how much of it is used, as the classic photo tab did.
+  // A tab under a plan cap says how much of it is used, as the old photo tab did.
   const tabs: { value: FileTab; label: string; count: number; max?: number }[] = [
     { value: 'all', label: t('modern.media.tabs.all'), count: total },
     {

+ 5 - 6
src/features/vehicles/Components/service-page/modern/JobFactsCard.tsx

@@ -147,9 +147,9 @@ function CopyPlate({ plate, label }: { plate: string; label: string }) {
 }
 
 /**
- * Who the job is for and what it is on, the first thing on the overhauled
- * page. It stands in for the classic "Customer & Vehicle" panel and carries
- * the same fields that panel owns: the mileage input, the hidden technician
+ * Who the job is for and what it is on, the first thing on the page. It took
+ * over from the "Customer & Vehicle" panel and carries the fields that panel
+ * owned: the mileage input, the hidden technician
  * name and the vehicle picker, each still in the document exactly once.
  *
  * The customer is read here and edited on the customer's own page; a work
@@ -284,9 +284,8 @@ export function JobFactsCard({
           )}
         </div>
 
-        {/* A counter sale has no vehicle and nothing to pick: the classic page
-            shows the picker regardless, and choosing one there turns the sale
-            into a vehicle job, so the way to do that stays. */}
+        {/* A counter sale has no vehicle and nothing to pick, but choosing one
+            turns the sale into a vehicle job, so the picker stays. */}
         <div className="order-3 space-y-2 border-t border-card-edge px-4 py-3 @xl:order-none @xl:border-t-0">
           <div className="flex items-center justify-between gap-2">
             <h3 className="font-display text-sm font-semibold tracking-tight">

+ 3 - 3
src/features/vehicles/Components/service-page/modern/MediaGrid.tsx

@@ -142,7 +142,7 @@ interface MediaGridProps {
  * One kind of file on the job, as tiles: what it is, what it is called, when
  * it arrived, and whether the customer gets to see it. The last tile is the
  * way in, for a click or a drop. The same uploads, limits and actions as the
- * classic page's tabs; only the drawing is new.
+ * old tabs had; only the drawing is new.
  *
  * "Customer can see" is the file's include-on-invoice switch under the name
  * the customer would give it: a marked file is printed on the invoice and
@@ -252,7 +252,7 @@ export function MediaGrid({
           fileSize: data.fileSize,
           category: target,
           // Shown to the customer unless somebody says otherwise, as the
-          // classic tabs do. For a video that means the shared link, which
+          // old tabs did. For a video that means the shared link, which
           // plays it; the printed invoice has nothing to draw for one.
           // A drop-off photo is the workshop's own record, so it starts hidden.
           includeInInvoice: target !== 'dropoff',
@@ -336,7 +336,7 @@ export function MediaGrid({
                 </button>
               ) : isVideo ? (
                 // The first frame as the tile, and a player a click away:
-                // the classic tab played video on the page, not in a new tab.
+                // the old tab played video on the page, not in a new tab.
                 <button
                   type="button"
                   onClick={() => setPlaying(file)}

+ 16 - 28
src/features/vehicles/Components/service-page/modern/ModernDetails.tsx

@@ -29,10 +29,9 @@ import { NotesSection } from '../../service-edit/NotesSection'
 import { PartsEditor } from '../../service-edit/PartsEditor'
 import { ScheduleTimesSection } from '../../service-edit/ScheduleTimesSection'
 import { TotalsSection } from '../../service-edit/TotalsSection'
-import type { DetailsLeftColumn } from '../DetailsLeftColumn'
-import type { DetailsRightColumn } from '../DetailsRightColumn'
 import { VideoCallSection } from '../VideoCallSection'
 import { WarrantySection } from '../WarrantySection'
+import type { WorkOrderJobProps, WorkOrderSidebarProps } from './details-props'
 import { ActivityCard } from './ActivityCard'
 import { FilesMediaCard } from './FilesMediaCard'
 import { JobFactsCard } from './JobFactsCard'
@@ -40,10 +39,9 @@ import { MoneyBar, MoneyLine } from './MoneyBar'
 import type { WorkOrderStatusOption } from '@/features/work-order-statuses/Lib/stages'
 import { StatusStepper } from './StatusStepper'
 
-type LeftProps = ComponentProps<typeof DetailsLeftColumn>
-type RightProps = ComponentProps<typeof DetailsRightColumn>
-
-export interface ModernDetailsProps extends LeftProps, Omit<RightProps, keyof LeftProps> {
+export interface ModernDetailsProps
+  extends WorkOrderJobProps,
+    Omit<WorkOrderSidebarProps, keyof WorkOrderJobProps> {
   /** The workshop's own statuses, for the menus on the stepper. */
   workOrderStatuses?: WorkOrderStatusOption[]
   /** The viewer may change settings, so the status menus offer to add a status. */
@@ -65,9 +63,8 @@ export interface ModernDetailsProps extends LeftProps, Omit<RightProps, keyof Le
   form: Pick<ComponentProps<'form'>, 'id' | 'ref' | 'onSubmit' | 'onInput'>
   /** Sections under the two columns that save on their own, outside the form. */
   belowForm?: ReactNode
-  /** A link that used to open one of the classic tabs lands on the files card, with that tab open. */
+  /** A link with a `?tab=` query lands on the files card, with that tab open. */
   scrollToFiles?: boolean
-  onBackToClassic: () => void
   /** The job's title as the header currently holds it, saved with the form. */
   title: string
   /** The connected AI vendor can transcribe speech, so dictation goes through it. */
@@ -81,10 +78,10 @@ export interface ModernDetailsProps extends LeftProps, Omit<RightProps, keyof Le
 }
 
 /**
- * Disables what is inside it when the invoice is locked, natively, the way
- * the classic page's single fieldset does. There are several on this page
- * because the files card sits between them and must stay usable;
- * display:contents keeps each one out of the layout.
+ * Disables what is inside it when the invoice is locked, natively. There are
+ * several on this page rather than one around it all, because the files card
+ * sits between them and must stay usable; display:contents keeps each one out
+ * of the layout.
  */
 function Lockable({
   locked,
@@ -103,10 +100,10 @@ function Lockable({
 }
 
 /**
- * The details tab of the overhauled work order page.
+ * The body of the work order page.
  *
- * Every section here is the component the classic page uses, told by context
- * to draw the house card instead of its old frame. Nothing about how the job
+ * Every section here is a component the quote page shares, told by context
+ * to draw the house card instead of its own frame. Nothing about how the job
  * is saved lives in this file: the same form wraps it, the same hooks feed
  * it, and each named field is still in the document once. What changes is
  * where things sit: who and what first, then the work in the order it is
@@ -268,7 +265,7 @@ export function ModernDetails(props: ModernDetailsProps) {
 
   return (
     <>
-      {/* One scroller for the whole page, with the classic page's padding. The
+      {/* One scroller for the whole page. The
           width is capped, but only where it starts to hurt: 1440px left empty
           bands on an ordinary desktop monitor, so the cap is 1800px, which a
           1920px screen with the app sidebar open never reaches. The container
@@ -282,8 +279,9 @@ export function ModernDetails(props: ModernDetailsProps) {
             screen; the money bar used to hold the page clear of it, and with
             the bar gone below `lg` the padding does. */}
         <div className="mx-auto flex w-full max-w-[1800px] flex-col gap-3 p-4 pb-[calc(2.5rem+58px+env(safe-area-inset-bottom))] md:pb-10">
-          {/* noValidate, and the rules checked in handleSubmit instead; see the
-            classic form in ServicePageClient. display:contents, so the form's
+          {/* noValidate, and the rules checked in handleSubmit instead: an autosave
+            submits through requestSubmit, which native validation would stop with
+            no message and no request. display:contents, so the form's
             children are laid out by this column as if it were not there. */}
           <form {...form} className="contents" noValidate>
             {/* The title is edited in the header, which is not inside this form,
@@ -597,16 +595,6 @@ export function ModernDetails(props: ModernDetailsProps) {
           </form>
 
           {belowForm}
-
-          {/* The same way back as the menu's, where somebody who has scrolled
-            the whole page looking for the old one will find it. */}
-          <button
-            type="button"
-            onClick={props.onBackToClassic}
-            className="cursor-pointer self-center text-[13px] text-muted-foreground underline-offset-2 transition-colors hover:text-foreground hover:underline"
-          >
-            {t('modern.backToClassic')}
-          </button>
         </div>
       </div>
 

+ 1 - 1
src/features/vehicles/Components/service-page/modern/ModernHero.tsx

@@ -28,7 +28,7 @@ interface ModernHeroProps {
   status: string
   paymentStatus: string
   warranty: WarrantyFields
-  /** Save, preview, send and the menu: the same cluster the classic header carries. */
+  /** Save, preview, send and the menu. */
   actions: ReactNode
   /** The title as it stands, unsaved edits included, and how to change it. */
   title: string

+ 98 - 0
src/features/vehicles/Components/service-page/modern/details-props.ts

@@ -0,0 +1,98 @@
+import type { ReactNode } from 'react'
+import type { DesignAutoRule } from '@/features/invoice-designer/Lib/designRules'
+import type { ServiceVideoCall } from '@/features/integrations/Actions/integrationActions'
+import type { TireSetBannerData } from '@/features/tire-hotel/Components/TireSetBanner'
+import type { JobClock } from '@/features/time-tracking/Actions/timeClockActions'
+import type { WarrantyTexts } from '@/lib/warranty'
+import type { ServiceDetail } from '../../service-detail/types'
+import type { InventoryPartOption } from '../../service-edit/form-types'
+import type { DesignOption } from '../../service-edit/InvoiceDetailsSection'
+import type { BoardTechnicianOption, OrgMemberOption, WorkBayOption } from '../service-page-types'
+import type { useServiceActions } from '../useServiceActions'
+import type { useServiceFormState } from '../useServiceFormState'
+
+/**
+ * What the work order page hands its body, in two halves: the job itself
+ * (concerns, lines, findings, payments, the clock) and the sidebar beside it
+ * (who and what, the booking, the invoice, warranty, files). The page builds
+ * each set once; ModernDetails takes both.
+ */
+export interface WorkOrderJobProps {
+  formState: ReturnType<typeof useServiceFormState>
+  actions: ReturnType<typeof useServiceActions>
+  record: ServiceDetail
+  /** Present only when the job came out of the tire hotel. */
+  tireSet?: TireSetBannerData | null
+  tireHotelEnabled?: boolean
+  tireThresholds?: { summerReplace: number; winterReplace: number; warnMargin: number }
+  unitSystem?: 'metric' | 'imperial'
+  currencyCode: string
+  defaultLaborRate: number
+  inventoryParts: InventoryPartOption[]
+  defaultMarkupPercent?: number
+  markupAppliesToInventory?: boolean
+  hasPresets?: boolean
+  onOpenPresets?: () => void
+  onScanBarcode?: () => void
+  aiEnabled?: boolean
+  vehicleId: string | null
+  findings?: {
+    id: string
+    description: string
+    severity: string
+    status: string
+    notes: string | null
+    concernId?: string | null
+  }[]
+  onAddFinding?: () => void
+  onEditFinding?: (finding: {
+    id: string
+    description: string
+    severity: string
+    status: string
+    notes: string | null
+  }) => void
+  openObservationsCount?: number
+  onShowExistingObservations?: () => void
+  jobClock: JobClock
+  /** A locked invoice: the notes editors go read-only, which the fieldset cannot do for them. */
+  locked?: boolean
+  /** The pay code button, when online payment is connected. Drawn where payments are taken. */
+  payCode?: ReactNode
+}
+
+export interface WorkOrderSidebarProps {
+  formState: ReturnType<typeof useServiceFormState>
+  actions: ReturnType<typeof useServiceActions>
+  record: ServiceDetail
+  vehicleId: string | null
+  organizationId: string
+  currencyCode: string
+  unitSystem: 'metric' | 'imperial'
+  warrantyTexts: WarrantyTexts
+  taxEnabled: boolean
+  initialVehicle: {
+    id: string
+    make: string
+    model: string
+    year: number
+    licensePlate: string | null
+  } | null
+  boardTechnicians: BoardTechnicianOption[]
+  workBays?: WorkBayOption[]
+  orgMembers?: OrgMemberOption[]
+  notificationHistory?: {
+    id: string
+    body: string
+    status: string
+    createdAt: string
+    toNumber: string
+  }[]
+  videoCall?: ServiceVideoCall
+  smsEnabled?: boolean
+  emailEnabled?: boolean
+  telegramEnabled?: boolean
+  designOptions?: DesignOption[]
+  designFollowsName?: string | null
+  designFollowsRule?: DesignAutoRule | null
+}

+ 8 - 6
src/features/vehicles/Components/service-page/service-page-types.ts

@@ -3,7 +3,6 @@ import type { MarkType } from '@/features/condition-map/Lib/markTypes'
 import type { ShopFeeConfig } from '@/features/settings/Lib/shopFee'
 import type { LineTaxSettings } from '@/features/settings/Lib/workshopTax'
 import type { WorkOrderStatusOption } from '@/features/work-order-statuses/Lib/stages'
-import type { WorkOrderLayout } from '@/lib/work-order-layout'
 import type { DesignAutoRule } from '@/features/invoice-designer/Lib/designRules'
 import type { JobClock } from '@/features/time-tracking/Actions/timeClockActions'
 import type { ServiceVideoCall } from '@/features/integrations/Actions/integrationActions'
@@ -153,6 +152,7 @@ export interface ServicePageClientProps {
     sentVia: string | null
     sentAt: string | null
   }[]
+  /** A section to open or scroll to, from a link's `?tab=` query (see ServiceTab). */
   initialTab?: string
   /** The workshop's saved invoice designs, for the picker on the invoice. */
   designOptions?: { id: string; name: string }[]
@@ -185,11 +185,6 @@ export interface ServicePageClientProps {
     createdAt: string
     toNumber: string
   }[]
-  /**
-   * The layout this browser asked for, read from its cookie on the server so
-   * the first paint is already the right page. Classic unless somebody opted in.
-   */
-  initialLayout?: WorkOrderLayout
 }
 
 export type {
@@ -200,3 +195,10 @@ export type {
   InventoryPartOption,
   LaborPresetOption,
 }
+
+/**
+ * The sections a link may ask for with `?tab=`. The page has no tabs of its
+ * own any more; `details` is the page as it opens, the rest open or scroll
+ * to the files card. Links in notifications and status reports still send them.
+ */
+export type ServiceTab = 'details' | 'images' | 'video' | 'documents' | 'statusReports'

+ 0 - 221
src/features/vehicles/Components/service-video-manager.tsx

@@ -1,221 +0,0 @@
-'use client'
-
-import { interactiveRow } from '@/lib/interactive-row'
-import { useEffect, useState, useCallback, useRef } from 'react'
-import { useTranslations } from 'next-intl'
-import { Button } from '@/components/ui/button'
-import { Switch } from '@/components/ui/switch'
-import { AppCard } from '@/components/app-card'
-import { toast } from 'sonner'
-import { Film, Loader2, Upload, X } from 'lucide-react'
-import { addServiceAttachment } from '@/features/vehicles/Actions/addServiceAttachment'
-import { updateServiceAttachment } from '@/features/vehicles/Actions/updateServiceAttachment'
-import { deleteServiceAttachment } from '@/features/vehicles/Actions/serviceActions'
-
-interface Attachment {
-  id: string
-  fileName: string
-  fileUrl: string
-  fileType: string
-  fileSize: number
-  category: string
-  description: string | null
-  includeInInvoice: boolean
-}
-
-interface ServiceVideoManagerProps {
-  serviceRecordId: string
-  initialVideos: Attachment[]
-}
-
-function formatFileSize(bytes: number) {
-  if (bytes < 1024) return `${bytes} B`
-  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
-  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
-}
-
-export function ServiceVideoManager({ serviceRecordId, initialVideos }: ServiceVideoManagerProps) {
-  const t = useTranslations('service')
-  const [videos, setVideos] = useState<Attachment[]>(initialVideos)
-  // The list follows the job: a file added elsewhere on the page (from a
-  // concern's row) arrives with the refreshed record and belongs here too.
-  const savedKey = initialVideos.map((a) => a.id).join(',')
-  useEffect(() => {
-    setVideos(initialVideos)
-  }, [savedKey]) // eslint-disable-line react-hooks/exhaustive-deps -- keyed on the ids, not the array's identity
-  const [uploading, setUploading] = useState(false)
-  const inputRef = useRef<HTMLInputElement>(null)
-
-  const handleUpload = useCallback(
-    async (fileList: FileList | File[]) => {
-      setUploading(true)
-      const fileArr = Array.from(fileList)
-      const toastId = toast.loading(t('videos.uploadingCount', { count: fileArr.length }))
-      let successCount = 0
-
-      for (const file of fileArr) {
-        const formData = new FormData()
-        formData.append('file', file)
-
-        try {
-          const res = await fetch('/api/protected/upload/service-files', {
-            method: 'POST',
-            body: formData,
-          })
-          if (!res.ok) {
-            const err = await res.json()
-            toast.error(err.error || `Failed to upload ${file.name}`)
-            continue
-          }
-          const data = await res.json()
-
-          const result = await addServiceAttachment({
-            serviceRecordId,
-            attachment: {
-              fileName: data.fileName,
-              fileUrl: data.url,
-              fileType: data.fileType,
-              fileSize: data.fileSize,
-              category: 'video',
-              includeInInvoice: true,
-            },
-          })
-
-          if (result.success && result.data) {
-            setVideos((prev) => [...prev, result.data as Attachment])
-            successCount++
-          } else {
-            toast.error(result.error || `Failed to save ${file.name}`)
-          }
-        } catch {
-          toast.error(`Failed to upload ${file.name}`)
-        }
-      }
-
-      if (successCount > 0) {
-        toast.success(t('videos.uploadedCount', { count: successCount }), { id: toastId })
-      } else {
-        toast.error(t('videos.uploadFailed'), { id: toastId })
-      }
-      setUploading(false)
-    },
-    [serviceRecordId, t]
-  )
-
-  const handleDelete = useCallback(
-    async (attachmentId: string) => {
-      const result = await deleteServiceAttachment(attachmentId)
-      if (result.success) {
-        setVideos((prev) => prev.filter((v) => v.id !== attachmentId))
-        toast.success(t('videos.deleted'))
-      } else {
-        toast.error(result.error || t('videos.failedDelete'))
-      }
-    },
-    [t]
-  )
-
-  const handleToggleInvoice = useCallback(
-    async (attachmentId: string, checked: boolean) => {
-      setVideos((prev) =>
-        prev.map((v) => (v.id === attachmentId ? { ...v, includeInInvoice: checked } : v))
-      )
-      const result = await updateServiceAttachment({
-        id: attachmentId,
-        includeInInvoice: checked,
-      })
-      if (!result.success) {
-        setVideos((prev) =>
-          prev.map((v) => (v.id === attachmentId ? { ...v, includeInInvoice: !checked } : v))
-        )
-        toast.error(result.error || t('videos.failedUpdate'))
-      }
-    },
-    [t]
-  )
-
-  return (
-    <div className="space-y-6">
-      <AppCard icon={Film} title={t('videos.title')} contentClassName="space-y-4">
-        <div
-          onDrop={(e) => {
-            e.preventDefault()
-            if (e.dataTransfer.files.length > 0) handleUpload(e.dataTransfer.files)
-          }}
-          onDragOver={(e) => e.preventDefault()}
-          {...interactiveRow(() => inputRef.current?.click())}
-          className="flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/25 p-6 transition-colors hover:border-muted-foreground/50"
-        >
-          <Upload className="mb-2 h-8 w-8 text-muted-foreground/50" />
-          <p className="text-sm font-medium">
-            {uploading ? t('videos.uploading') : t('videos.dropzone')}
-          </p>
-          <p className="mt-1 text-xs text-muted-foreground">{t('videos.formats')}</p>
-          <input
-            ref={inputRef}
-            type="file"
-            multiple
-            accept=".mp4,.webm,.mov"
-            className="hidden"
-            onChange={(e) => {
-              if (e.target.files && e.target.files.length > 0) {
-                handleUpload(e.target.files)
-                e.target.value = ''
-              }
-            }}
-          />
-        </div>
-
-        {uploading && (
-          <div className="flex items-center gap-2 text-sm text-muted-foreground">
-            <Loader2 className="h-4 w-4 animate-spin" />
-            {t('videos.uploadingVideos')}
-          </div>
-        )}
-
-        {videos.length > 0 && (
-          <div className="space-y-4">
-            {videos.map((video) => (
-              <div key={video.id} className="overflow-hidden rounded-md border">
-                <video
-                  src={video.fileUrl}
-                  controls
-                  preload="metadata"
-                  playsInline
-                  className="w-full max-h-80"
-                />
-                <div className="flex items-center gap-3 p-2.5">
-                  <Film className="h-4 w-4 shrink-0 text-purple-500" />
-                  <div className="min-w-0 flex-1">
-                    <p className="truncate text-sm font-medium">{video.fileName}</p>
-                    <p className="text-xs text-muted-foreground">
-                      {formatFileSize(video.fileSize)}
-                    </p>
-                  </div>
-                  <label className="flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground">
-                    <Switch
-                      checked={video.includeInInvoice}
-                      onCheckedChange={(checked) => handleToggleInvoice(video.id, checked)}
-                      className="scale-75"
-                    />
-                    {t('videos.invoice')}
-                  </label>
-                  <Button
-                    type="button"
-                    variant="ghost"
-                    size="icon"
-                    className="h-7 w-7 text-muted-foreground hover:text-destructive"
-                    onClick={() => handleDelete(video.id)}
-                    aria-label={t('header.delete')}
-                  >
-                    <X className="h-3.5 w-3.5" />
-                  </Button>
-                </div>
-              </div>
-            ))}
-          </div>
-        )}
-      </AppCard>
-    </div>
-  )
-}

+ 2 - 6
src/lib/analytics.ts

@@ -19,12 +19,8 @@ import posthog from 'posthog-js'
  */
 
 export type AnalyticsEvent =
-  /** A work order opened, with the layout it was drawn in. */
+  /** A work order opened. */
   | 'work_order:page_view'
-  /** A browser moved between the classic and the overhauled work order page. */
-  | 'work_order:layout_switch'
-  /** The invitation to try the overhauled page closed without trying it. */
-  | 'work_order:layout_invite_dismiss'
   /** An account made with email and password. Google sign-ups show as workshop:create. */
   | 'account:sign_up'
   /** The onboarding form made the person's first workshop, with or without sample data. */
@@ -99,7 +95,7 @@ export function reportError(error: unknown, properties?: Properties): void {
 /**
  * Attaches properties to every later event from this browser, autocaptured
  * clicks and page views included (PostHog's super properties). For facts
- * about the browser rather than about one action: the layout it shows, say.
+ * about the browser rather than about one action: the setup step it is on, say.
  */
 export function registerAnalyticsProperties(properties: Properties): void {
   if (ready) posthog.register(properties)

+ 0 - 14
src/lib/work-order-layout.server.ts

@@ -1,14 +0,0 @@
-import 'server-only'
-
-import { cookies } from 'next/headers'
-import {
-  WORK_ORDER_LAYOUT_COOKIE,
-  parseWorkOrderLayout,
-  type WorkOrderLayout,
-} from './work-order-layout'
-
-/** The layout this browser asked for; the overhauled one unless it chose classic. */
-export async function resolveWorkOrderLayout(): Promise<WorkOrderLayout> {
-  const store = await cookies()
-  return parseWorkOrderLayout(store.get(WORK_ORDER_LAYOUT_COOKIE)?.value)
-}

+ 0 - 24
src/lib/work-order-layout.ts

@@ -1,24 +0,0 @@
-/**
- * Which work order page this browser renders: the overhauled one, or the
- * classic one for whoever has asked to go back to it.
- *
- * A cookie for the same reason the list sort is one: the page is rendered on
- * the server, and reading the choice there means the first paint is already
- * the right layout rather than the other one flashing past.
- */
-export type WorkOrderLayout = 'classic' | 'modern'
-
-export const WORK_ORDER_LAYOUT_COOKIE = 'workOrderLayout'
-
-/** A year, the same as the other remembered preferences. */
-export const WORK_ORDER_LAYOUT_COOKIE_MAX_AGE = 60 * 60 * 24 * 365
-
-/** Anything but an explicit choice of the classic page is the overhauled one. */
-export function parseWorkOrderLayout(raw: string | undefined | null): WorkOrderLayout {
-  return raw === 'classic' ? 'classic' : 'modern'
-}
-
-/** Client only: remember the choice for the next server render. */
-export function rememberWorkOrderLayout(layout: WorkOrderLayout) {
-  document.cookie = `${WORK_ORDER_LAYOUT_COOKIE}=${layout}; path=/; max-age=${WORK_ORDER_LAYOUT_COOKIE_MAX_AGE}; SameSite=Lax`
-}