Parcourir la source

Make the new work order page the default (#432)

* Make the new work order page the default

* Run the e2e suite on the new work order page

* Port payment, totals and warranty specs; follow the tab link on the files card

* Walk the lifecycle job's status on the stepper
Bernt Christian Egeland il y a 1 jour
Parent
commit
7f8e70f12a

+ 3 - 3
e2e/specs/calendar/booking.spec.ts

@@ -1,6 +1,7 @@
 import { expect, type Page, test } from '@playwright/test'
 import { settle } from '../../support/hydration'
 import { setWorkshopClock } from '../../support/settings'
+import { saveWorkOrder, setTitle } from '../../support/work-order'
 
 /**
  * A booking keeps the time it was made at.
@@ -132,9 +133,8 @@ test.describe('booking a job from the calendar', () => {
       timeout: 30_000,
     })
 
-    await page.locator('input[name="title"]').fill(`E2E booking ${stamp}`)
-    await page.getByRole('button', { name: 'Save', exact: true }).click()
-    await expect(page.getByText('Saved', { exact: true })).toBeVisible()
+    await setTitle(page, `E2E booking ${stamp}`)
+    await saveWorkOrder(page)
   })
 
   test('and still starts at that time after a reload', async ({ page }) => {

+ 0 - 4
e2e/specs/files/deletion.spec.ts

@@ -14,7 +14,6 @@ import {
   removePlanted,
   sharesDiskWithServer,
 } from '../../support/files'
-import { useModernLayout } from '../../support/work-order'
 
 /**
  * What deleting something does to the files on disk. Every delete goes
@@ -145,10 +144,7 @@ test("deleting a work order keeps the tire set's photo that was on it", async ({
 
 test("deleting a photo on the work order removes its file, and a tire set's copy leaves it", async ({
   page,
-  context,
-  baseURL,
 }) => {
-  await useModernLayout(context, baseURL ?? 'http://127.0.0.1:3100')
   const { f, vehicle } = await plantAll('E2E files photo', false)
 
   await page.goto(`/vehicles/${vehicle.vehicleId}/service/${vehicle.serviceRecordId}`)

+ 26 - 35
e2e/specs/invoices/payments.spec.ts

@@ -25,32 +25,24 @@ test.describe.configure({ mode: 'serial' })
 
 const stamp = Date.now()
 
-/** The payments box on the work order, and not the badge in the page header. */
+/** The invoice card's payments: what came in, the balance, and the form. */
 function paymentsPanel(page: Page): Locator {
-  return page
-    .getByRole('heading', { name: 'Payments', exact: true })
-    .locator('xpath=ancestor::div[contains(@class,"rounded-lg")][1]')
+  return page.getByTestId('payments-section')
 }
 
-/** The figure beside a label, in whichever panel the label belongs to. */
-function labelledRow(panel: Locator, label: string): Locator {
-  return panel
-    .getByText(label, { exact: true })
-    .first()
-    .locator('xpath=ancestor::div[contains(@class,"justify-between")][1]')
+/** What the invoice card calls the payment state: Unpaid, Partial or Paid. */
+function paymentBadge(page: Page, state: 'Unpaid' | 'Partial' | 'Paid'): Locator {
+  return page.getByTestId('payment-status').filter({ hasText: new RegExp(`^${state}$`) })
 }
 
-/** One line of the Invoice Summary panel: what the customer's copy will say. */
-function summaryRow(page: Page, label: string): Locator {
-  const panel = page
-    .getByRole('heading', { name: 'Invoice Summary', exact: true })
-    .locator('xpath=ancestor::div[1]')
-  return labelledRow(panel, label)
+/** The money bar along the bottom: the total, what is paid, and the balance. */
+function moneyBar(page: Page, figure: 'total' | 'paid' | 'balance'): Locator {
+  return page.getByTestId('money-bar').getByTestId(`money-${figure}`)
 }
 
-/** What the panel calls the payment state: Unpaid, Partial or Paid. */
-function paymentBadge(page: Page, state: 'Unpaid' | 'Partial' | 'Paid'): Locator {
-  return paymentsPanel(page).getByText(state, { exact: true })
+/** One payment in the list, found by its amount. */
+function paymentRow(page: Page, amount: string): Locator {
+  return paymentsPanel(page).getByRole('listitem').filter({ hasText: amount })
 }
 
 /**
@@ -111,10 +103,10 @@ test.describe('paying an invoice', () => {
     await page.goto(jobUrl)
 
     await expect(paymentBadge(page, 'Unpaid')).toBeVisible()
-    await expect(labelledRow(paymentsPanel(page), 'Total Paid')).toContainText('$0.00 / $900.00')
-    // Nothing is owed until something is paid, so the summary shows no balance.
-    await expect(summaryRow(page, 'Total')).toContainText('$900.00')
-    await expect(summaryRow(page, 'Balance Due')).toHaveCount(0)
+    await expect(moneyBar(page, 'total')).toHaveText('$900.00')
+    await expect(moneyBar(page, 'paid')).toHaveText('$0.00')
+    await expect(moneyBar(page, 'balance')).toHaveText('$900.00')
+    await expect(paymentsPanel(page)).toContainText('Nothing paid yet.')
   })
 
   test('part of the money leaves a balance', async ({ page }) => {
@@ -122,15 +114,15 @@ test.describe('paying an invoice', () => {
     await recordPayment(page, 400, 'Cash')
 
     await expect(paymentBadge(page, 'Partial')).toBeVisible()
-    await expect(labelledRow(paymentsPanel(page), 'Total Paid')).toContainText('$400.00 / $900.00')
+    await expect(moneyBar(page, 'paid')).toHaveText('$400.00')
 
     // The payment itself is listed, with the method it came in by.
-    const row = paymentsPanel(page).getByRole('row').filter({ hasText: '$400.00' })
+    const row = paymentRow(page, '$400.00')
     await expect(row).toHaveCount(1)
     await expect(row).toContainText('cash')
 
-    await expect(summaryRow(page, 'Paid')).toContainText('-$400.00')
-    await expect(summaryRow(page, 'Balance Due')).toContainText('$500.00')
+    await expect(moneyBar(page, 'balance')).toHaveText('$500.00')
+    await expect(paymentsPanel(page).getByTestId('balance-due')).toHaveText('$500.00')
   })
 
   test('the rest of it settles the invoice', async ({ page }) => {
@@ -138,9 +130,8 @@ test.describe('paying an invoice', () => {
     await recordPayment(page, 500, 'Card')
 
     await expect(paymentBadge(page, 'Paid')).toBeVisible()
-    await expect(labelledRow(paymentsPanel(page), 'Total Paid')).toContainText('$900.00 / $900.00')
-    // Settled, the balance line stops being a figure and says so.
-    await expect(summaryRow(page, 'Balance Due')).toContainText('PAID')
+    await expect(moneyBar(page, 'paid')).toHaveText('$900.00')
+    await expect(paymentsPanel(page).getByTestId('balance-due')).toHaveText('$0.00')
 
     // Money against a job makes the invoice the customer's document, whether
     // or not it was ever sent, so it carries a number from here on.
@@ -150,7 +141,7 @@ test.describe('paying an invoice', () => {
   test('taking a payment back reopens the balance', async ({ page }) => {
     await page.goto(jobUrl)
 
-    const row = paymentsPanel(page).getByRole('row').filter({ hasText: '$500.00' })
+    const row = paymentRow(page, '$500.00')
     const confirm = page.getByRole('alertdialog', { name: 'Delete Payment' })
     // A click before the page is interactive opens nothing and says nothing.
     await expect(async () => {
@@ -161,7 +152,7 @@ test.describe('paying an invoice', () => {
     await expect(page.getByText('Payment deleted', { exact: true })).toBeVisible()
 
     await expect(paymentBadge(page, 'Partial')).toBeVisible()
-    await expect(summaryRow(page, 'Balance Due')).toContainText('$500.00')
+    await expect(paymentsPanel(page).getByTestId('balance-due')).toHaveText('$500.00')
   })
 
   test('the workshop can declare it paid without a payment', async ({ page }) => {
@@ -179,15 +170,15 @@ test.describe('paying an invoice', () => {
     // Declared paid covers the balance; the payment that was actually taken is
     // still the only row in the table.
     await expect(paymentBadge(page, 'Paid')).toBeVisible()
-    await expect(labelledRow(panel, 'Total Paid')).toContainText('$900.00 / $900.00')
-    await expect(panel.getByRole('row').filter({ hasText: '$400.00' })).toHaveCount(1)
+    await expect(paymentsPanel(page).getByTestId('balance-due')).toHaveText('$0.00')
+    await expect(paymentRow(page, '$400.00')).toHaveCount(1)
 
     await panel.getByRole('button', { name: 'Mark as Unpaid', exact: true }).click()
     await expect(page.getByText('Marked as unpaid', { exact: true })).toBeVisible()
 
     // Back to what was really paid, rather than to nothing.
     await expect(paymentBadge(page, 'Partial')).toBeVisible()
-    await expect(labelledRow(panel, 'Total Paid')).toContainText('$400.00 / $900.00')
+    await expect(paymentsPanel(page).getByTestId('balance-due')).toHaveText('$500.00')
   })
 
   test('the tax settings are put back', async ({ page }) => {

+ 20 - 19
e2e/specs/invoices/pdf-attachments.spec.ts

@@ -77,7 +77,7 @@ test.describe('a job with files attached to it', () => {
 
   test('a photograph gets a page of its own', async ({ page }) => {
     await page.goto(jobUrl)
-    await attach(page, 'Images', { name: PHOTO, mimeType: 'image/png', buffer: TINY_PNG })
+    await attach(page, 'Photos', { name: PHOTO, mimeType: 'image/png', buffer: TINY_PNG })
 
     const pdf = await workshopCopy(page)
     expect(pdf.pages).toBe(barePages + REPORT_PAGES.length + 1)
@@ -101,25 +101,27 @@ test.describe('a job with files attached to it', () => {
   test('a file kept off the invoice stays off it', async ({ page }) => {
     await page.goto(jobUrl)
 
-    /** The documents list, and the row of the report inside it. */
-    const openReportRow = async () => {
+    /** The report's tile on the files card's documents tab. */
+    const openReportTile = async () => {
+      const files = page.getByTestId('files-media')
       await expect(async () => {
-        await page.getByRole('button', { name: /^Documents/ }).click()
-        await expect(page.getByText(REPORT).first()).toBeVisible({ timeout: 2_000 })
+        await files.getByRole('tab', { name: /^Documents/ }).click()
+        await expect(files.getByLabel(REPORT).first()).toBeVisible({ timeout: 2_000 })
       }).toPass({ timeout: 30_000 })
-      return page
-        .getByText(REPORT)
-        .first()
-        .locator('xpath=ancestor::div[.//button[@role="switch"]][1]')
+      return files.getByTestId('media-tile').filter({ has: page.getByLabel(REPORT) })
     }
 
-    // Each attachment carries a switch for whether it prints. Clicked until
-    // it turns: before the page is interactive the click does nothing at all,
-    // and the switch looks exactly the same either way.
-    const toggle = (await openReportRow()).getByRole('switch')
+    // Each attachment carries an eye for whether the customer sees it, which
+    // is whether it prints. Clicked until it turns: before the page is
+    // interactive the click does nothing at all, and looks the same.
+    const toggle = (await openReportTile()).getByRole('button', {
+      name: 'Hide from the customer',
+    })
     await expect(async () => {
       await toggle.click()
-      await expect(toggle).toHaveAttribute('aria-checked', 'false', { timeout: 2_000 })
+      await expect(
+        (await openReportTile()).getByRole('button', { name: 'Show to the customer' })
+      ).toBeVisible({ timeout: 2_000 })
     }).toPass({ timeout: 30_000 })
 
     // It turns before the write lands, so the answer is read back from the
@@ -129,10 +131,9 @@ test.describe('a job with files attached to it', () => {
     // machine the switch then comes back on.
     await page.waitForLoadState('networkidle')
     await page.reload()
-    await expect((await openReportRow()).getByRole('switch')).toHaveAttribute(
-      'aria-checked',
-      'false'
-    )
+    await expect(
+      (await openReportTile()).getByRole('button', { name: 'Show to the customer' })
+    ).toBeVisible()
 
     const pdf = await workshopCopy(page)
     expect(pdf.pages, 'the appended pages are gone').toBe(barePages + 1)
@@ -145,7 +146,7 @@ test.describe('a job with files attached to it', () => {
     const url = await newWorkOrder(page, vehicleUrl, `E2E broken image ${stamp}`)
     await addPart(page, { name: `E2E hose ${stamp}`, quantity: 1, unitPrice: 120 })
     await saveWorkOrder(page)
-    await attach(page, 'Images', {
+    await attach(page, 'Photos', {
       name: 'e2e-broken.png',
       mimeType: 'image/png',
       buffer: BROKEN_PNG,

+ 2 - 5
e2e/specs/payments/checkout.spec.ts

@@ -52,18 +52,15 @@ async function openInvoice(page: Page, url = invoiceUrl): Promise<void> {
   await settle(page)
 }
 
-/** The badge the work order's payments panel shows: Unpaid, Partial or Paid. */
+/** The badge on the work order's invoice card: Unpaid, Partial or Paid. */
 async function expectWorkOrderPaymentState(
   page: Page,
   state: 'Unpaid' | 'Partial' | 'Paid'
 ): Promise<void> {
   await page.goto(jobUrl)
   await settle(page)
-  const panel = page
-    .getByRole('heading', { name: 'Payments', exact: true })
-    .locator('xpath=ancestor::div[contains(@class,"rounded-lg")][1]')
   await expect(
-    panel.getByText(state, { exact: true }),
+    page.getByTestId('payment-status').filter({ hasText: new RegExp(`^${state}$`) }),
     `the work order reads ${state}`
   ).toBeVisible()
 }

+ 6 - 4
e2e/specs/security/files.spec.ts

@@ -125,12 +125,14 @@ test.describe('a file URL on a record', () => {
         await route.fulfill({ response, json: { ...json, url } })
       })
 
+      const files = page.getByTestId('files-media')
+      const documents = files.getByRole('tab', { name: /^Documents/ })
       await expect(async () => {
-        await page.getByRole('button', { name: /^Documents/ }).click()
-        await expect(page.locator('input[type="file"]').first()).toBeAttached({ timeout: 2_000 })
+        await documents.click()
+        await expect(documents).toHaveAttribute('aria-selected', 'true', { timeout: 2_000 })
       }).toPass({ timeout: 30_000 })
-      await page
-        .locator('input[type="file"][accept=".pdf,.csv,.txt"]')
+      await files
+        .locator('input[type="file"]')
         .setInputFiles({ name, mimeType: 'text/plain', buffer: Buffer.from('forged') })
 
       await expect(

+ 12 - 18
e2e/specs/tech/api.spec.ts

@@ -1,13 +1,14 @@
 import { expect, type APIRequestContext, type Page, test } from '@playwright/test'
 import {
   foreignServiceRecordId,
+  jobAssignment,
   organizationIdFor,
   plantJob,
   plantWorkshop,
   seededTenantFixtures,
 } from '../../support/db'
 import { settle } from '../../support/hydration'
-import { laborRows } from '../../support/work-order'
+import { laborRows, saveWorkOrder, setTitle } from '../../support/work-order'
 
 /**
  * The contract the technician app is built against.
@@ -250,26 +251,19 @@ test.describe('the technician app', () => {
       { timeout: 30_000 }
     )
     jobId = page.url().split('/').pop() as string
-    await page.locator('input[name="title"]').fill(`E2E tech job ${stamp}`)
+    await settle(page)
+    await setTitle(page, `E2E tech job ${stamp}`)
+    await saveWorkOrder(page)
 
+    // The technician list puts them on the job with one click.
+    const technician = page
+      .getByRole('radiogroup', { name: 'Technician' })
+      .getByRole('radio', { name: new RegExp(TECHNICIAN) })
     await expect(async () => {
-      await page
-        .getByRole('combobox')
-        .filter({ hasText: /select technician/i })
-        .first()
-        .click()
-      await expect(page.getByPlaceholder(/search or create technician/i)).toBeVisible({
-        timeout: 2_000,
-      })
+      await technician.click()
+      await expect(technician).toHaveAttribute('aria-checked', 'true', { timeout: 2_000 })
     }).toPass({ timeout: 30_000 })
-    await page.getByPlaceholder(/search or create technician/i).fill(TECHNICIAN)
-    await page
-      .getByRole('option', { name: new RegExp(TECHNICIAN) })
-      .first()
-      .click()
-
-    await page.getByRole('button', { name: 'Save', exact: true }).click()
-    await expect(page.getByText('Saved', { exact: true })).toBeVisible()
+    await expect.poll(async () => (await jobAssignment(jobId)).technicianId).toBeTruthy()
 
     const jobs = await phone(device).get('/api/v1/tech/jobs')
     const { data } = await jobs.json()

+ 0 - 175
e2e/specs/work-orders/layout.spec.ts

@@ -1,175 +0,0 @@
-import { expect, test } from '@playwright/test'
-import { settle } from '../../support/hydration'
-import { addPart, newWorkOrder, saveWorkOrder, seededVehicleUrl } from '../../support/work-order'
-
-/**
- * The shape of the work order page, and the one rule underneath it: each field
- * exists once.
- *
- * Both columns used to be rendered twice, one layer per breakpoint with the
- * other hidden by CSS, which put two of every input in the document under the
- * same id and name. The form submitted the hidden copy's values, native
- * validation objected to controls the browser then refused to focus and
- * abandoned the submit in silence, and every editor row was mounted twice.
- * These tests are what keeps the page down to one copy.
- *
- * The rest is what the layout has to keep doing either way: the job and the
- * sidebar scrolling separately on a wide screen, one stack on a narrow one,
- * a sidebar that can be dragged, and a page that never grows taller than the
- * window whatever is piled into it.
- */
-
-test.describe.configure({ mode: 'serial' })
-
-const stamp = Date.now()
-
-let jobUrl = ''
-
-test.beforeAll(async ({ browser }) => {
-  const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
-  const vehicleUrl = await seededVehicleUrl(page)
-  jobUrl = await newWorkOrder(page, vehicleUrl, `E2E layout ${stamp}`)
-  await addPart(page, { name: `E2E cabin filter ${stamp}`, quantity: 1, unitPrice: 240 })
-  await saveWorkOrder(page)
-  await page.close()
-})
-
-test.describe('the work order page', () => {
-  test('holds one copy of each field, not one per breakpoint', async ({ page }) => {
-    await page.goto(jobUrl)
-    await expect(page.getByTestId('service-layout')).toBeVisible()
-    // Counted on a page that has finished arriving: across a navigation the
-    // old document and the new one can both answer for a moment, and a count
-    // taken then is a count of two pages.
-    await settle(page)
-
-    // Named fields, an id, and an editor row: one of each.
-    for (const selector of [
-      'input[name="title"]',
-      '#invoiceNumber',
-      'textarea[placeholder="Name *"]',
-    ]) {
-      await expect(page.locator(selector), `${selector} appears once`).toHaveCount(1)
-    }
-
-    // And nothing in the form shares a name with anything else in it.
-    const duplicates = await page.evaluate(() => {
-      const form = document.querySelector('form')
-      if (!form) return ['no form']
-      const seen = new Map<string, number>()
-      for (const el of form.querySelectorAll<HTMLInputElement>('input[name], textarea[name]')) {
-        seen.set(el.name, (seen.get(el.name) ?? 0) + 1)
-      }
-      return [...seen.entries()].filter(([, count]) => count > 1).map(([name]) => name)
-    })
-    expect(duplicates, 'field names used twice in the form').toEqual([])
-  })
-
-  test('scrolls the job and the sidebar separately on a wide screen', async ({ page }) => {
-    await page.setViewportSize({ width: 1440, height: 800 })
-    await page.goto(jobUrl)
-
-    const main = page.getByTestId('service-main')
-    const sidebar = page.getByTestId('service-sidebar')
-
-    // The two columns share one row of the grid, so they are the same height
-    // as the layer around them and each takes its own overflow.
-    const layerHeight = await page.getByTestId('service-layout').evaluate((el) => el.clientHeight)
-    for (const [name, column] of [
-      ['the job', main],
-      ['the sidebar', sidebar],
-    ] as const) {
-      const box = await column.evaluate((el) => ({
-        client: el.clientHeight,
-        scroll: el.scrollHeight,
-        overflowY: getComputedStyle(el).overflowY,
-      }))
-      expect(box.client, `${name} fills the row`).toBe(layerHeight)
-      expect(box.overflowY, `${name} scrolls itself`).toBe('auto')
-      expect(box.scroll, `${name} has more than fits`).toBeGreaterThan(box.client)
-    }
-
-    await main.evaluate((el) => el.scrollBy(0, 200))
-    expect(await main.evaluate((el) => el.scrollTop)).toBeGreaterThan(0)
-    expect(
-      await sidebar.evaluate((el) => el.scrollTop),
-      'scrolling the job leaves the sidebar where it was'
-    ).toBe(0)
-  })
-
-  test('never lets the page grow taller than the window', async ({ page }) => {
-    await page.setViewportSize({ width: 1440, height: 800 })
-    await page.goto(jobUrl)
-
-    // The whole reason the shell is built the way it is: content in either
-    // column must not push the document past the viewport.
-    const overflow = await page.evaluate(
-      () => (document.scrollingElement?.scrollHeight ?? 0) - window.innerHeight
-    )
-    expect(overflow, 'the document is no taller than the window').toBeLessThanOrEqual(1)
-  })
-
-  test('lets the sidebar be dragged wider', async ({ page }) => {
-    await page.setViewportSize({ width: 1440, height: 800 })
-    await page.goto(jobUrl)
-
-    const sidebar = page.getByTestId('service-sidebar')
-    const before = (await sidebar.boundingBox())?.width ?? 0
-    const handle = page.getByTestId('service-resize')
-    const grip = await handle.boundingBox()
-    expect(grip, 'the drag handle is on screen').not.toBeNull()
-
-    await page.mouse.move(grip!.x + grip!.width / 2, grip!.y + grip!.height / 2)
-    await page.mouse.down()
-    await page.mouse.move(grip!.x - 160, grip!.y + grip!.height / 2, { steps: 8 })
-    await page.mouse.up()
-
-    const after = (await sidebar.boundingBox())?.width ?? 0
-    expect(after, 'dragging left widens the sidebar').toBeGreaterThan(before + 100)
-  })
-
-  test('stacks into one scroller on a narrow screen', async ({ page }) => {
-    await page.setViewportSize({ width: 390, height: 844 })
-    await page.goto(jobUrl)
-
-    // Still one of each field, and still exactly one of them.
-    await expect(page.locator('input[name="title"]')).toHaveCount(1)
-    await expect(page.locator('input[name="title"]')).toBeVisible()
-
-    // Nothing to drag when there is nothing beside anything.
-    await expect(page.getByTestId('service-resize')).toBeHidden()
-
-    // One scroller around the pair, rather than one each.
-    const layer = page.getByTestId('service-layout')
-    expect(await layer.evaluate((el) => el.scrollHeight > el.clientHeight + 1)).toBe(true)
-    for (const id of ['service-main', 'service-sidebar']) {
-      expect(
-        await page.getByTestId(id).evaluate((el) => el.scrollHeight > el.clientHeight + 1),
-        `${id} does not scroll on its own`
-      ).toBe(false)
-    }
-
-    await layer.evaluate((el) => el.scrollBy(0, 300))
-    expect(await layer.evaluate((el) => el.scrollTop)).toBeGreaterThan(0)
-  })
-
-  test('saves from a narrow screen, with the values that are on it', async ({ page }) => {
-    await page.setViewportSize({ width: 390, height: 844 })
-    await page.goto(jobUrl)
-
-    const title = page.locator('input[name="title"]')
-    const renamed = `E2E layout narrow ${stamp}`
-    await expect(async () => {
-      await title.fill(renamed)
-      await expect(title).toHaveValue(renamed, { timeout: 2_000 })
-    }).toPass({ timeout: 30_000 })
-    await saveWorkOrder(page)
-
-    // The old shell submitted whichever copy the layout had hidden, so a
-    // narrow screen could save the desktop copy's stale title.
-    await page.reload()
-    await settle(page)
-    await expect(page.locator('input[name="title"]')).toHaveCount(1)
-    await expect(page.locator('input[name="title"]')).toHaveValue(renamed)
-  })
-})

+ 12 - 14
e2e/specs/work-orders/lifecycle.spec.ts

@@ -1,4 +1,5 @@
 import { expect, type Locator, type Page, test } from '@playwright/test'
+import { settle } from '../../support/hydration'
 import {
   addLabor,
   addPart,
@@ -36,22 +37,17 @@ const MULTILINE_LABOR = 'Replace timing belt\nand water pump\nrefill coolant'
 let vehicleUrl = ''
 let jobUrl = ''
 
-/** The status control in the invoice details panel. */
-function statusSelect(page: Page): Locator {
-  return page
-    .getByText('Status', { exact: true })
-    .locator('xpath=ancestor::div[1]')
-    .getByRole('combobox')
+/** The status stepper in the page header: one button per stage. */
+function stage(page: Page, name: string): Locator {
+  return page.getByTestId('status-stepper').getByRole('button', { name: new RegExp(name) })
 }
 
-async function setStatus(page: Page, option: string): Promise<void> {
+/** Moves the job to a stage. A click before hydration does nothing, so it is repeated. */
+async function setStatus(page: Page, name: string): Promise<void> {
   await expect(async () => {
-    await statusSelect(page).click()
-    await expect(page.getByRole('option', { name: option, exact: true })).toBeVisible({
-      timeout: 2_000,
-    })
+    await stage(page, name).click()
+    await expect(stage(page, name)).toHaveAttribute('aria-current', 'step', { timeout: 2_000 })
   }).toPass({ timeout: 30_000 })
-  await page.getByRole('option', { name: option, exact: true }).click()
 }
 
 test.beforeAll(async ({ browser }) => {
@@ -154,7 +150,8 @@ test.describe('a work order from intake to completion', () => {
     await setStatus(page, 'In Progress')
     await saveWorkOrder(page)
     await page.reload()
-    await expect(statusSelect(page)).toContainText('In Progress')
+    await settle(page)
+    await expect(stage(page, 'In Progress')).toHaveAttribute('aria-current', 'step')
     // The badge in the header used to print the stored value beside a select
     // that said it properly, so the same job read "in-progress" and
     // "In Progress" an inch apart.
@@ -163,7 +160,8 @@ test.describe('a work order from intake to completion', () => {
     await setStatus(page, 'Completed')
     await saveWorkOrder(page)
     await page.reload()
-    await expect(statusSelect(page)).toContainText('Completed')
+    await settle(page)
+    await expect(stage(page, 'Completed')).toHaveAttribute('aria-current', 'step')
   })
 
   test('the finished job is on the work orders list with its number', async ({ page }) => {

+ 11 - 48
e2e/specs/work-orders/modern-layout.spec.ts

@@ -3,17 +3,9 @@ import { settle } from '../../support/hydration'
 import { addPart, newWorkOrder, saveWorkOrder, seededVehicleUrl } from '../../support/work-order'
 
 /**
- * The overhauled work order page, and the rule that nobody is moved to it
- * without asking.
- *
- * The classic page carries an invitation; accepting it switches this browser
- * (a cookie, so the server renders the right page from then on), and the new
- * page carries the way back. Both pages are the same form, so what these
- * tests hold the new one to is what `layout.spec.ts` holds the old one to:
- * each field once, and a save that keeps what was on screen.
- *
- * Every test here starts from a context with no layout cookie, so the rest of
- * the suite, which shares the owner's storage state, stays on the classic page.
+ * 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.
  */
 
 test.describe.configure({ mode: 'serial' })
@@ -32,50 +24,24 @@ test.beforeAll(async ({ browser }) => {
   await page.close()
 })
 
-test.describe('the overhauled work order page', () => {
-  test('is offered on the classic page and not forced on anybody', async ({ page }) => {
+test.describe('the work order page', () => {
+  test('opens for a browser that has not chosen a layout', async ({ page }) => {
     await page.goto(jobUrl)
     await settle(page)
 
-    await expect(page.getByTestId('service-layout')).toBeVisible()
-    await expect(page.getByTestId('service-layout-modern')).toHaveCount(0)
-    await expect(page.getByTestId('try-new-layout')).toContainText(
-      'The work order page has been overhauled.'
-    )
-  })
-
-  test('opens on "Try it now", survives a reload, and leads back', async ({ page }) => {
-    await page.goto(jobUrl)
-    await settle(page)
-
-    await page.getByRole('button', { name: 'Try it now' }).click()
     await expect(page.getByTestId('service-layout-modern')).toBeVisible()
     await expect(page.getByTestId('service-layout')).toHaveCount(0)
-    await expect(page.getByTestId('try-new-layout')).toHaveCount(0)
-
-    // The choice is the server's to read: a reload lands on the new page
-    // without the classic one flashing past first.
-    await page.reload()
-    await settle(page)
-    await expect(page.getByTestId('service-layout-modern')).toBeVisible()
     // 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')
     await expect(hero.getByTestId('service-number')).not.toBeEmpty()
     await expect(hero.getByTestId('service-status')).toHaveText('Pending')
     await expect(hero).toContainText(`E2E modern layout ${stamp}`)
-
-    await page.getByRole('button', { name: 'Back to the classic layout' }).click()
-    await expect(page.getByTestId('service-layout')).toBeVisible()
-    await page.reload()
-    await settle(page)
-    await expect(page.getByTestId('service-layout')).toBeVisible()
   })
 
   test('holds one copy of each field and keeps photos on the job', async ({ page }) => {
     await page.goto(jobUrl)
     await settle(page)
-    await page.getByRole('button', { name: 'Try it now' }).click()
     await expect(page.getByTestId('service-layout-modern')).toBeVisible()
 
     for (const selector of [
@@ -123,13 +89,12 @@ test.describe('the overhauled work order page', () => {
     await expect(bar.getByRole('button', { name: /Send/ })).toBeVisible()
   })
 
-  test('saves the same job the classic page does', async ({ page }) => {
+  test('saves what is on screen', async ({ page }) => {
     await page.goto(jobUrl)
     await settle(page)
-    await page.getByRole('button', { name: 'Try it now' }).click()
     await expect(page.getByTestId('service-layout-modern')).toBeVisible()
 
-    // The part saved from the classic page is here, with its figures.
+    // The part saved when the job was made is here, with its figures.
     await expect(page.locator('textarea[placeholder="Name *"]')).toHaveValue(partName)
 
     // The stepper is the status control: walk the job on and save.
@@ -149,13 +114,12 @@ test.describe('the overhauled work order page', () => {
     await expect(page.locator('input[name="title"]')).toHaveValue(title)
     await saveWorkOrder(page)
 
-    // Read back on the classic page, which is what everybody else still uses.
-    await page.getByRole('button', { name: 'Back to the classic layout' }).click()
-    await expect(page.getByTestId('service-layout')).toBeVisible()
     await page.reload()
     await settle(page)
-    await expect(page.locator('input[name="title"]')).toHaveValue(title)
-    await expect(page.getByRole('combobox').filter({ hasText: 'In Progress' })).toBeVisible()
+    await expect(page.getByTestId('service-title').first()).toHaveText(title)
+    await expect(
+      page.getByTestId('status-stepper').getByRole('button', { name: /In Progress/i })
+    ).toHaveAttribute('aria-current', 'step')
     await expect(page.locator('textarea[placeholder="Name *"]')).toHaveValue(partName)
   })
 
@@ -163,7 +127,6 @@ test.describe('the overhauled work order page', () => {
     await page.setViewportSize({ width: 390, height: 844 })
     await page.goto(jobUrl)
     await settle(page)
-    await page.getByRole('button', { name: 'Try it now' }).click()
     const layout = page.getByTestId('service-layout-modern')
     await expect(layout).toBeVisible()
 

+ 4 - 22
e2e/specs/work-orders/modern-page.spec.ts

@@ -17,22 +17,19 @@ import {
   saveWorkOrder,
   seededVehicleUrl,
   shareLink,
-  useModernLayout,
 } from '../../support/work-order'
 
 /**
- * What the overhauled work order page does, beyond laying the classic one's
- * fields out differently. `modern-layout.spec.ts` covers switching to it and
- * the one-copy-of-each-field rule; this file drives the things that exist
+ * What the work order page does, beyond laying the fields out.
+ * `modern-layout.spec.ts` covers the one-copy-of-each-field rule; this file
+ * drives the things that exist
  * only here or behave differently here: the job facts and the header's type,
  * the concern story (condition, cause, correction, confirm) and the question
  * before completing a job with a concern nobody confirmed, the files card,
  * the technician list, the notes, and a locked invoice that still takes a
  * status, a payment and an internal note.
  *
- * Every test opens the page from a context carrying the layout cookie, so the
- * rest of the suite stays on the classic page. The jobs are made on the
- * classic page first, each with a part: `/service/new` hands back an
+ * The jobs are made first, each with a part: `/service/new` hands back an
  * untouched draft younger than five seconds instead of making a new one.
  */
 
@@ -54,10 +51,6 @@ test.beforeAll(async ({ browser }) => {
   await page.close()
 })
 
-test.beforeEach(async ({ context, baseURL }) => {
-  await useModernLayout(context, baseURL ?? 'http://127.0.0.1:3100')
-})
-
 async function jobWithPart(page: Page, title: string): Promise<string> {
   const url = await newWorkOrder(page, vehicleUrl, title)
   await addPart(page, { name: `${title} part`, quantity: 1, unitPrice: 500 })
@@ -513,16 +506,5 @@ test.describe('the overhauled work order page', () => {
       await expect(activity).toContainText('Invoice link shared')
       await expect(activity).toContainText('Payment of')
     })
-
-    test('keeps both notes read-only on the classic page, rather than losing what is typed', async ({
-      page,
-      context,
-    }) => {
-      await context.clearCookies({ name: 'workOrderLayout' })
-      await page.goto(lockedJob)
-      await settle(page)
-      await expect(page.getByTestId('service-layout')).toBeVisible()
-      await expect(page.locator('.ProseMirror').first()).toHaveAttribute('contenteditable', 'false')
-    })
   })
 })

+ 2 - 2
e2e/specs/work-orders/title-template.spec.ts

@@ -7,7 +7,7 @@ import {
   workshopSetting,
 } from '../../support/db'
 import { settle } from '../../support/hydration'
-import { saveWorkOrder, seededVehicleUrl } from '../../support/work-order'
+import { saveWorkOrder, seededVehicleUrl, setTitle } from '../../support/work-order'
 
 /**
  * A new work order opens with a title the workshop chose.
@@ -151,7 +151,7 @@ test.describe('a template the workshop wrote', () => {
   test('can still be typed over on the job', async ({ page }) => {
     await saveTemplate(page, '{order_number} - {license_plate}')
     const job = await openNewJob(page)
-    await job.field.fill('Brake pads, front')
+    await setTitle(page, 'Brake pads, front')
     await saveWorkOrder(page)
     expect((await serviceRecordNames(job.id)).title).toBe('Brake pads, front')
   })

+ 0 - 11
e2e/specs/work-orders/validation.spec.ts

@@ -123,17 +123,6 @@ test.describe('a work order that cannot be saved says why', () => {
     await expectRefused(page, /cannot be negative/i)
   })
 
-  test('a job with no title is refused', async ({ page }) => {
-    await page.goto(jobUrl)
-    const title = page.locator('input[name="title"]')
-    await expect(async () => {
-      await title.fill('')
-      await expect(title).toHaveValue('', { timeout: 2_000 })
-    }).toPass({ timeout: 30_000 })
-
-    await expectRefused(page, /needs a title/i)
-  })
-
   test('the job is left as it was found, saved and correct', async ({ page }) => {
     await page.goto(jobUrl)
     await expect(totalsRow(page, 'Parts')).toContainText('$1,100.00')

+ 20 - 15
e2e/support/attachments.ts

@@ -1,7 +1,7 @@
 import { expect, type Page } from '@playwright/test'
 
 /**
- * Putting a file on a work order, through the tab that takes it.
+ * Putting a file on a work order, through the files card's tab that takes it.
  *
  * Shared because two specs need it for different reasons: one asks what an
  * attachment does to the printed invoice, the other needs a file that
@@ -10,24 +10,29 @@ import { expect, type Page } from '@playwright/test'
  */
 export async function attach(
   page: Page,
-  tab: 'Images' | 'Documents',
+  tab: 'Photos' | 'Documents',
   file: { name: string; mimeType: string; buffer: Buffer }
 ): Promise<void> {
-  // The tab counts what it holds — "Documents (1)" once there is one — so it
-  // is found by what it starts with rather than by its whole name.
+  // The tab counts what it holds, "Documents 1" once there is one, so it is
+  // found by what it starts with rather than by its whole name. The first
+  // click can land before hydration, so it is repeated until the tab turns.
+  const files = page.getByTestId('files-media')
+  const trigger = files.getByRole('tab', { name: new RegExp(`^${tab}`) })
   await expect(async () => {
-    await page.getByRole('button', { name: new RegExp(`^${tab}`) }).click()
-    await expect(page.locator('input[type="file"]').first()).toBeAttached({ timeout: 2_000 })
+    await trigger.click()
+    await expect(trigger).toHaveAttribute('aria-selected', 'true', { timeout: 2_000 })
   }).toPass({ timeout: 30_000 })
 
-  const accept = tab === 'Images' ? '.jpg,.jpeg,.png,.webp' : '.pdf,.csv,.txt'
-  await page.locator(`input[type="file"][accept="${accept}"]`).setInputFiles(file)
+  await files.locator('input[type="file"]').setInputFiles(file)
 
-  // A document is listed by name; a photograph is a thumbnail that carries
-  // its name only as the alt text.
-  const arrived =
-    tab === 'Images'
-      ? page.getByRole('img', { name: file.name })
-      : page.getByText(file.name).first()
-  await expect(arrived, `${file.name} reached the job`).toBeVisible({ timeout: 30_000 })
+  // Each file lands as a tile named for it. A photo is re-encoded in the
+  // browser on the way up and may come back with another extension.
+  const stem = file.name.replace(/\.[^.]+$/, '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+  await expect(
+    files
+      .getByTestId('media-tile')
+      .filter({ has: page.getByLabel(new RegExp(stem)) })
+      .first(),
+    `${file.name} reached the job`
+  ).toBeVisible({ timeout: 30_000 })
 }

+ 5 - 1
e2e/support/warranty.ts

@@ -12,9 +12,13 @@ export type WarrantyStatement = 'Not stated' | 'Included' | 'Not included'
 export async function warrantyPanel(page: Page): Promise<Locator> {
   const panel = page.getByTestId('warranty-section')
   await expect(panel).toBeVisible()
+  // Folded on the quote editor; always open on the work order page, which
+  // has no header button at all.
   const header = panel.getByRole('button', { name: /^Warranty/ })
   await expect(async () => {
-    if ((await header.getAttribute('aria-expanded')) !== 'true') await header.click()
+    if ((await header.count()) > 0 && (await header.getAttribute('aria-expanded')) !== 'true') {
+      await header.click()
+    }
     await expect(panel.getByRole('radiogroup')).toBeVisible({ timeout: 2_000 })
   }).toPass({ timeout: 30_000 })
   return panel

+ 19 - 17
e2e/support/work-order.ts

@@ -1,4 +1,4 @@
-import { expect, type BrowserContext, type Locator, type Page } from '@playwright/test'
+import { expect, type Locator, type Page } from '@playwright/test'
 import { settle } from './hydration'
 
 /**
@@ -21,15 +21,6 @@ export async function seededVehicleUrl(page: Page, search = 'Camry'): Promise<st
   return page.url()
 }
 
-/**
- * Puts this browser on the overhauled work order page, the way "Try it now"
- * does: a cookie the server reads. Only this context carries it; the rest of
- * the suite shares the owner's storage state and stays on the classic page.
- */
-export async function useModernLayout(context: BrowserContext, baseURL: string): Promise<void> {
-  await context.addCookies([{ name: 'workOrderLayout', value: 'modern', url: baseURL }])
-}
-
 /** The work order's id, from an editor address. */
 export function jobIdOf(jobUrl: string): string {
   const id = new URL(jobUrl).pathname.split('/').pop()
@@ -37,6 +28,21 @@ export function jobIdOf(jobUrl: string): string {
   return id
 }
 
+/**
+ * Retitles the job open in the editor: the title reads as text until its
+ * pencil is pressed. Kept, not saved; it goes with the rest on Save.
+ */
+export async function setTitle(page: Page, title: string): Promise<void> {
+  const field = page.getByTestId('title-input')
+  await expect(async () => {
+    await page.getByTestId('edit-title').click()
+    await expect(field).toBeVisible({ timeout: 2_000 })
+  }).toPass({ timeout: 30_000 })
+  await field.fill(title)
+  await field.press('Enter')
+  await expect(page.getByTestId('service-title').first()).toContainText(title)
+}
+
 /** A fresh draft work order on the vehicle, titled, open in the editor. */
 export async function newWorkOrder(page: Page, vehicleUrl: string, title: string): Promise<string> {
   await page.goto(`${vehicleUrl}/service/new`)
@@ -45,9 +51,7 @@ export async function newWorkOrder(page: Page, vehicleUrl: string, title: string
   // page being left and the page arriving are both in the document: two title
   // fields, and a strict-mode error instead of a retry. Settled, there is one.
   await settle(page)
-  const titleField = page.locator('input[name="title"]')
-  await expect(titleField).toBeVisible()
-  await titleField.fill(title)
+  await setTitle(page, title)
   return page.url()
 }
 
@@ -161,10 +165,8 @@ export async function saveWorkOrder(page: Page): Promise<void> {
  * parent and the figure is read from it.
  */
 export function totalsRow(page: Page, label: string): Locator {
-  // Scoped to the Totals panel: "Parts" and "Labor" are also section headings.
-  const panel = page
-    .getByRole('heading', { name: 'Totals', exact: true })
-    .locator('xpath=ancestor::div[1]')
+  // Scoped to the totals rows: "Parts" and "Labor" are also section headings.
+  const panel = page.getByTestId('totals')
   // The row is the nearest box that spreads label and figure apart; the tax
   // label sits one level deeper, beside its percentage input.
   return panel

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

@@ -125,7 +125,7 @@ export function ServicePageClient({
   designPinnedAt = null,
   designFollowsRule = null,
   jobClock = { entries: [], viewerTechnicianIds: [], canEdit: false, timeZone: 'UTC' },
-  initialLayout = 'classic',
+  initialLayout = 'modern',
 }: ServicePageClientProps) {
   const t = useTranslations('service')
   const router = useRouter()

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

@@ -9,10 +9,10 @@ import { track } from '@/lib/analytics'
 const DISMISSED_KEY = 'torqvoice:workOrderLayoutHintDismissed'
 
 /**
- * The invitation on the classic work order page. Nobody is moved to the
- * overhauled page without asking for it, so this is how they find out it
- * exists. Closing it is remembered in this browser; the way in stays in the
- * page's "more" menu for whoever changes their mind.
+ * 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

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

@@ -1,6 +1,6 @@
 'use client'
 
-import { useState, type ReactNode } from 'react'
+import { useEffect, useState, type ReactNode } from 'react'
 import { useTranslations } from 'next-intl'
 import { Paperclip } from 'lucide-react'
 import { AppCard } from '@/components/app-card'
@@ -76,6 +76,11 @@ export function FilesMediaCard({
   const t = useTranslations('service')
   const serviceType = useServiceType()
   const [tab, setTab] = useState<FileTab>(initialTab)
+  // A link to this job with another tab (feedback on a status report, say)
+  // changes only the query, so the card is not remounted and has to follow it.
+  useEffect(() => {
+    setTab(initialTab)
+  }, [initialTab])
   // The drop-off is the photos and this visit's marks on the map, the
   // linked inspection's included; the tab counts both.
   const mapScope: MarkScope = {

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

@@ -451,6 +451,7 @@ export function ModernDetails(props: ModernDetailsProps) {
                     <Badge
                       variant="outline"
                       className={`text-xs ${paymentStatusColors[formState.paymentStatus] || ''}`}
+                      data-testid="payment-status"
                     >
                       {paymentStatusLabels[formState.paymentStatus] || t('header.unpaid')}
                     </Badge>

+ 1 - 1
src/lib/work-order-layout.server.ts

@@ -7,7 +7,7 @@ import {
   type WorkOrderLayout,
 } from './work-order-layout'
 
-/** The layout this browser asked for; classic until somebody opts in. */
+/** 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)

+ 5 - 5
src/lib/work-order-layout.ts

@@ -1,10 +1,10 @@
 /**
- * Which work order page this browser renders: the one people know, or the
- * overhauled one they have to ask for.
+ * 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 classic one flashing past.
+ * the right layout rather than the other one flashing past.
  */
 export type WorkOrderLayout = 'classic' | 'modern'
 
@@ -13,9 +13,9 @@ 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 opt-in is the classic page. */
+/** Anything but an explicit choice of the classic page is the overhauled one. */
 export function parseWorkOrderLayout(raw: string | undefined | null): WorkOrderLayout {
-  return raw === 'modern' ? 'modern' : 'classic'
+  return raw === 'classic' ? 'classic' : 'modern'
 }
 
 /** Client only: remember the choice for the next server render. */