Parcourir la source

Overhauled work order page, offered beside the classic one (#410)

* first design review

* speech to text

* fonts

* translations

* notes

* settings

* migrations

* Keep a quote's valid-until date through saves, and find team members by test id

The quote form filled its date field from the UTC day while the server reads
it in the workshop's zone, so east of UTC every save moved the date a day
earlier. The role-standing spec matched cards by class, which the AppCard
change made ambiguous.

* Keep report schedule end dates and inspection due dates through saves

Both forms filled their date field from the UTC day of a stored instant that
the server writes on the workshop's clock, so each save moved the date a day.
Registry inspection dates are bare UTC days and are read as such.

* events

* customer card

* file manager

* export import

* promised table

* job_status_changed
Bernt Christian Egeland il y a 1 semaine
Parent
commit
fa21f771af
100 fichiers modifiés avec 5906 ajouts et 155 suppressions
  1. 1 1
      e2e/README.md
  2. 171 0
      e2e/specs/files/deletion.spec.ts
  3. 3 1
      e2e/specs/security/role-standing.spec.ts
  4. 32 0
      e2e/specs/tech/api.spec.ts
  5. 200 0
      e2e/specs/work-orders/modern-layout.spec.ts
  6. 522 0
      e2e/specs/work-orders/modern-page.spec.ts
  7. 120 0
      e2e/support/db.ts
  8. 49 0
      e2e/support/files.ts
  9. 36 0
      e2e/support/settings.ts
  10. 17 1
      e2e/support/work-order.ts
  11. 21 2
      messages/de/integrations.json
  12. 202 5
      messages/de/service.json
  13. 23 1
      messages/de/settings.json
  14. 8 2
      messages/de/workBoard.json
  15. 7 1
      messages/de/workOrders.json
  16. 21 2
      messages/en/integrations.json
  17. 202 5
      messages/en/service.json
  18. 23 1
      messages/en/settings.json
  19. 8 2
      messages/en/workBoard.json
  20. 7 1
      messages/en/workOrders.json
  21. 21 2
      messages/es/integrations.json
  22. 202 5
      messages/es/service.json
  23. 23 1
      messages/es/settings.json
  24. 8 2
      messages/es/workBoard.json
  25. 7 1
      messages/es/workOrders.json
  26. 21 2
      messages/fr/integrations.json
  27. 202 5
      messages/fr/service.json
  28. 23 1
      messages/fr/settings.json
  29. 8 2
      messages/fr/workBoard.json
  30. 7 1
      messages/fr/workOrders.json
  31. 21 2
      messages/it/integrations.json
  32. 202 5
      messages/it/service.json
  33. 23 1
      messages/it/settings.json
  34. 8 2
      messages/it/workBoard.json
  35. 7 1
      messages/it/workOrders.json
  36. 21 2
      messages/lt/integrations.json
  37. 202 5
      messages/lt/service.json
  38. 23 1
      messages/lt/settings.json
  39. 8 2
      messages/lt/workBoard.json
  40. 7 1
      messages/lt/workOrders.json
  41. 21 2
      messages/nb/integrations.json
  42. 202 5
      messages/nb/service.json
  43. 23 1
      messages/nb/settings.json
  44. 8 2
      messages/nb/workBoard.json
  45. 7 1
      messages/nb/workOrders.json
  46. 21 2
      messages/nl/integrations.json
  47. 202 5
      messages/nl/service.json
  48. 23 1
      messages/nl/settings.json
  49. 8 2
      messages/nl/workBoard.json
  50. 7 1
      messages/nl/workOrders.json
  51. 21 2
      messages/pl/integrations.json
  52. 202 5
      messages/pl/service.json
  53. 23 1
      messages/pl/settings.json
  54. 8 2
      messages/pl/workBoard.json
  55. 7 1
      messages/pl/workOrders.json
  56. 21 2
      messages/pt-BR/integrations.json
  57. 202 5
      messages/pt-BR/service.json
  58. 23 1
      messages/pt-BR/settings.json
  59. 8 2
      messages/pt-BR/workBoard.json
  60. 7 1
      messages/pt-BR/workOrders.json
  61. 21 2
      messages/pt-PT/integrations.json
  62. 202 5
      messages/pt-PT/service.json
  63. 23 1
      messages/pt-PT/settings.json
  64. 8 2
      messages/pt-PT/workBoard.json
  65. 7 1
      messages/pt-PT/workOrders.json
  66. 21 2
      messages/ru/integrations.json
  67. 202 5
      messages/ru/service.json
  68. 23 1
      messages/ru/settings.json
  69. 8 2
      messages/ru/workBoard.json
  70. 7 1
      messages/ru/workOrders.json
  71. 21 2
      messages/tr/integrations.json
  72. 202 5
      messages/tr/service.json
  73. 23 1
      messages/tr/settings.json
  74. 8 2
      messages/tr/workBoard.json
  75. 7 1
      messages/tr/workOrders.json
  76. 28 0
      prisma/migrations/20260919142636_work_order_overhaul/migration.sql
  77. 2 0
      prisma/schema/auth.prisma
  78. 34 1
      prisma/schema/service-records.prisma
  79. 3 0
      prisma/schema/technicians.prisma
  80. 1 0
      public/images/integrations/speech-to-text.svg
  81. 258 0
      src/__tests__/api/photo-handoff.test.ts
  82. 133 0
      src/__tests__/api/tech-labor-live.test.ts
  83. 60 0
      src/__tests__/features/ai/transcription.test.ts
  84. 138 0
      src/__tests__/features/backup/restore-overrides.test.ts
  85. 21 1
      src/__tests__/features/inspection-reminders/booking-actions.test.ts
  86. 3 1
      src/__tests__/features/integrations/ai.test.ts
  87. 23 0
      src/__tests__/features/integrations/speech.test.ts
  88. 15 0
      src/__tests__/features/onboarding/sample-data.test.ts
  89. 13 2
      src/__tests__/features/tire-hotel/backup-coverage.test.ts
  90. 121 0
      src/__tests__/features/vehicles/concern-story.test.ts
  91. 24 0
      src/__tests__/features/vehicles/header-title.test.ts
  92. 150 0
      src/__tests__/features/vehicles/live-labor.test.tsx
  93. 77 0
      src/__tests__/features/vehicles/promise-overdue.test.tsx
  94. 1 3
      src/__tests__/features/vehicles/work-order-title.test.ts
  95. 133 0
      src/__tests__/features/vehicles/work-orders-promised-sort.test.ts
  96. 49 0
      src/__tests__/features/workboard/availability.test.ts
  97. 1 0
      src/__tests__/features/workboard/datetime.test.ts
  98. 72 0
      src/__tests__/features/workboard/promised-on-cards.test.ts
  99. 1 0
      src/__tests__/features/workboard/week-layout.test.ts
  100. 1 1
      src/__tests__/features/workorders/concerns-section.test.tsx

+ 1 - 1
e2e/README.md

@@ -84,7 +84,7 @@ export E2E_DATABASE_URL="postgresql://torqvoice:torqvoice@localhost:5432/torqvoi
 export BETTER_AUTH_SECRET=$(grep -oP '^BETTER_AUTH_SECRET="?\K[^"]+' .env)
 npx tsx e2e/prepare-db.ts
 DATABASE_URL="$E2E_DATABASE_URL" NEXT_PUBLIC_APP_URL=http://127.0.0.1:3100 \
-  DEMO_MODE=false AUTH_RATE_LIMIT=off TORQVOICE_MODE=self-hosted \
+  DEMO_MODE=false AUTH_RATE_LIMIT=off DISABLE_BACKGROUND_JOBS=1 TORQVOICE_MODE=self-hosted \
   SMTP_HOST=127.0.0.1 SMTP_PORT=1025 SMTP_FROM_EMAIL=workshop@e2e.test \
   npm run start -- --port 3100 &
 docker run --rm --network host --user "$(id -u):$(id -g)" -e HOME=/tmp \

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

@@ -0,0 +1,171 @@
+import { expect, type Page, test } from '@playwright/test'
+import { settle } from '../../support/hydration'
+import {
+  ownerOrganizationId,
+  type PlantedVehicle,
+  plantVehicleWithFiles,
+  removePlantedVehicle,
+  userIdFor,
+} from '../../support/db'
+import {
+  onDisk,
+  type PlantedFile,
+  plantFile,
+  removePlanted,
+  sharesDiskWithServer,
+} from '../../support/files'
+import { useModernLayout } from '../../support/work-order'
+
+/**
+ * What deleting something does to the files on disk. Every delete goes
+ * through the file manager (src/lib/files/manager.ts), which removes a file
+ * only once no row points at it, only inside the workshop's own folder, and
+ * only after the rows are gone. These specs plant real files and the rows
+ * that use them, delete through the page the way a workshop does, and read
+ * the disk:
+ *
+ * - what went with the deleted rows is gone;
+ * - what something else still uses stays: a tire set's photo copied onto a
+ *   job (the set outlives the job and the vehicle), a quote's document (a
+ *   quote outlives its vehicle);
+ * - another workshop's file stays, whatever URL a row holds.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+const stamp = Date.now()
+let organizationId = ''
+let userId = ''
+const foreignOrg = `e2e-foreign-${stamp}`
+const planted: PlantedVehicle[] = []
+const files: PlantedFile[] = []
+
+async function plantAll(label: string, withInspection = true) {
+  const f = {
+    vehicleImage: await plantFile(organizationId, 'vehicles'),
+    jobPhoto: await plantFile(organizationId, 'services'),
+    tireSetPhoto: await plantFile(organizationId, 'tire-hotel'),
+    statusVideo: await plantFile(organizationId, 'services', 'mp4'),
+    inspectionPhoto: await plantFile(organizationId, 'services'),
+    quoteDocument: await plantFile(organizationId, 'quotes', 'pdf'),
+    foreignPhoto: await plantFile(foreignOrg, 'services'),
+  }
+  files.push(...Object.values(f))
+  const vehicle = await plantVehicleWithFiles(
+    organizationId,
+    userId,
+    {
+      vehicleImage: f.vehicleImage.url,
+      jobPhoto: f.jobPhoto.url,
+      tireSetPhoto: f.tireSetPhoto.url,
+      statusVideo: f.statusVideo.url,
+      inspectionPhoto: withInspection ? f.inspectionPhoto.url : undefined,
+      quoteDocument: f.quoteDocument.url,
+      foreignPhoto: f.foreignPhoto.url,
+    },
+    `${label} ${stamp}`
+  )
+  planted.push(vehicle)
+  return { f, vehicle }
+}
+
+async function expectGone(file: PlantedFile, what: string) {
+  await expect.poll(() => onDisk(file), { message: `${what} is deleted` }).toBe(false)
+}
+
+function expectKept(file: PlantedFile, what: string) {
+  expect(onDisk(file), `${what} is kept`).toBe(true)
+}
+
+async function confirmIn(page: Page, dialogTitle: RegExp) {
+  const dialog = page.getByRole('alertdialog', { name: dialogTitle })
+  await expect(dialog).toBeVisible()
+  await dialog.getByRole('button', { name: 'Delete', exact: true }).click()
+}
+
+test.beforeAll(async () => {
+  organizationId = await ownerOrganizationId()
+  userId = await userIdFor('demo@torqvoice.com')
+})
+
+test.beforeEach(() => {
+  test.skip(!sharesDiskWithServer(), 'the suite does not share a disk with the app server')
+})
+
+test.afterAll(async () => {
+  for (const vehicle of planted) await removePlantedVehicle(vehicle).catch(() => undefined)
+  await removePlanted(files)
+})
+
+test('deleting a vehicle removes its files and keeps what outlives it', async ({ page }) => {
+  const { f, vehicle } = await plantAll('E2E files vehicle')
+  for (const file of Object.values(f)) expect(onDisk(file)).toBe(true)
+
+  await page.goto(`/vehicles/${vehicle.vehicleId}`)
+  await settle(page)
+  await expect(async () => {
+    await page.getByRole('button', { name: 'Open menu' }).click()
+    await page.getByRole('menuitem', { name: 'Delete' }).click({ timeout: 2_000 })
+  }).toPass({ timeout: 30_000 })
+  await confirmIn(page, /Delete vehicle/)
+  await page.waitForURL((url) => !url.pathname.includes(vehicle.vehicleId))
+
+  // Went with the vehicle.
+  await expectGone(f.vehicleImage, "the vehicle's image")
+  await expectGone(f.jobPhoto, "its job's photo")
+  await expectGone(f.statusVideo, "its job's status report video")
+  if (vehicle.inspected) await expectGone(f.inspectionPhoto, "its inspection's photo")
+
+  // Still used: the tire set and the quote are not deleted with the vehicle.
+  expectKept(f.tireSetPhoto, "the tire set's photo, which was also on the job")
+  expectKept(f.quoteDocument, "the quote's document")
+  // Not this workshop's to delete, whatever the job's row said.
+  expectKept(f.foreignPhoto, "another workshop's file")
+})
+
+test("deleting a work order keeps the tire set's photo that was on it", async ({ page }) => {
+  const { f, vehicle } = await plantAll('E2E files job', false)
+
+  await page.goto(`/vehicles/${vehicle.vehicleId}/service/${vehicle.serviceRecordId}`)
+  await settle(page)
+  await expect(async () => {
+    await page.getByRole('button', { name: 'More actions' }).click()
+    await page.getByRole('menuitem', { name: 'Delete' }).click({ timeout: 2_000 })
+  }).toPass({ timeout: 30_000 })
+  await confirmIn(page, /Delete Service Record/)
+  await page.waitForURL((url) => !url.pathname.includes(vehicle.serviceRecordId))
+
+  await expectGone(f.jobPhoto, "the job's own photo")
+  await expectGone(f.statusVideo, "the job's status report video")
+  // This is the file the old delete took from the tire set.
+  expectKept(f.tireSetPhoto, "the tire set's photo")
+  expectKept(f.vehicleImage, "the vehicle's image")
+  expectKept(f.foreignPhoto, "another workshop's file")
+})
+
+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}`)
+  await settle(page)
+  const tiles = page.getByTestId('files-media').getByTestId('media-tile')
+  const tileOf = (file: PlantedFile) =>
+    tiles.filter({ has: page.locator(`img[src="${file.url}"]`) })
+
+  await expect(tileOf(f.jobPhoto)).toHaveCount(1)
+  await tileOf(f.jobPhoto).getByRole('button', { name: 'Delete' }).click()
+  await expect(tileOf(f.jobPhoto)).toHaveCount(0)
+  await expectGone(f.jobPhoto, 'the deleted photo')
+
+  // The tire set's photo is on the job as a copy; deleting the copy leaves
+  // the set its file.
+  await expect(tileOf(f.tireSetPhoto)).toHaveCount(1)
+  await tileOf(f.tireSetPhoto).getByRole('button', { name: 'Delete' }).click()
+  await expect(tileOf(f.tireSetPhoto)).toHaveCount(0)
+  expectKept(f.tireSetPhoto, "the tire set's photo")
+})

+ 3 - 1
e2e/specs/security/role-standing.spec.ts

@@ -129,7 +129,9 @@ test.describe('the role picker’s action', () => {
     await settle(page)
 
     // Members are bordered cards, one per person, each with its own picker.
-    const row = page.locator('div.rounded-lg.border').filter({ hasText: PEER })
+    // By test id: the page's own cards are bordered and rounded as well, and
+    // one of them holds every member.
+    const row = page.getByTestId('team-member').filter({ hasText: PEER })
     const action = page.waitForRequest((request) => Boolean(request.headers()['next-action']))
     await row.getByRole('combobox').click()
     await page.getByRole('option', { name: `E2E Plain ${stamp}`, exact: true }).click()

+ 32 - 0
e2e/specs/tech/api.spec.ts

@@ -7,6 +7,7 @@ import {
   seededTenantFixtures,
 } from '../../support/db'
 import { settle } from '../../support/hydration'
+import { laborRows } from '../../support/work-order'
 
 /**
  * The contract the technician app is built against.
@@ -303,6 +304,37 @@ test.describe('the technician app', () => {
     expect(again.status()).toBe(409)
   })
 
+  /**
+   * The other half of clocking off: the time is billed onto the job, and the
+   * desk has that job open while it happens.
+   *
+   * A work order saves its labour by replacing every line, so a desk holding
+   * a list read before the technician's line existed deletes that line on its
+   * next save. The page therefore hears about it on the work board channel
+   * and reads the job again, and the line appears without a reload. This is
+   * the whole path: phone, endpoint, socket, browser.
+   */
+  test('bills time onto the job, and the desk sees it without reloading', async ({ page }) => {
+    const description = `E2E bay labour ${stamp}`
+    await page.goto(`/vehicles/${(await seededTenantFixtures()).vehicleId}/service/${jobId}`)
+    await settle(page)
+    // The socket is opened by the app shell after hydration; a line added
+    // before it is listening would only be found by reloading.
+    await expect(laborRows(page)).toHaveCount(0)
+
+    const added = await phone(device).post(`/api/v1/tech/jobs/${jobId}/labor`, {
+      description,
+      hours: 1.5,
+    })
+    expect(added.status()).toBe(201)
+    expect((await added.json()).data.labor.hours).toBe(1.5)
+
+    // No reload anywhere in this test: the page is told.
+    await expect(laborRows(page)).toHaveCount(1, { timeout: 30_000 })
+    await expect(laborRows(page).first()).toHaveValue(description)
+    expect(page.url(), 'the page never navigated').toContain(jobId)
+  })
+
   test('asks for a day rather than everything', async () => {
     // The phone owns the technician's timezone, so it sends the window; a
     // request without one is a client mistake and says which field is missing.

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

@@ -0,0 +1,200 @@
+import { expect, test } from '@playwright/test'
+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.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+const stamp = Date.now()
+const partName = `E2E wiper blade ${stamp}`
+
+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 modern layout ${stamp}`)
+  await addPart(page, { name: partName, quantity: 2, unitPrice: 150 })
+  await saveWorkOrder(page)
+  await page.close()
+})
+
+test.describe('the overhauled work order page', () => {
+  test('is offered on the classic page and not forced on anybody', 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 [
+      'input[name="title"]',
+      '#invoiceNumber',
+      '#invoiceDate',
+      '#mileage',
+      'input[name="techName"]',
+      'input[name="serviceDate"]',
+      'textarea[placeholder="Name *"]',
+    ]) {
+      await expect(page.locator(selector), `${selector} appears once`).toHaveCount(1)
+    }
+
+    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([])
+
+    // No tab bar at the top of the page: photos, documents, diagnostics,
+    // video and status reports are tabs of one card on the job itself.
+    const files = page.getByTestId('files-media')
+    await expect(files).toBeVisible()
+    await expect(page.getByRole('button', { name: /^Images/ })).toHaveCount(0)
+    await expect(page.getByRole('button', { name: /^Status Reports/ })).toHaveCount(0)
+    for (const name of [/^Photos/, /^Documents/, /^Diagnostics/, /^Video/, /^Status Reports/]) {
+      await expect(files.getByRole('tab', { name })).toBeVisible()
+    }
+    await expect(files.getByTestId('media-add')).toContainText('Add photos')
+    await files.getByRole('tab', { name: /^Status Reports/ }).click()
+    await expect(files.getByTestId('status-reports-section')).toBeVisible()
+
+    // The money stays on screen along the bottom, with what is done about it.
+    const bar = page.getByTestId('money-bar')
+    await expect(bar).toBeInViewport()
+    await expect(bar.getByTestId('money-balance')).toContainText('300')
+    await expect(bar.getByRole('button', { name: 'Take payment' })).toBeVisible()
+    await expect(bar.getByRole('button', { name: /Preview/ })).toBeVisible()
+    await expect(bar.getByRole('button', { name: /Send/ })).toBeVisible()
+  })
+
+  test('saves the same job the classic page does', 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.
+    await expect(page.locator('textarea[placeholder="Name *"]')).toHaveValue(partName)
+
+    // The stepper is the status control: walk the job on and save.
+    const stepper = page.getByTestId('status-stepper')
+    await stepper.getByRole('button', { name: /In Progress/i }).click()
+    await expect(stepper.getByRole('button', { name: /In Progress/i })).toHaveAttribute(
+      'aria-current',
+      'step'
+    )
+    // The title is edited in the header, behind its pencil; the form carries
+    // it as a hidden field, so Save takes it with everything else.
+    const title = `E2E modern layout ${stamp} renamed`
+    await page.getByTestId('edit-title').click()
+    await page.getByTestId('title-input').fill(title)
+    await page.getByTestId('title-input').press('Enter')
+    await expect(page.getByTestId('service-title')).toHaveText(title)
+    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.locator('textarea[placeholder="Name *"]')).toHaveValue(partName)
+  })
+
+  test('stacks into one column on a phone without overflowing sideways', async ({ 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()
+
+    // The money bar is a desktop thing: the page already carries its totals,
+    // payment form, preview and send, and a phone has no height to spare.
+    await expect(page.getByTestId('money-bar')).toBeHidden()
+    // The header's second line is which job this is; when it was opened is
+    // left for a wider screen.
+    await expect(page.getByTestId('service-opened')).toBeHidden()
+
+    // Each stage of the status stepper stays in its own space: a label is cut
+    // short rather than running into the next stage.
+    const stages = await page
+      .getByTestId('status-stepper')
+      .getByRole('button')
+      .evaluateAll((buttons) => buttons.map((b) => b.getBoundingClientRect().toJSON()))
+    expect(stages).toHaveLength(4)
+    for (let i = 1; i < stages.length; i++) {
+      expect(
+        stages[i - 1].right,
+        `stage ${i} ends before stage ${i + 1} starts`
+      ).toBeLessThanOrEqual(stages[i].left)
+    }
+
+    const overflow = await layout.evaluate((el) => el.scrollWidth - el.clientWidth)
+    expect(overflow, 'sideways overflow in pixels').toBeLessThanOrEqual(1)
+
+    const main = await page.getByTestId('service-main').boundingBox()
+    const sidebar = await page.getByTestId('service-sidebar').boundingBox()
+    expect(sidebar?.y ?? 0, 'the sidebar sits under the job').toBeGreaterThan(
+      (main?.y ?? 0) + (main?.height ?? 0) - 1
+    )
+  })
+})

+ 522 - 0
e2e/specs/work-orders/modern-page.spec.ts

@@ -0,0 +1,522 @@
+import { expect, type Locator, type Page, test } from '@playwright/test'
+import {
+  deleteTechnicians,
+  insertTechnician,
+  jobAssignment,
+  ownerOrganizationId,
+  paymentsFor,
+} from '../../support/db'
+import { fillSettled, settle } from '../../support/hydration'
+import { makePdf, TINY_PNG } from '../../support/pdf'
+import { setInvoiceLock } from '../../support/settings'
+import {
+  addPart,
+  jobIdOf,
+  newWorkOrder,
+  partRows,
+  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
+ * 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
+ * untouched draft younger than five seconds instead of making a new one.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+const stamp = Date.now()
+
+let vehicleUrl = ''
+let factsJob = ''
+let filesJob = ''
+let scheduleJob = ''
+
+test.beforeAll(async ({ browser }) => {
+  const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
+  vehicleUrl = await seededVehicleUrl(page)
+  factsJob = await jobWithPart(page, `E2E modern facts ${stamp}`)
+  filesJob = await jobWithPart(page, `E2E modern files ${stamp}`)
+  scheduleJob = await jobWithPart(page, `E2E modern schedule ${stamp}`)
+  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 })
+  await saveWorkOrder(page)
+  return url
+}
+
+/** Opens a job on the overhauled page and waits for it to be usable. */
+async function openModern(page: Page, url: string): Promise<void> {
+  await page.goto(url)
+  await settle(page)
+  await expect(page.getByTestId('service-layout-modern')).toBeVisible()
+}
+
+/**
+ * Clicks low in a notes box, well below the first line, and types. The box is
+ * taller than what is written in it, and a click in that empty space has to
+ * put the cursor in the text the way a textarea does.
+ */
+async function typeInNotes(page: Page, card: Locator, text: string): Promise<void> {
+  const area = card.getByTestId('rich-text-area')
+  const editor = card.locator('.ProseMirror')
+  await expect(async () => {
+    const box = await area.boundingBox()
+    if (!box) throw new Error('notes box not on screen')
+    await area.click({ position: { x: box.width / 2, y: box.height - 12 } })
+    await expect(editor).toBeFocused({ timeout: 2_000 })
+  }).toPass({ timeout: 30_000 })
+  await page.keyboard.type(text)
+  await expect(editor).toContainText(text)
+}
+
+function stepper(page: Page): Locator {
+  return page.getByTestId('status-stepper')
+}
+
+async function expectStatus(page: Page, name: RegExp): Promise<void> {
+  await expect(stepper(page).getByRole('button', { name })).toHaveAttribute('aria-current', 'step')
+}
+
+test.describe('the overhauled work order page', () => {
+  test('keeps the type, set in the header, and the mileage, set on the facts card', async ({
+    page,
+  }) => {
+    await openModern(page, factsJob)
+
+    const facts = page.getByTestId('job-facts')
+    await expect(facts).toContainText('Camry')
+    await expect(facts.getByRole('link', { name: 'Open customer' })).toBeVisible()
+
+    const type = page.getByTestId('service-type')
+    await expect(async () => {
+      await type.click()
+      await expect(page.getByRole('option', { name: 'Repair' })).toBeVisible({ timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+    await page.getByRole('option', { name: 'Repair' }).click()
+    await fillSettled(page.locator('#mileage'), '123456')
+    await saveWorkOrder(page)
+
+    await page.reload()
+    await settle(page)
+    await expect(page.getByTestId('service-type')).toContainText('Repair')
+    await expect(page.locator('#mileage')).toHaveValue('123456')
+  })
+
+  test('shows the whole customer and vehicle under "More info", with the VIN one click from the clipboard', async ({
+    page,
+    context,
+    baseURL,
+  }) => {
+    await context.grantPermissions(['clipboard-read', 'clipboard-write'], {
+      origin: baseURL ?? 'http://127.0.0.1:3100',
+    })
+    await openModern(page, factsJob)
+    const facts = page.getByTestId('job-facts')
+    const details = facts.getByTestId('vehicle-details')
+    await expect(details).toHaveCount(0)
+
+    await expect(async () => {
+      await facts.getByRole('button', { name: 'More info' }).click()
+      await expect(details).toBeVisible({ timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+    await expect(details.getByTestId('vehicle-detail-vehicle')).toContainText('Camry')
+    // The same switch opens the customer's half: every customer has a "since".
+    await expect(facts.getByTestId('customer-details')).toContainText('Customer since')
+
+    // The reason for the list: the VIN, copied without leaving the job.
+    const vinRow = details.getByTestId('vehicle-detail-vin')
+    const vin = (await vinRow.locator('dd span').first().innerText()).trim()
+    expect(vin).toMatch(/^[A-HJ-NPR-Z0-9]{17}$/)
+    await vinRow.getByRole('button', { name: 'Copy VIN' }).click()
+    await expect(vinRow.getByRole('button', { name: 'Copied' })).toBeVisible()
+    expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(vin)
+
+    // The plate on the card copies too; the vehicle's name is the link.
+    const plate = facts.getByTestId('copy-plate')
+    const plateText = (await plate.innerText()).trim()
+    await plate.click()
+    await expect(plate).toContainText('Copied')
+    expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(plateText)
+    await expect(plate).toHaveText(plateText)
+
+    // Left open, it opens again next time; closed, it stays closed.
+    await page.reload()
+    await settle(page)
+    await expect(page.getByTestId('vehicle-details')).toBeVisible()
+    await page.getByTestId('job-facts').getByRole('button', { name: 'Less info' }).click()
+    await expect(page.getByTestId('vehicle-details')).toHaveCount(0)
+    await expect(page.getByTestId('customer-details')).toHaveCount(0)
+  })
+
+  test('writes in both notes from a click anywhere in the box, and saves them', async ({
+    page,
+  }) => {
+    await openModern(page, factsJob)
+
+    const internal = `Pads measured 2 mm on the left ${stamp}`
+    const customer = `Front pads replaced, discs within limits ${stamp}`
+    await typeInNotes(page, page.getByTestId('notes-internal'), internal)
+    await typeInNotes(page, page.getByTestId('notes-customer'), customer)
+    await saveWorkOrder(page)
+
+    await page.reload()
+    await settle(page)
+    await expect(page.getByTestId('notes-internal').locator('.ProseMirror')).toContainText(internal)
+    await expect(page.getByTestId('notes-customer').locator('.ProseMirror')).toContainText(customer)
+  })
+
+  test('tells a concern as condition, cause, correction and confirmation', async ({ page }) => {
+    await openModern(page, factsJob)
+
+    const concerns = page.getByTestId('customer-concerns')
+    const story = concerns.getByTestId('concern-story').first()
+    const words = {
+      condition: `Squeal from the front left when braking ${stamp}`,
+      cause: 'Inner pad worn to the backing plate',
+      correction: 'Replaced front pads and cleaned the slides',
+      confirm: 'Test drive, no noise',
+    }
+
+    await fillSettled(
+      story.getByPlaceholder('What the customer reported, or what you observed'),
+      words.condition
+    )
+    await story.getByTestId('concern-step-cause').click()
+    await story.getByPlaceholder('What testing showed the root cause to be').fill(words.cause)
+    await story.getByTestId('concern-step-correction').click()
+    await story.getByPlaceholder('What was done to fix it').fill(words.correction)
+    await story.getByTestId('concern-step-confirm').click()
+    await story
+      .getByPlaceholder('How the fix was checked: test drive, re-scan, symptom gone')
+      .fill(words.confirm)
+    await story.getByTestId('concern-confirmed').click()
+    await expect(story).toContainText('Stamped with your name when you save')
+    await saveWorkOrder(page)
+
+    await page.reload()
+    await settle(page)
+    const saved = page.getByTestId('customer-concerns').getByTestId('concern-story').first()
+    await expect(saved.getByTestId('concern-step-condition')).toContainText(words.condition)
+    await expect(saved.getByTestId('concern-step-cause')).toContainText(words.cause)
+    await expect(saved.getByTestId('concern-step-correction')).toContainText(words.correction)
+    await expect(saved.getByTestId('concern-step-confirm')).toContainText(words.confirm)
+    await saved.getByTestId('concern-step-confirm').click()
+    // The stamp is the server's: who ticked it, and when.
+    await expect(saved).toContainText('Confirmed by')
+    await expect(page.getByTestId('concerns-confirmed')).toHaveText('1 of 1 confirmed')
+  })
+
+  test('asks before completing a job with a concern nobody confirmed', async ({ page }) => {
+    await openModern(page, factsJob)
+
+    const concerns = page.getByTestId('customer-concerns')
+    const stories = concerns.getByTestId('concern-story')
+    const add = concerns.getByRole('button', { name: 'Add another' })
+    // A blank row may already be waiting; otherwise ask for one.
+    if (await add.isEnabled()) {
+      const before = await stories.count()
+      await expect(async () => {
+        await add.click()
+        await expect(stories).toHaveCount(before + 1, { timeout: 2_000 })
+      }).toPass({ timeout: 30_000 })
+    }
+    const open = `Aircon smells musty ${stamp}`
+    await stories
+      .last()
+      .getByPlaceholder('What the customer reported, or what you observed')
+      .fill(open)
+    await saveWorkOrder(page)
+
+    const question = page.getByRole('alertdialog', { name: 'Complete with unconfirmed concerns?' })
+    await stepper(page)
+      .getByRole('button', { name: /Completed/ })
+      .click()
+    await expect(question).toContainText(open)
+    await question.getByRole('button', { name: 'Cancel' }).click()
+    await expect(question).toBeHidden()
+    await expect(stepper(page).getByRole('button', { name: /Completed/ })).not.toHaveAttribute(
+      'aria-current',
+      'step'
+    )
+
+    await stepper(page)
+      .getByRole('button', { name: /Completed/ })
+      .click()
+    await question.getByRole('button', { name: 'Complete anyway' }).click()
+    await expectStatus(page, /Completed/)
+    await saveWorkOrder(page)
+
+    await page.reload()
+    await settle(page)
+    await expectStatus(page, /Completed/)
+  })
+
+  test('files photos and video on the job, shown to the customer until hidden', async ({
+    page,
+  }) => {
+    await openModern(page, filesJob)
+    const files = page.getByTestId('files-media')
+    const tiles = files.getByTestId('media-tile')
+
+    // A photo: shown to the customer from the moment it lands, then hidden.
+    await files.locator('input[type="file"]').setInputFiles({
+      name: `e2e-photo-${stamp}.png`,
+      mimeType: 'image/png',
+      buffer: TINY_PNG,
+    })
+    await expect(tiles).toHaveCount(1)
+    await expect(tiles.first()).toContainText('Customer can see')
+    await expect(async () => {
+      await tiles.first().getByRole('button', { name: 'Hide from the customer' }).click()
+      await expect(tiles.first()).not.toContainText('Customer can see', { timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+    // Read back: the switch turns before the write lands.
+    await expect(async () => {
+      await page.reload()
+      await settle(page)
+      await expect(page.getByTestId('media-tile').first()).not.toContainText('Customer can see', {
+        timeout: 2_000,
+      })
+    }).toPass({ timeout: 30_000 })
+
+    // A video goes to the customer too, as on the classic page: the shared
+    // link plays it. And it plays here, not in a new tab.
+    const clip = `e2e-clip-${stamp}.mp4`
+    await files.getByRole('tab', { name: /^Video/ }).click()
+    await files.locator('input[type="file"]').setInputFiles({
+      name: clip,
+      mimeType: 'video/mp4',
+      // The server keeps the bytes as they came when it cannot re-encode them.
+      buffer: Buffer.from(`not really a video ${stamp}`),
+    })
+    await expect(tiles).toHaveCount(1)
+    await expect(tiles.first()).toContainText('Customer can see')
+    await tiles.first().getByRole('button', { name: clip }).click()
+    const player = page.getByRole('dialog', { name: clip })
+    await expect(player.locator('video')).toHaveAttribute('src', /\/api\/protected\/files\//)
+    await page.keyboard.press('Escape')
+    await expect(player).toBeHidden()
+  })
+
+  test('lands a link to an old tab on that tab of the files card', async ({ page }) => {
+    await page.goto(`${filesJob}?tab=documents`)
+    await settle(page)
+    const files = page.getByTestId('files-media')
+    await expect(files.getByRole('tab', { name: /^Documents/ })).toHaveAttribute(
+      'aria-selected',
+      'true'
+    )
+    await expect(files).toBeInViewport()
+  })
+
+  test("takes photos on a phone that scanned the desk's code, and shows them at the desk as they land", async ({
+    page,
+    browser,
+  }) => {
+    await openModern(page, factsJob)
+    const files = page.getByTestId('files-media')
+    const before = await files.getByTestId('media-tile').count()
+
+    const dialog = page.getByTestId('photo-handoff-dialog')
+    await expect(async () => {
+      await files.getByTestId('photo-handoff-open').click()
+      await expect(dialog).toBeVisible({ timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+    const link = dialog.getByTestId('photo-handoff-link')
+    await expect(link).toHaveValue(/\/p\/ph1\./)
+    const url = await link.inputValue()
+
+    // The phone: another browser, signed in to nothing.
+    const phone = await browser.newContext({
+      storageState: { cookies: [], origins: [] },
+      viewport: { width: 390, height: 844 },
+    })
+    const phonePage = await phone.newPage()
+    await phonePage.goto(url)
+    await settle(phonePage)
+    await expect(phonePage.getByRole('heading', { name: 'Add photos and documents' })).toBeVisible()
+    await expect(phonePage.getByRole('button', { name: 'Take photo' })).toBeVisible()
+    // The workshop's own page, as far as the customer holding the phone can tell.
+    await expect(phonePage.getByTestId('photo-handoff-workshop')).not.toBeEmpty()
+    await phonePage.getByTestId('photo-handoff-library').setInputFiles({
+      name: `e2e-phone-${stamp}.png`,
+      mimeType: 'image/png',
+      buffer: TINY_PNG,
+    })
+    await expect(phonePage.getByTestId('photo-handoff-added')).toHaveText('1 file added')
+
+    // And a customer's paperwork: a PDF goes on the job as a document.
+    await phonePage.getByTestId('photo-handoff-document').setInputFiles({
+      name: `e2e-registration-${stamp}.pdf`,
+      mimeType: 'application/pdf',
+      buffer: await makePdf([`Registration ${stamp}`]),
+    })
+    await expect(phonePage.getByTestId('photo-handoff-added')).toHaveText('2 files added')
+
+    // A link somebody changed opens nothing.
+    await phonePage.goto(url.replace(/.$/, (last) => (last === 'A' ? 'B' : 'A')))
+    await expect(phonePage.getByTestId('photo-handoff-problem')).toContainText(
+      'This link does not work'
+    )
+    await phone.close()
+
+    // At the desk the photo arrives without anybody reloading.
+    await expect(dialog.getByTestId('photo-handoff-received')).toHaveText('2 files received', {
+      timeout: 15_000,
+    })
+    await dialog.getByRole('button', { name: 'Done' }).click()
+    await expect(files.getByTestId('media-tile')).toHaveCount(before + 1)
+    await files.getByRole('tab', { name: /^Documents/ }).click()
+    // A document's name is its caption until somebody writes one.
+    await expect(files.getByPlaceholder(`e2e-registration-${stamp}.pdf`)).toHaveCount(1)
+  })
+
+  test.describe('the technician list', () => {
+    let technicianId = ''
+    const technicianName = `E2E Mechanic ${stamp}`
+
+    test.beforeAll(async () => {
+      technicianId = await insertTechnician(await ownerOrganizationId(), technicianName)
+    })
+
+    test.afterAll(async () => {
+      await deleteTechnicians([technicianId])
+    })
+
+    test('puts a technician on the job with one click', async ({ page }) => {
+      await openModern(page, scheduleJob)
+
+      const list = page.getByRole('radiogroup', { name: 'Technician' })
+      const technician = list.getByRole('radio', { name: new RegExp(technicianName) })
+      await expect(async () => {
+        await technician.click()
+        await expect(technician).toHaveAttribute('aria-checked', 'true', { timeout: 2_000 })
+      }).toPass({ timeout: 30_000 })
+      await expect
+        .poll(async () => (await jobAssignment(jobIdOf(scheduleJob))).technicianId)
+        .toBe(technicianId)
+
+      await page.reload()
+      await settle(page)
+      await expect(
+        page
+          .getByRole('radiogroup', { name: 'Technician' })
+          .getByRole('radio', { name: new RegExp(technicianName) })
+      ).toHaveAttribute('aria-checked', 'true')
+      // The way to the workshop's defaults is here, as on the classic picker.
+      await expect(page.getByRole('link', { name: 'Set Defaults' })).toHaveAttribute(
+        'href',
+        '/settings/workshop'
+      )
+    })
+  })
+
+  test.describe('on a locked invoice', () => {
+    let lockedJob = ''
+
+    test.beforeAll(async ({ browser }) => {
+      const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
+      // On first, so the share below is a send the lock counts.
+      await setInvoiceLock(page, { enabled: true, trigger: 'sent' })
+      lockedJob = await jobWithPart(page, `E2E modern locked ${stamp}`)
+      await shareLink(page)
+      await page.close()
+    })
+
+    test.afterAll(async ({ browser }) => {
+      const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
+      await setInvoiceLock(page, { enabled: false, trigger: 'sent' })
+      await page.close()
+    })
+
+    test('freezes what is owed and nothing else', async ({ page }) => {
+      await openModern(page, lockedJob)
+      await expect(
+        page.getByText('This invoice is locked because it has been sent', { exact: true })
+      ).toBeVisible()
+
+      // Frozen: the lines, the mileage, and the notes the invoice prints.
+      await expect(partRows(page).first()).toBeDisabled()
+      await expect(page.locator('#mileage')).toBeDisabled()
+      // Still readable: the vehicle's details open on a locked invoice too.
+      await page.getByTestId('job-facts').getByRole('button', { name: 'More info' }).click()
+      await expect(page.getByTestId('vehicle-details')).toBeVisible()
+      await expect(page.getByTestId('notes-customer').locator('.ProseMirror')).toHaveAttribute(
+        'contenteditable',
+        'false'
+      )
+
+      // Still moving: the status, saved on its own.
+      await stepper(page)
+        .getByRole('button', { name: /Waiting Parts/ })
+        .click()
+      await expectStatus(page, /Waiting Parts/)
+
+      // Still written: the internal notes, saved on their own.
+      const note = `Customer will collect on Friday ${stamp}`
+      await typeInNotes(page, page.getByTestId('notes-internal'), note)
+      await expect(page.getByText('Saved', { exact: true })).toBeVisible()
+
+      // Still paid: the bar's button opens a form that works, and Enter in
+      // the amount records the payment rather than saving the job.
+      const bar = page.getByTestId('money-bar')
+      await bar.getByRole('button', { name: 'Take payment' }).click()
+      const amount = page.locator('#paymentAmount')
+      await expect(amount).toBeEnabled()
+      await expect(amount).toBeFocused()
+      await amount.fill('100')
+      await amount.press('Enter')
+      await expect(page.getByText('Payment recorded', { exact: true })).toBeVisible()
+      // Recording a payment offers to tell the customer; not today.
+      const notify = page.getByRole('dialog', { name: /^Notify / })
+      await expect(notify).toBeVisible()
+      await page.keyboard.press('Escape')
+      await expect(notify).toBeHidden()
+      await expect(bar.getByTestId('money-paid')).toContainText('100')
+      await expect.poll(async () => (await paymentsFor(jobIdOf(lockedJob))).length).toBe(1)
+      expect((await paymentsFor(jobIdOf(lockedJob)))[0].amount).toBe(100)
+
+      await page.reload()
+      await settle(page)
+      await expectStatus(page, /Waiting Parts/)
+      await expect(page.getByTestId('notes-internal').locator('.ProseMirror')).toContainText(note)
+      const activity = page
+        .getByRole('heading', { name: 'Activity', exact: true })
+        .locator('xpath=ancestor::*[.//ol][1]')
+      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')
+    })
+  })
+})

+ 120 - 0
e2e/support/db.ts

@@ -1335,3 +1335,123 @@ export async function jobWithCustomer(organizationId: string): Promise<{
     return row
   })
 }
+
+export interface PlantedVehicleFiles {
+  vehicleImage: string
+  jobPhoto: string
+  /** A tire set's photo, also on the job as a tire hotel copy. */
+  tireSetPhoto: string
+  statusVideo: string
+  inspectionPhoto?: string
+  quoteDocument: string
+  /** A URL naming another workshop, as a row restored from its backup can. */
+  foreignPhoto: string
+}
+
+export interface PlantedVehicle {
+  vehicleId: string
+  serviceRecordId: string
+  tireSetId: string
+  quoteId: string
+  inspected: boolean
+}
+
+/**
+ * A vehicle with every kind of file that can go with it, written straight
+ * into the database so a spec knows exactly which rows point at which file:
+ * its image; a job with a photo, a status report video and the copy of a tire
+ * set's photo; an inspection with a photo on one item (when the workshop has
+ * a template to hang it on); and, pointing at the same vehicle but not
+ * deleted with it, a stored tire set and a quote with a document.
+ */
+export async function plantVehicleWithFiles(
+  organizationId: string,
+  userId: string,
+  files: PlantedVehicleFiles,
+  label: string
+): Promise<PlantedVehicle> {
+  return withDb(async (db) => {
+    const id = () => randomBytes(12).toString('hex')
+    const vehicleId = id()
+    const serviceRecordId = id()
+    const tireSetId = id()
+    const quoteId = id()
+    await db.query(
+      `insert into vehicles (id, make, model, year, "userId", "organizationId", "imageUrl", "updatedAt")
+       values ($1, 'E2E', $2, 2020, $3, $4, $5, now())`,
+      [vehicleId, label, userId, organizationId, files.vehicleImage]
+    )
+    await db.query(
+      `insert into service_records (id, title, "vehicleId", "organizationId", "updatedAt")
+       values ($1, $2, $3, $4, now())`,
+      [serviceRecordId, `${label} job`, vehicleId, organizationId]
+    )
+    await db.query(
+      `insert into tire_sets (id, "organizationId", "userId", "vehicleId", "updatedAt")
+       values ($1, $2, $3, $4, now())`,
+      [tireSetId, organizationId, userId, vehicleId]
+    )
+    await db.query(
+      `insert into tire_set_attachments (id, "organizationId", "tireSetId", "fileName", "fileUrl", "fileType", "fileSize")
+       values ($1, $2, $3, 'rim.jpg', $4, 'image/jpeg', 10)`,
+      [id(), organizationId, tireSetId, files.tireSetPhoto]
+    )
+    for (const [fileUrl, category] of [
+      [files.jobPhoto, 'image'],
+      [files.tireSetPhoto, 'tire_hotel'],
+      [files.foreignPhoto, 'image'],
+    ]) {
+      await db.query(
+        `insert into service_attachments (id, "serviceRecordId", "fileName", "fileUrl", "fileType", "fileSize", category)
+         values ($1, $2, 'photo.jpg', $3, 'image/jpeg', 10, $4)`,
+        [id(), serviceRecordId, fileUrl, category]
+      )
+    }
+    await db.query(
+      `insert into status_reports (id, "publicToken", "organizationId", "serviceRecordId", "videoUrl", "updatedAt")
+       values ($1, $2, $3, $4, $5, now())`,
+      [id(), id(), organizationId, serviceRecordId, files.statusVideo]
+    )
+    await db.query(
+      `insert into quotes (id, title, "userId", "organizationId", "vehicleId", "updatedAt")
+       values ($1, $2, $3, $4, $5, now())`,
+      [quoteId, `${label} quote`, userId, organizationId, vehicleId]
+    )
+    await db.query(
+      `insert into quote_attachments (id, "quoteId", "fileName", "fileUrl", "fileType", "fileSize")
+       values ($1, $2, 'estimate.pdf', $3, 'application/pdf', 10)`,
+      [id(), quoteId, files.quoteDocument]
+    )
+
+    let inspected = false
+    const template = await db.query<{ id: string }>(
+      `select id from inspection_templates where "organizationId" = $1 limit 1`,
+      [organizationId]
+    )
+    if (files.inspectionPhoto && template.rows[0]) {
+      const inspectionId = id()
+      await db.query(
+        `insert into inspections (id, "vehicleId", "organizationId", "templateId", "updatedAt")
+         values ($1, $2, $3, $4, now())`,
+        [inspectionId, vehicleId, organizationId, template.rows[0].id]
+      )
+      await db.query(
+        `insert into inspection_items (id, "inspectionId", name, section, "imageUrls")
+         values ($1, $2, 'Brakes', 'Checks', $3)`,
+        [id(), inspectionId, [files.inspectionPhoto]]
+      )
+      inspected = true
+    }
+    return { vehicleId, serviceRecordId, tireSetId, quoteId, inspected }
+  })
+}
+
+/** Removes what `plantVehicleWithFiles` made that its spec did not delete. */
+export async function removePlantedVehicle(planted: PlantedVehicle): Promise<void> {
+  await withDb(async (db) => {
+    await db.query('delete from quotes where id = $1', [planted.quoteId])
+    await db.query('delete from tire_sets where id = $1', [planted.tireSetId])
+    await db.query('delete from inspections where "vehicleId" = $1', [planted.vehicleId])
+    await db.query('delete from vehicles where id = $1', [planted.vehicleId])
+  })
+}

+ 49 - 0
e2e/support/files.ts

@@ -0,0 +1,49 @@
+import { randomUUID } from 'node:crypto'
+import { existsSync } from 'node:fs'
+import { mkdir, rm, writeFile } from 'node:fs/promises'
+import path from 'node:path'
+
+/**
+ * Real files in the app server's upload folder, for the specs that check
+ * what deleting something does to the disk. The server and the suite share
+ * a disk locally and on CI (the server runs from the repository, and so
+ * `data/uploads` is the same folder for both); a spec skips when they do not.
+ */
+
+/** Where the app server keeps a workshop's uploads, as the suite sees it. */
+export function uploadPath(organizationId: string, ...segments: string[]): string {
+  return path.join('data', 'uploads', organizationId, ...segments)
+}
+
+/** Whether this run can see the server's uploads at all. */
+export function sharesDiskWithServer(): boolean {
+  return existsSync(path.join('data', 'uploads'))
+}
+
+export interface PlantedFile {
+  /** What a row stores. */
+  url: string
+  /** Where it is on disk. */
+  path: string
+}
+
+/** A file with a name like the upload routes give, and the URL a row would hold. */
+export async function plantFile(
+  organizationId: string,
+  folder: string,
+  ext = 'jpg'
+): Promise<PlantedFile> {
+  const name = `${randomUUID()}.${ext}`
+  const file = uploadPath(organizationId, folder, name)
+  await mkdir(path.dirname(file), { recursive: true })
+  await writeFile(file, `e2e ${folder} ${name}`)
+  return { url: `/api/protected/files/${organizationId}/${folder}/${name}`, path: file }
+}
+
+export function onDisk(file: PlantedFile): boolean {
+  return existsSync(file.path)
+}
+
+export async function removePlanted(files: PlantedFile[]): Promise<void> {
+  await Promise.all(files.map((file) => rm(file.path, { force: true })))
+}

+ 36 - 0
e2e/support/settings.ts

@@ -129,6 +129,42 @@ export async function setQuoteLock(
   await expect(page.getByText('Invoice settings saved', { exact: true })).toBeVisible()
 }
 
+export interface InvoiceLockSetup {
+  enabled: boolean
+  trigger: 'sent' | 'paid'
+}
+
+/**
+ * Settings → Invoice, the invoice half of "Locking finished documents", saved.
+ * Off by default, so a spec that turns it on turns it off again. Invoices sent
+ * before it was turned on stay editable, so a spec sends its job afterwards.
+ */
+export async function setInvoiceLock(
+  page: Page,
+  { enabled, trigger }: InvoiceLockSetup
+): Promise<void> {
+  await page.goto('/settings/invoice')
+  await settle(page)
+
+  // As with the quote lock: the trigger is disabled while the switch is off.
+  const toggle = page.locator('#invoiceLockEnabled')
+  await setSwitch(toggle, true)
+
+  const wanted =
+    trigger === 'sent' ? 'Once the invoice is sent' : 'Once the invoice is paid in full'
+  const select = page.locator('#invoiceLockTrigger')
+  await expect(async () => {
+    await select.click()
+    await expect(page.getByRole('option', { name: wanted })).toBeVisible({ timeout: 2_000 })
+  }).toPass({ timeout: 30_000 })
+  await page.getByRole('option', { name: wanted }).click()
+  await expect(select).toContainText(wanted)
+
+  await setSwitch(toggle, enabled)
+  await page.getByRole('button', { name: 'Save Invoice Settings', exact: true }).click()
+  await expect(page.getByText('Invoice settings saved', { exact: true })).toBeVisible()
+}
+
 /** What the settings page currently offers as the next invoice number. */
 export async function invoiceStartNumber(page: Page): Promise<string> {
   await page.goto('/settings/invoice')

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

@@ -1,4 +1,4 @@
-import { expect, type Locator, type Page } from '@playwright/test'
+import { expect, type BrowserContext, type Locator, type Page } from '@playwright/test'
 import { settle } from './hydration'
 
 /**
@@ -21,6 +21,22 @@ 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()
+  if (!id) throw new Error(`no job id in ${jobUrl}`)
+  return id
+}
+
 /** 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`)

+ 21 - 2
messages/de/integrations.json

@@ -60,7 +60,8 @@
       "safety": "Rückrufe und Beschwerden von Haltern"
     },
     "ai": {
-      "chat": "Texte und Zusammenfassungen"
+      "chat": "Texte und Zusammenfassungen",
+      "transcribe": "Diktieren"
     },
     "payments": {
       "checkout": "Bezahlen über den Rechnungslink"
@@ -156,7 +157,8 @@
       "syncNowHelp": "Jetzt synchronisieren startet sofort einen Durchlauf statt auf den stündlichen zu warten. Er fragt 25 Fahrzeuge auf einmal ab und macht von selbst weiter, bis alle fälligen Fahrzeuge erledigt sind."
     },
     "supersedes": "Wenn Sie {next} verbinden, wird {current} getrennt. Nur einer kann zuständig sein: Sobald {next} aktiv ist, wird {current} nicht mehr genutzt.",
-    "batchRetried": "{count, plural, =0 {Nichts zu wiederholen} one {# Auftrag erneut eingereiht} other {# Aufträge erneut eingereiht}}"
+    "batchRetried": "{count, plural, =0 {Nichts zu wiederholen} one {# Auftrag erneut eingereiht} other {# Aufträge erneut eingereiht}}",
+    "advanced": "Erweitert"
   },
   "activity": {
     "title": "Aktivität",
@@ -578,6 +580,23 @@
         "refreshFleet": "Rückruf- und Beschwerdedaten für jedes Fahrzeug dieser Werkstatt aktuell halten",
         "refreshFleetHelp": "Einmal täglich werden Modelle, die in der letzten Woche nicht aktualisiert wurden, erneut abgerufen, damit die Fahrzeugseite nie wartet und ein neuer Rückruf innerhalb einer Woche nach Einreichung erscheint. Nur Marke, Modell und Jahr werden gesendet."
       }
+    },
+    "speech-to-text": {
+      "description": "Diktat auf einem Arbeitsauftrag, aufgeschrieben von einem Sprachmodell statt vom Browser: viel genauer, in jeder Sprache, und es kennt die Wörter einer Werkstatt. Getrennt von Ihrem KI-Anbieter, den Sie also so lassen können, wie er ist. Nutzt Ihren eigenen OpenAI-Schlüssel oder einen eigenen Server, der die Transkriptions-API von OpenAI spricht. Die Aufnahme wird nur zum Aufschreiben gesendet und nicht aufbewahrt.",
+      "fields": {
+        "apiKey": "API-Schlüssel",
+        "apiKeyHelp": "Ein OpenAI-Schlüssel von platform.openai.com unter API keys; OpenAI rechnet die Nutzung pro Minute ab. Bei einem eigenen Server nur, wenn er einen verlangt.",
+        "baseUrl": "Serveradresse",
+        "baseUrlHelp": "Leer lassen, um OpenAI zu nutzen. Bei einem eigenen Server (ein lokales Whisper, LocalAI, Groq) die Adresse, an der seine OpenAI-artige API beginnt, einschließlich Pfad, etwa /v1. Torqvoice hängt /audio/transcriptions daran an."
+      },
+      "settings": {
+        "model": "Modell",
+        "modelHelp": "Das Sprachmodell, das angefragt wird. gpt-4o-mini-transcribe ist genau und günstig; gpt-4o-transcribe ist das größere. Ein eigener Server hört meist auf whisper-1.",
+        "mode": "Wer entscheidet, wie das Diktat aufgeschrieben wird",
+        "modeHelp": "Der Browser schreibt mit, während Sie sprechen, ist aber schwächer, vor allem bei kleineren Sprachen. Das Sprachmodell schreibt, sobald Sie aufhören.",
+        "modeAi": "Immer das Sprachmodell",
+        "modeChoice": "Jede Person wählt: Sprachmodell oder Browser"
+      }
     }
   },
   "jobKinds": {

+ 202 - 5
messages/de/service.json

@@ -21,7 +21,8 @@
       "title": "Der Auftrag braucht einen Titel, bevor er gespeichert werden kann.",
       "negative": "Mengen, Stunden, Preise und Kosten dürfen nicht negativ sein.",
       "partName": "Jedes Teil mit Preis braucht einen Namen.",
-      "laborDescription": "Jede Arbeitszeile mit Stunden oder Satz braucht eine Beschreibung."
+      "laborDescription": "Jede Arbeitszeile mit Stunden oder Satz braucht eine Beschreibung.",
+      "concernCondition": "Ein Anliegen mit Ursache, Behebung oder Kontrolle braucht eine ausgefüllte Beanstandung."
     }
   },
   "header": {
@@ -164,6 +165,11 @@
     "deleteRow": "Zeile löschen",
     "descriptionMissingHint": "Zeilen ohne Beschreibung werden nicht gespeichert."
   },
+  "liveLabor": {
+    "title": "{count, plural, one {Ein Techniker hat Arbeit zu diesem Auftrag hinzugefügt} other {Ein Techniker hat # Arbeitspositionen zu diesem Auftrag hinzugefügt}}",
+    "body": "Sie bleibt beim Bearbeiten aus der Liste. Beim nächsten Speichern wird sie ohnehin übernommen.",
+    "show": "Anzeigen"
+  },
   "totals": {
     "title": "Summen",
     "parts": "Teile",
@@ -198,7 +204,9 @@
     "aiOverwriteReplace": "Ersetzen",
     "aiOverwriteAppend": "Anhängen",
     "aiOverwriteCancel": "Abbrechen",
-    "aiDontShowAgain": "Nicht mehr anzeigen"
+    "aiDontShowAgain": "Nicht mehr anzeigen",
+    "internalTitle": "Interne Notizen",
+    "publicTitle": "Kundennotizen"
   },
   "invoice": {
     "title": "Rechnungszusammenfassung",
@@ -248,7 +256,10 @@
     "failedDelete": "Zahlung konnte nicht gelöscht werden",
     "deleteTitle": "Zahlung löschen",
     "deleteDescription": "Dies entfernt diesen Zahlungseintrag.",
-    "failedPdf": "PDF-Rechnung konnte nicht erstellt werden"
+    "failedPdf": "PDF-Rechnung konnte nicht erstellt werden",
+    "received": "Erhaltene Zahlungen",
+    "noneYet": "Noch nichts bezahlt.",
+    "balanceDue": "Ausstehend"
   },
   "attachments": {
     "deleteTitle": "Anlage löschen",
@@ -357,6 +368,7 @@
     "date": "Datum",
     "startTime": "Startzeit",
     "endTime": "Endzeit",
+    "durationPresets": "Dauer",
     "technician": "Techniker",
     "selectTechnician": "Techniker auswählen...",
     "searchOrCreate": "Techniker suchen oder erstellen...",
@@ -394,7 +406,17 @@
     "pickNextAvailable": "Nächsten freien Termin wählen",
     "findingSlot": "Termin wird gesucht…",
     "slotPicked": "Auf den nächsten freien Termin gelegt",
-    "noSlotFound": "In den nächsten Wochen kein freier Termin gefunden"
+    "noSlotFound": "In den nächsten Wochen kein freier Termin gefunden",
+    "assignedTitle": "Zugewiesener Techniker",
+    "boardLink": "Auftragsboard",
+    "someoneElse": "Jemand anderes",
+    "loadOf": "{booked} / {capacity} h",
+    "loadFree": "{hours} h frei",
+    "loadFull": "Ausgebucht",
+    "loadOn": "Gebuchte Stunden am {date}",
+    "promisedAt": "Dem Kunden zugesagt",
+    "promisedPlaceholder": "Noch nicht zugesagt",
+    "promisedClear": "Leeren"
   },
   "notifications": {
     "title": "Kundenbenachrichtigungen",
@@ -447,7 +469,60 @@
     "placeholder": "Beim Bremsen kommt hinten ein Geräusch",
     "remove": "Dieses Anliegen entfernen",
     "notAnswered": "Dazu wurde noch nichts festgestellt",
-    "answered": "{count} Feststellung(en) dazu"
+    "answered": "{count} Feststellung(en) dazu",
+    "storyTitle": "Auftragsbeschreibung",
+    "storyHint": "Beanstandung, Ursache, Behebung, Kontrolle. Klicken Sie auf einen Schritt, um ihn zu öffnen; eine Zeile für jeden Punkt, den der Kunde genannt oder Sie festgestellt haben.",
+    "steps": {
+      "condition": "Beanstandung",
+      "cause": "Ursache",
+      "correction": "Behebung",
+      "confirm": "Kontrolle"
+    },
+    "conditionPlaceholder": "Was der Kunde gemeldet hat oder was Sie festgestellt haben",
+    "causePlaceholder": "Was die Prüfung als eigentliche Ursache ergeben hat",
+    "correctionPlaceholder": "Was zur Behebung getan wurde",
+    "confirmPlaceholder": "Wie die Behebung geprüft wurde: Probefahrt, erneuter Scan, Symptom weg",
+    "markConfirmed": "Bestätigt",
+    "confirmedBy": "Bestätigt von {name}, {date}",
+    "confirmedAt": "Bestätigt am {date}",
+    "confirmedOnSave": "Wird beim Speichern mit Ihrem Namen versehen",
+    "confirmedCount": "{confirmed} von {total} bestätigt",
+    "findingsCount": "{count, plural, one {# Beobachtung} other {# Beobachtungen}}",
+    "addFinding": "Beobachtung hinzufügen",
+    "unconfirmedTitle": "Mit unbestätigten Anliegen abschließen?",
+    "unconfirmedBody": "{count, plural, one {Bei einem Anliegen ist die Behebung nicht bestätigt:} other {Bei # Anliegen ist die Behebung nicht bestätigt:}}",
+    "unconfirmedProceed": "Trotzdem abschließen",
+    "addMedia": "Medien hinzufügen",
+    "mediaUploading": "Wird hochgeladen…",
+    "mediaAdded": "{count, plural, one {# Datei hinzugefügt} other {# Dateien hinzugefügt}}",
+    "mediaFailed": "{name} konnte nicht hochgeladen werden",
+    "mediaTypes": "Wählen Sie ein Foto (JPG, PNG, WebP) oder ein Video.",
+    "dictate": {
+      "start": "Diktieren",
+      "listening": "Hört zu",
+      "startHint": "Sprechen Sie, und es wird hier aufgeschrieben. Nichts wird aufgenommen.",
+      "stopHint": "Zuhören beenden",
+      "notAllowed": "Der Browser durfte das Mikrofon nicht verwenden. Erlauben Sie es für diese Seite und versuchen Sie es erneut.",
+      "noMicrophone": "Kein Mikrofon gefunden.",
+      "failed": "Diktat abgebrochen. Versuchen Sie es erneut.",
+      "startHintServer": "Sprechen Sie, und es wird beim Reden aufgeschrieben. Der Ton geht zum Aufschreiben an Ihr Sprachmodell und wird nicht aufbewahrt.",
+      "transcribing": "Wird abgeschlossen…",
+      "language": "Gesprochene Sprache",
+      "auto": "Automatisch",
+      "nothingHeard": "Es war nichts zu hören.",
+      "fellBack": "Vorerst wird das Diktieren des Browsers verwendet.",
+      "autoLong": "Automatisch (Sprache erkennen)",
+      "engine": "Aufgeschrieben von",
+      "engineAi": "Sprachmodell (genauer)",
+      "engineBrowser": "Browser (schreibt beim Sprechen mit)",
+      "liveFellBack": "Schreibt in Schritten von wenigen Sekunden statt Wort für Wort."
+    },
+    "stepEmpty": "Noch nicht ausgefüllt",
+    "removeTitle": "Dieses Anliegen entfernen?",
+    "removeBody": "Beanstandung, Ursache, Behebung und Kontrolle werden beim Speichern des Arbeitsauftrags mit entfernt.",
+    "removeAttached": "Die zugehörigen Beobachtungen und Medien bleiben am Auftrag, nur nicht mehr unter einem Anliegen abgelegt.",
+    "removeConfirm": "Anliegen entfernen",
+    "saveFirst": "Speichern Sie zuerst den Arbeitsauftrag"
   },
   "videoCall": {
     "title": "Videoanruf",
@@ -493,5 +568,127 @@
       "joinLine": "Hier dem Videoanruf beitreten: {url}",
       "emailSubject": "Videoanruf zu deinem {vehicle}"
     }
+  },
+  "modern": {
+    "statusLabel": "Status des Arbeitsauftrags",
+    "back": "Zurück",
+    "backToClassic": "Zurück zum klassischen Layout",
+    "openCustomer": "Kunde öffnen",
+    "serviceHistory": "Servicehistorie",
+    "changeVehicle": "Fahrzeug ändern",
+    "vehicleChangesOnSave": "Der Arbeitsauftrag wechselt beim Speichern zu diesem Fahrzeug.",
+    "phone": "Telefon",
+    "email": "E-Mail",
+    "notSet": "Nicht angegeben",
+    "noCustomer": "Kein Kunde auf diesem Arbeitsauftrag.",
+    "invoiceTitle": "Rechnung & Zahlung",
+    "filesTitle": "Dateien & Medien",
+    "activity": {
+      "title": "Aktivität",
+      "messageSent": "Nachricht an den Kunden gesendet",
+      "linkShared": "Rechnungslink geteilt",
+      "linkViewed": "{count, plural, one {Kunde hat den Link geöffnet} other {Kunde hat den Link # Mal geöffnet}}",
+      "paymentTaken": "Zahlung von {amount} aufgezeichnet"
+    },
+    "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}",
+    "openedBy": "Eröffnet am {date} von {name}",
+    "editTitle": "Titel bearbeiten",
+    "titleField": "Titel",
+    "media": {
+      "tabs": {
+        "photos": "Fotos",
+        "diagnostics": "Diagnose"
+      },
+      "summary": "{count, plural, one {# Datei} other {# Dateien}} · nur markierte werden mit dem Kunden geteilt",
+      "customerCanSee": "Für Kunden sichtbar",
+      "showToCustomer": "Dem Kunden zeigen",
+      "hideFromCustomer": "Vor dem Kunden verbergen",
+      "add": {
+        "image": "Fotos hinzufügen",
+        "document": "Dokumente hinzufügen",
+        "diagnostic": "Diagnosebericht hinzufügen",
+        "video": "Video hinzufügen"
+      },
+      "dropHint": "Dateien hier ablegen oder zum Durchsuchen klicken",
+      "added": "{count, plural, one {# Datei hinzugefügt} other {# Dateien hinzugefügt}}",
+      "failed": "{name} konnte nicht hochgeladen werden",
+      "wrongType": "Nicht die richtige Dateiart für diesen Tab: {names}",
+      "limit": "Limit erreicht ({count}/{max})"
+    },
+    "moneyBar": {
+      "total": "Gesamt",
+      "paid": "Bezahlt",
+      "balance": "Ausstehend",
+      "settled": "Vollständig bezahlt",
+      "takePayment": "Zahlung annehmen",
+      "preview": "Rechnungsvorschau",
+      "previewShort": "Vorschau",
+      "send": "An Kunden senden",
+      "sendShort": "Senden"
+    },
+    "vehicleInfo": {
+      "more": "Mehr Infos",
+      "less": "Weniger Infos",
+      "copy": "{label} kopieren",
+      "copied": "Kopiert",
+      "lastInspection": "Letzte Hauptuntersuchung",
+      "purchaseDate": "Kaufdatum",
+      "purchasePrice": "Kaufpreis",
+      "overdue": "überfällig",
+      "customerSince": "Kunde seit"
+    }
+  },
+  "photoHandoff": {
+    "button": "Vom Handy hinzufügen",
+    "dialogTitle": "Fotos und Dokumente per Handy hinzufügen",
+    "dialogBody": "Fotos mit einem beliebigen Handy aufnehmen oder auswählen oder ein PDF teilen. Alles landet direkt in diesem Auftrag.",
+    "expires": "Der Code gilt bis {time}.",
+    "waiting": "Warten auf Fotos und Dokumente…",
+    "received": "{count, plural, one {# Datei empfangen} other {# Dateien empfangen}}",
+    "copyLink": "Link kopieren",
+    "linkCopied": "Link kopiert",
+    "failed": "Code konnte nicht erstellt werden. Versuchen Sie es erneut.",
+    "done": "Fertig",
+    "pageTitle": "Fotos und Dokumente hinzufügen",
+    "forJob": "Arbeitsauftrag {number}",
+    "takePhoto": "Foto aufnehmen",
+    "choosePhotos": "Fotos auswählen",
+    "hint": "Alles landet direkt im Auftrag. Machen Sie so viele Fotos, wie Sie brauchen.",
+    "uploading": "Wird hochgeladen…",
+    "added": "Hinzugefügt",
+    "notAdded": "Nicht hinzugefügt",
+    "retry": "Erneut versuchen",
+    "addedCount": "{count, plural, one {# Datei hinzugefügt} other {# Dateien hinzugefügt}}",
+    "expiredTitle": "Dieser Code ist abgelaufen",
+    "invalidTitle": "Dieser Link funktioniert nicht",
+    "errors": {
+      "expired": "Fragen Sie am Empfang nach einem neuen Code. Jeder Code gilt 30 Minuten.",
+      "invalid": "Scannen Sie den Code am Arbeitsauftrag erneut.",
+      "limit": "Dieser Auftrag hat bereits so viele Fotos, wie er aufnehmen kann.",
+      "type": "Hier können nur Fotos und PDF-Dokumente hinzugefügt werden.",
+      "tooLarge": "Diese Datei ist zu groß.",
+      "failed": "Das Foto wurde nicht hinzugefügt. Prüfen Sie die Verbindung und versuchen Sie es erneut.",
+      "codeLimit": "Mit diesem Code wurden so viele Fotos hinzugefügt, wie ein Code erlaubt. Scannen Sie am Empfang einen neuen Code."
+    },
+    "concernHeading": "Die Fotos gehören zu diesem Anliegen",
+    "howTitle": "So funktioniert es",
+    "step1": "Richten Sie die Handykamera auf den Code und öffnen Sie den Link.",
+    "step2": "Nehmen Sie Fotos auf oder wählen Sie Fotos oder PDF-Dokumente aus, die schon auf dem Handy sind.",
+    "step3": "Sie erscheinen in diesem Auftrag, sobald sie ankommen.",
+    "noSignIn": "Auf dem Handy ist keine Anmeldung nötig.",
+    "customerToo": "Mitarbeiter können es nutzen, und der Kunde auch: Lassen Sie ihn den Code scannen oder senden Sie ihm den Link.",
+    "scope": "Der Link fügt nur Fotos und Dokumente zu diesem Auftrag hinzu und funktioniert 30 Minuten lang.",
+    "scanHint": "Mit der Handykamera scannen",
+    "chooseDocument": "Dokument hochladen",
+    "privacy": "Nur die Werkstatt sieht, was Sie hier hinzufügen."
   }
 }

+ 23 - 1
messages/de/settings.json

@@ -1229,7 +1229,29 @@
     "dateLabel": "Datum",
     "dateTimeLabel": "Datum & Uhrzeit",
     "saveSettings": "Einstellungen speichern",
-    "saved": "Einstellungen gespeichert"
+    "saved": "Einstellungen gespeichert",
+    "fontLabel": "Schriftart",
+    "fontHint": "Wählen Sie die Schriften, in denen die App gesetzt ist. Die Auswahl wird auf diesem Gerät gespeichert.",
+    "fontSampleHeading": "Arbeitsauftrag",
+    "fontSampleText": "Zündspule und Zündkerzen ersetzen",
+    "fonts": {
+      "default": {
+        "name": "Standard",
+        "description": "Geist, klar und neutral."
+      },
+      "workshop": {
+        "name": "Werkstatt",
+        "description": "IBM Plex mit schmalen Überschriften in Großbuchstaben, wie ein Auftragszettel."
+      },
+      "precise": {
+        "name": "Präzise",
+        "description": "Inter und JetBrains Mono. Gemacht für Bildschirme voller Tabellen und Zahlen."
+      },
+      "legible": {
+        "name": "Gut lesbar",
+        "description": "Atkinson Hyperlegible. 0 und O, 1 und l sehen nie gleich aus; auch quer durch die Werkstatt gut lesbar."
+      }
+    }
   },
   "data": {
     "title": "Datenverwaltung",

+ 8 - 2
messages/de/workBoard.json

@@ -78,7 +78,10 @@
     "addTechnician": "Techniker hinzufügen",
     "standaloneOnly": "Ein Name auf dem Board für jemanden ohne Anmeldung, etwa einen Azubi oder Subunternehmer. Einen Mechaniker mit App fügen Sie auf der Team-Seite hinzu.",
     "linkedTo": "Meldet sich als {name} an",
-    "linkedManagedOnTeam": "Wird auf der Team-Seite verwaltet, zusammen mit dem App-Zugang."
+    "linkedManagedOnTeam": "Wird auf der Team-Seite verwaltet, zusammen mit dem App-Zugang.",
+    "skills": "Fähigkeiten",
+    "skillsPlaceholder": "Diagnose, HV-qualifiziert",
+    "skillsHint": "Wird unter dem Namen angezeigt, wo Arbeit verteilt wird. Optional."
   },
   "jobDetail": {
     "viewDetails": "Details anzeigen",
@@ -154,6 +157,9 @@
     "workBay": "Arbeitsplatz",
     "status": "Status",
     "noTime": "Noch keine Zeit",
-    "none": "Noch niemand"
+    "none": "Noch niemand",
+    "promised": "Zugesagt",
+    "promiseLate": "Überfällig",
+    "promiseOverdue": "Dem Kunden für einen bereits vergangenen Zeitpunkt zugesagt"
   }
 }

+ 7 - 1
messages/de/workOrders.json

@@ -31,7 +31,9 @@
       "status": "Status",
       "tech": "Techniker",
       "date": "Datum",
-      "total": "Gesamt"
+      "total": "Gesamt",
+      "promised": "Zugesagt",
+      "promisedOverdue": "Zugesagt {date}, überfällig"
     },
     "emailSubject": "Fahrzeugstatusupdate: {status}",
     "changeStatus": "Status ändern",
@@ -39,6 +41,10 @@
       "open": "Arbeitsauftrag öffnen",
       "openVehicle": "Fahrzeug öffnen",
       "openCustomer": "Kunde öffnen"
+    },
+    "dueToday": "Heute fällig",
+    "columns": {
+      "label": "Spalten"
     }
   },
   "vehiclePicker": {

+ 21 - 2
messages/en/integrations.json

@@ -60,7 +60,8 @@
       "safety": "Recalls and owner complaints"
     },
     "ai": {
-      "chat": "Writing and summaries"
+      "chat": "Writing and summaries",
+      "transcribe": "Dictation"
     },
     "payments": {
       "checkout": "Pay from the invoice link"
@@ -156,7 +157,8 @@
       "syncNowHelp": "Sync now starts a pass straight away instead of waiting for the hourly one. It looks up 25 vehicles at a time and continues on its own until every due vehicle is done."
     },
     "supersedes": "Connecting {next} disconnects {current}. Only one at a time can be in charge, so {current} stops being used the moment {next} goes live.",
-    "batchRetried": "{count, plural, =0 {Nothing to retry} one {# job queued again} other {# jobs queued again}}"
+    "batchRetried": "{count, plural, =0 {Nothing to retry} one {# job queued again} other {# jobs queued again}}",
+    "advanced": "Advanced"
   },
   "activity": {
     "title": "Activity",
@@ -578,6 +580,23 @@
         "refreshFleet": "Keep recall and complaint data fresh for every vehicle in this workshop",
         "refreshFleetHelp": "Once a day, models not refreshed in the last week are fetched again, so the vehicle page never waits and a new recall shows within a week of being filed. Only make, model and year are sent."
       }
+    },
+    "speech-to-text": {
+      "description": "Dictation on a work order, written down by a speech model instead of the browser: far more accurate, any language, and it knows a workshop's words. Separate from your AI provider, so you can keep that one as it is. Uses your own OpenAI key, or a server of your own that speaks OpenAI's transcription API. The recording is sent to be written down and is not kept.",
+      "fields": {
+        "apiKey": "API key",
+        "apiKeyHelp": "An OpenAI key from platform.openai.com under API keys; OpenAI bills the usage, by the minute. For your own server, only if it asks for one.",
+        "baseUrl": "Server address",
+        "baseUrlHelp": "Leave empty to use OpenAI. For a server of your own (a local Whisper, LocalAI, Groq), where its OpenAI-style API starts, including the path, such as /v1. Torqvoice appends /audio/transcriptions to it."
+      },
+      "settings": {
+        "model": "Model",
+        "modelHelp": "The speech model to ask for. gpt-4o-mini-transcribe is accurate and cheap; gpt-4o-transcribe is the larger one. A server of your own usually answers to whisper-1.",
+        "mode": "Who chooses how dictation is written down",
+        "modeHelp": "The browser writes as you speak but is weaker, above all in smaller languages. The speech model writes when you stop.",
+        "modeAi": "Always the speech model",
+        "modeChoice": "Each person chooses: speech model or browser"
+      }
     }
   },
   "jobKinds": {

+ 202 - 5
messages/en/service.json

@@ -21,7 +21,8 @@
       "title": "The job needs a title before it can be saved.",
       "negative": "Quantities, hours, prices and costs cannot be negative.",
       "partName": "Every priced part needs a name.",
-      "laborDescription": "Every labour line with hours or a rate needs a description."
+      "laborDescription": "Every labour line with hours or a rate needs a description.",
+      "concernCondition": "A concern with a cause, correction or confirmation needs its condition filled in."
     }
   },
   "header": {
@@ -164,6 +165,11 @@
     "deleteRow": "Delete row",
     "descriptionMissingHint": "Rows without a description are not saved."
   },
+  "liveLabor": {
+    "title": "{count, plural, one {A technician added labor to this job} other {A technician added # labor lines to this job}}",
+    "body": "It stays out of the list while you are editing. Your next save includes it either way.",
+    "show": "Show it"
+  },
   "totals": {
     "title": "Totals",
     "parts": "Parts",
@@ -198,7 +204,9 @@
     "aiOverwriteReplace": "Replace",
     "aiOverwriteAppend": "Append",
     "aiOverwriteCancel": "Cancel",
-    "aiDontShowAgain": "Don't show this again"
+    "aiDontShowAgain": "Don't show this again",
+    "internalTitle": "Internal notes",
+    "publicTitle": "Customer notes"
   },
   "invoice": {
     "title": "Invoice Summary",
@@ -248,7 +256,10 @@
     "failedDelete": "Failed to delete payment",
     "deleteTitle": "Delete Payment",
     "deleteDescription": "This will remove this payment record.",
-    "failedPdf": "Failed to generate PDF invoice"
+    "failedPdf": "Failed to generate PDF invoice",
+    "received": "Payments received",
+    "noneYet": "Nothing paid yet.",
+    "balanceDue": "Balance due"
   },
   "attachments": {
     "deleteTitle": "Delete Attachment",
@@ -357,6 +368,7 @@
     "date": "Date",
     "startTime": "Start Time",
     "endTime": "End Time",
+    "durationPresets": "Duration presets",
     "technician": "Technician",
     "selectTechnician": "Select technician...",
     "searchOrCreate": "Search or create technician...",
@@ -394,7 +406,17 @@
     "pickNextAvailable": "Pick next available slot",
     "findingSlot": "Finding a slot…",
     "slotPicked": "Booked into the next free slot",
-    "noSlotFound": "No free slot found in the next few weeks"
+    "noSlotFound": "No free slot found in the next few weeks",
+    "assignedTitle": "Assigned technician",
+    "boardLink": "Work board",
+    "someoneElse": "Someone else",
+    "loadOf": "{booked} / {capacity} h",
+    "loadFree": "{hours} h free",
+    "loadFull": "Fully booked",
+    "loadOn": "Booked hours on {date}",
+    "promisedAt": "Promised to customer",
+    "promisedPlaceholder": "Not promised yet",
+    "promisedClear": "Clear"
   },
   "notifications": {
     "title": "Customer Notifications",
@@ -447,7 +469,60 @@
     "placeholder": "There is a noise from the back when I brake",
     "remove": "Remove this concern",
     "notAnswered": "Nothing found against this yet",
-    "answered": "{count} finding(s) against this"
+    "answered": "{count, plural, one {# observation against this} other {# observations against this}}",
+    "storyTitle": "Job description",
+    "storyHint": "Condition, cause, correction, confirm. Click a step to open it; one row for each thing the customer mentioned or you observed.",
+    "steps": {
+      "condition": "Condition",
+      "cause": "Cause",
+      "correction": "Correction",
+      "confirm": "Confirm"
+    },
+    "conditionPlaceholder": "What the customer reported, or what you observed",
+    "causePlaceholder": "What testing showed the root cause to be",
+    "correctionPlaceholder": "What was done to fix it",
+    "confirmPlaceholder": "How the fix was checked: test drive, re-scan, symptom gone",
+    "markConfirmed": "Confirmed",
+    "confirmedBy": "Confirmed by {name}, {date}",
+    "confirmedAt": "Confirmed {date}",
+    "confirmedOnSave": "Stamped with your name when you save",
+    "confirmedCount": "{confirmed} of {total} confirmed",
+    "findingsCount": "{count, plural, one {# observation} other {# observations}}",
+    "addFinding": "Add observation",
+    "unconfirmedTitle": "Complete with unconfirmed concerns?",
+    "unconfirmedBody": "{count, plural, one {One concern has not been confirmed as fixed:} other {# concerns have not been confirmed as fixed:}}",
+    "unconfirmedProceed": "Complete anyway",
+    "addMedia": "Add media",
+    "mediaUploading": "Uploading…",
+    "mediaAdded": "{count, plural, one {# file added} other {# files added}}",
+    "mediaFailed": "Could not upload {name}",
+    "mediaTypes": "Choose a photo (JPG, PNG, WebP) or a video.",
+    "dictate": {
+      "start": "Dictate",
+      "listening": "Listening",
+      "startHint": "Speak and it is written here. Nothing is recorded.",
+      "stopHint": "Stop listening",
+      "notAllowed": "The browser was not allowed to use the microphone. Allow it for this site and try again.",
+      "noMicrophone": "No microphone was found.",
+      "failed": "Dictation stopped. Try again.",
+      "startHintServer": "Speak and it is written as you talk. The audio goes to your speech model to be written down and is not kept.",
+      "transcribing": "Finishing…",
+      "language": "Language spoken",
+      "auto": "Auto",
+      "nothingHeard": "Nothing was heard.",
+      "fellBack": "Using the browser's own dictation for now.",
+      "autoLong": "Auto (detect the language)",
+      "engine": "Written down by",
+      "engineAi": "Speech model (more accurate)",
+      "engineBrowser": "Browser (writes as you speak)",
+      "liveFellBack": "Writing in steps of a few seconds instead of word by word."
+    },
+    "stepEmpty": "Not written yet",
+    "removeTitle": "Remove this concern?",
+    "removeBody": "Its condition, cause, correction and confirmation are removed with it when you save the work order.",
+    "removeAttached": "Its observations and media stay on the job, no longer filed under a concern.",
+    "removeConfirm": "Remove concern",
+    "saveFirst": "Save the work order first"
   },
   "videoCall": {
     "title": "Video call",
@@ -493,5 +568,127 @@
       "joinLine": "Join the video call here: {url}",
       "emailSubject": "Video call about your {vehicle}"
     }
+  },
+  "modern": {
+    "statusLabel": "Work order status",
+    "back": "Back",
+    "backToClassic": "Back to the classic layout",
+    "openCustomer": "Open customer",
+    "serviceHistory": "Service history",
+    "changeVehicle": "Change vehicle",
+    "vehicleChangesOnSave": "The work order moves to this vehicle when you save.",
+    "phone": "Phone",
+    "email": "Email",
+    "notSet": "Not set",
+    "noCustomer": "No customer on this work order.",
+    "invoiceTitle": "Invoice & payment",
+    "filesTitle": "Files & media",
+    "activity": {
+      "title": "Activity",
+      "messageSent": "Message sent to the customer",
+      "linkShared": "Invoice link shared",
+      "linkViewed": "{count, plural, one {Customer opened the link} other {Customer opened the link # times}}",
+      "paymentTaken": "Payment of {amount} recorded"
+    },
+    "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}",
+    "openedBy": "Opened {date} by {name}",
+    "editTitle": "Edit title",
+    "titleField": "Title",
+    "media": {
+      "tabs": {
+        "photos": "Photos",
+        "diagnostics": "Diagnostics"
+      },
+      "summary": "{count, plural, one {# file} other {# files}} · shared with the customer only when marked",
+      "customerCanSee": "Customer can see",
+      "showToCustomer": "Show to the customer",
+      "hideFromCustomer": "Hide from the customer",
+      "add": {
+        "image": "Add photos",
+        "document": "Add documents",
+        "diagnostic": "Add diagnostic report",
+        "video": "Add video"
+      },
+      "dropHint": "Drop files or click to browse",
+      "added": "{count, plural, one {# file added} other {# files added}}",
+      "failed": "Could not upload {name}",
+      "wrongType": "Not the right kind of file for this tab: {names}",
+      "limit": "Limit reached ({count}/{max})"
+    },
+    "moneyBar": {
+      "total": "Total",
+      "paid": "Paid",
+      "balance": "Balance due",
+      "settled": "Paid in full",
+      "takePayment": "Take payment",
+      "preview": "Preview invoice",
+      "previewShort": "Preview",
+      "send": "Send to customer",
+      "sendShort": "Send"
+    },
+    "vehicleInfo": {
+      "more": "More info",
+      "less": "Less info",
+      "copy": "Copy {label}",
+      "copied": "Copied",
+      "lastInspection": "Last periodic inspection",
+      "purchaseDate": "Purchase date",
+      "purchasePrice": "Purchase price",
+      "overdue": "overdue",
+      "customerSince": "Customer since"
+    }
+  },
+  "photoHandoff": {
+    "button": "Add from phone",
+    "dialogTitle": "Add photos and documents from a phone",
+    "dialogBody": "Take or choose photos, or share a PDF, on any phone. Everything lands straight on this work order.",
+    "expires": "The code works until {time}.",
+    "waiting": "Waiting for photos and documents…",
+    "received": "{count, plural, one {# file received} other {# files received}}",
+    "copyLink": "Copy link",
+    "linkCopied": "Link copied",
+    "failed": "Could not make a code. Try again.",
+    "done": "Done",
+    "pageTitle": "Add photos and documents",
+    "forJob": "Work order {number}",
+    "takePhoto": "Take photo",
+    "choosePhotos": "Choose photos",
+    "hint": "Everything goes straight onto the work order. Take as many photos as you need.",
+    "uploading": "Uploading…",
+    "added": "Added",
+    "notAdded": "Not added",
+    "retry": "Try again",
+    "addedCount": "{count, plural, one {# file added} other {# files added}}",
+    "expiredTitle": "This code has expired",
+    "invalidTitle": "This link does not work",
+    "errors": {
+      "expired": "Ask for a new code at the desk. Each one works for 30 minutes.",
+      "invalid": "Scan the code on the work order again.",
+      "limit": "This work order already has as many photos as it can take.",
+      "type": "Only photos and PDF documents can be added here.",
+      "tooLarge": "That file is too large.",
+      "failed": "The photo was not added. Check the connection and try again.",
+      "codeLimit": "This code has added as many photos as one code can. Scan a new code at the desk."
+    },
+    "concernHeading": "Photos go under this concern",
+    "howTitle": "How it works",
+    "step1": "Point the phone camera at the code and open the link.",
+    "step2": "Take photos, or choose photos or PDF documents already on the phone.",
+    "step3": "They appear on this work order as they arrive.",
+    "noSignIn": "No sign-in needed on the phone.",
+    "customerToo": "Staff can use it, and so can the customer: let them scan it, or send them the link.",
+    "scope": "The link only adds photos and documents to this work order, and stops working after 30 minutes.",
+    "scanHint": "Scan with the phone camera",
+    "chooseDocument": "Upload a document",
+    "privacy": "Only the workshop can see what you add here."
   }
 }

+ 23 - 1
messages/en/settings.json

@@ -1229,7 +1229,29 @@
     "dateLabel": "Date",
     "dateTimeLabel": "Date & Time",
     "saveSettings": "Save Settings",
-    "saved": "Settings saved"
+    "saved": "Settings saved",
+    "fontLabel": "Font",
+    "fontHint": "Choose the typefaces the app is set in. The choice is saved on this device.",
+    "fontSampleHeading": "Work order",
+    "fontSampleText": "Replace ignition coil and spark plugs",
+    "fonts": {
+      "default": {
+        "name": "Default",
+        "description": "Geist, clean and neutral."
+      },
+      "workshop": {
+        "name": "Workshop",
+        "description": "IBM Plex with condensed capital headings, like a job card."
+      },
+      "precise": {
+        "name": "Precise",
+        "description": "Inter and JetBrains Mono. Built for screens full of tables and figures."
+      },
+      "legible": {
+        "name": "Legible",
+        "description": "Atkinson Hyperlegible. 0 and O, 1 and l never look alike; easy to read from across the workshop."
+      }
+    }
   },
   "data": {
     "title": "Data Management",

+ 8 - 2
messages/en/workBoard.json

@@ -78,7 +78,10 @@
     "addTechnician": "Add Technician",
     "standaloneOnly": "A name on the board for somebody who does not sign in, like an apprentice or a contractor. To add a mechanic who uses the app, add them on the Team page.",
     "linkedTo": "Signs in as {name}",
-    "linkedManagedOnTeam": "Managed on the Team page, along with their app access."
+    "linkedManagedOnTeam": "Managed on the Team page, along with their app access.",
+    "skills": "Skills",
+    "skillsPlaceholder": "Diagnostics, EV certified",
+    "skillsHint": "Shown under the name where work is handed out. Optional."
   },
   "jobDetail": {
     "viewDetails": "View Details",
@@ -154,6 +157,9 @@
     "workBay": "Work bay",
     "status": "Status",
     "noTime": "No time set yet",
-    "none": "Nobody yet"
+    "none": "Nobody yet",
+    "promised": "Promised",
+    "promiseLate": "Late",
+    "promiseOverdue": "Promised to the customer before now"
   }
 }

+ 7 - 1
messages/en/workOrders.json

@@ -31,7 +31,9 @@
       "status": "Status",
       "tech": "Tech",
       "date": "Date",
-      "total": "Total"
+      "total": "Total",
+      "promised": "Promised",
+      "promisedOverdue": "Promised {date}, overdue"
     },
     "emailSubject": "Vehicle Status Update: {status}",
     "changeStatus": "Change status",
@@ -39,6 +41,10 @@
       "open": "Open work order",
       "openVehicle": "Open vehicle",
       "openCustomer": "Open customer"
+    },
+    "dueToday": "Due today",
+    "columns": {
+      "label": "Columns"
     }
   },
   "vehiclePicker": {

+ 21 - 2
messages/es/integrations.json

@@ -60,7 +60,8 @@
       "safety": "Llamadas a revisión y quejas de propietarios"
     },
     "ai": {
-      "chat": "Textos y resúmenes"
+      "chat": "Textos y resúmenes",
+      "transcribe": "Dictado"
     },
     "payments": {
       "checkout": "Pagar desde el enlace de la factura"
@@ -156,7 +157,8 @@
       "syncNowHelp": "Sincronizar ahora inicia una pasada de inmediato en vez de esperar a la horaria. Consulta 25 vehículos cada vez y continúa sola hasta terminar con todos los pendientes."
     },
     "supersedes": "Conectar {next} desconecta {current}. Solo uno puede estar activo a la vez, así que {current} deja de usarse en cuanto {next} entra en servicio.",
-    "batchRetried": "{count, plural, =0 {Nada que reintentar} one {# tarea vuelta a la cola} other {# tareas vueltas a la cola}}"
+    "batchRetried": "{count, plural, =0 {Nada que reintentar} one {# tarea vuelta a la cola} other {# tareas vueltas a la cola}}",
+    "advanced": "Avanzado"
   },
   "activity": {
     "title": "Actividad",
@@ -578,6 +580,23 @@
         "refreshFleet": "Mantener al día los datos de llamadas a revisión y quejas de todos los vehículos de este taller",
         "refreshFleetHelp": "Una vez al día se vuelven a consultar los modelos no actualizados en la última semana, para que la página del vehículo nunca espere y una nueva llamada a revisión aparezca en una semana. Solo se envían marca, modelo y año."
       }
+    },
+    "speech-to-text": {
+      "description": "El dictado en una orden de trabajo lo transcribe un modelo de voz en lugar del navegador: mucho más preciso, en cualquier idioma y conoce las palabras de un taller. Es independiente de tu proveedor de IA, así que puedes dejar ese como está. Usa tu propia clave de OpenAI o un servidor propio que hable la API de transcripción de OpenAI. La grabación se envía para transcribirla y no se guarda.",
+      "fields": {
+        "apiKey": "Clave de API",
+        "apiKeyHelp": "Una clave de OpenAI de platform.openai.com, en API keys; OpenAI factura el uso por minuto. Para tu propio servidor, solo si la pide.",
+        "baseUrl": "Dirección del servidor",
+        "baseUrlHelp": "Déjalo vacío para usar OpenAI. Para un servidor propio (un Whisper local, LocalAI, Groq), donde empieza su API al estilo de OpenAI, incluida la ruta, como /v1. Torqvoice le añade /audio/transcriptions."
+      },
+      "settings": {
+        "model": "Modelo",
+        "modelHelp": "El modelo de voz que se solicita. gpt-4o-mini-transcribe es preciso y barato; gpt-4o-transcribe es el más grande. Un servidor propio suele responder a whisper-1.",
+        "mode": "Quién elige cómo se transcribe el dictado",
+        "modeHelp": "El navegador escribe mientras hablas, pero es más flojo, sobre todo en idiomas pequeños. El modelo de voz escribe cuando paras.",
+        "modeAi": "Siempre el modelo de voz",
+        "modeChoice": "Cada persona elige: modelo de voz o navegador"
+      }
     }
   },
   "jobKinds": {

+ 202 - 5
messages/es/service.json

@@ -21,7 +21,8 @@
       "title": "El trabajo necesita un título antes de poder guardarse.",
       "negative": "Las cantidades, horas, precios y costes no pueden ser negativos.",
       "partName": "Toda pieza con precio necesita un nombre.",
-      "laborDescription": "Toda línea de mano de obra con horas o tarifa necesita una descripción."
+      "laborDescription": "Toda línea de mano de obra con horas o tarifa necesita una descripción.",
+      "concernCondition": "Un motivo con causa, corrección o confirmación necesita que se rellene su condición."
     }
   },
   "header": {
@@ -164,6 +165,11 @@
     "deleteRow": "Eliminar fila",
     "descriptionMissingHint": "Las filas sin descripción no se guardan."
   },
+  "liveLabor": {
+    "title": "{count, plural, one {Un técnico añadió mano de obra a este trabajo} other {Un técnico añadió # líneas de mano de obra a este trabajo}}",
+    "body": "Se mantiene fuera de la lista mientras editas. Se guardará de todos modos al guardar.",
+    "show": "Mostrar"
+  },
   "totals": {
     "title": "Totales",
     "parts": "Piezas",
@@ -198,7 +204,9 @@
     "aiOverwriteReplace": "Reemplazar",
     "aiOverwriteAppend": "Agregar",
     "aiOverwriteCancel": "Cancelar",
-    "aiDontShowAgain": "No mostrar de nuevo"
+    "aiDontShowAgain": "No mostrar de nuevo",
+    "internalTitle": "Notas internas",
+    "publicTitle": "Notas para el cliente"
   },
   "invoice": {
     "title": "Resumen de Factura",
@@ -248,7 +256,10 @@
     "failedDelete": "Error al eliminar pago",
     "deleteTitle": "Eliminar Pago",
     "deleteDescription": "Esto eliminará este registro de pago.",
-    "failedPdf": "Error al generar factura en PDF"
+    "failedPdf": "Error al generar factura en PDF",
+    "received": "Pagos recibidos",
+    "noneYet": "Todavía no se ha pagado nada.",
+    "balanceDue": "Saldo pendiente"
   },
   "attachments": {
     "deleteTitle": "Eliminar Archivo",
@@ -357,6 +368,7 @@
     "date": "Fecha",
     "startTime": "Hora de inicio",
     "endTime": "Hora de fin",
+    "durationPresets": "Duración",
     "technician": "Técnico",
     "selectTechnician": "Seleccionar técnico...",
     "searchOrCreate": "Buscar o crear técnico...",
@@ -394,7 +406,17 @@
     "pickNextAvailable": "Elegir la siguiente franja libre",
     "findingSlot": "Buscando una franja…",
     "slotPicked": "Reservado en la siguiente franja libre",
-    "noSlotFound": "No se encontró ninguna franja libre en las próximas semanas"
+    "noSlotFound": "No se encontró ninguna franja libre en las próximas semanas",
+    "assignedTitle": "Técnico asignado",
+    "boardLink": "Tablero de trabajo",
+    "someoneElse": "Otra persona",
+    "loadOf": "{booked} / {capacity} h",
+    "loadFree": "{hours} h libres",
+    "loadFull": "Completo",
+    "loadOn": "Horas reservadas el {date}",
+    "promisedAt": "Prometido al cliente",
+    "promisedPlaceholder": "Sin prometer todavía",
+    "promisedClear": "Borrar"
   },
   "notifications": {
     "title": "Notificaciones al cliente",
@@ -447,7 +469,60 @@
     "placeholder": "Hace un ruido detrás cuando freno",
     "remove": "Quitar este punto",
     "notAnswered": "Todavía no se ha encontrado nada",
-    "answered": "{count} hallazgo(s) sobre esto"
+    "answered": "{count} hallazgo(s) sobre esto",
+    "storyTitle": "Descripción del trabajo",
+    "storyHint": "Condición, causa, corrección, confirmación. Haz clic en un paso para abrirlo; una fila por cada cosa que mencionó el cliente o que observaste.",
+    "steps": {
+      "condition": "Condición",
+      "cause": "Causa",
+      "correction": "Corrección",
+      "confirm": "Confirmación"
+    },
+    "conditionPlaceholder": "Lo que informó el cliente o lo que observaste",
+    "causePlaceholder": "Qué causa raíz mostraron las pruebas",
+    "correctionPlaceholder": "Qué se hizo para repararlo",
+    "confirmPlaceholder": "Cómo se comprobó la reparación: prueba de conducción, nuevo escaneo, síntoma desaparecido",
+    "markConfirmed": "Confirmado",
+    "confirmedBy": "Confirmado por {name}, {date}",
+    "confirmedAt": "Confirmado el {date}",
+    "confirmedOnSave": "Se firma con tu nombre al guardar",
+    "confirmedCount": "{confirmed} de {total} confirmados",
+    "findingsCount": "{count, plural, one {# observación} other {# observaciones}}",
+    "addFinding": "Añadir observación",
+    "unconfirmedTitle": "¿Completar con motivos sin confirmar?",
+    "unconfirmedBody": "{count, plural, one {Un motivo no se ha confirmado como reparado:} other {# motivos no se han confirmado como reparados:}}",
+    "unconfirmedProceed": "Completar igualmente",
+    "addMedia": "Añadir fotos o vídeo",
+    "mediaUploading": "Subiendo…",
+    "mediaAdded": "{count, plural, one {# archivo añadido} other {# archivos añadidos}}",
+    "mediaFailed": "No se pudo subir {name}",
+    "mediaTypes": "Elige una foto (JPG, PNG, WebP) o un vídeo.",
+    "dictate": {
+      "start": "Dictar",
+      "listening": "Escuchando",
+      "startHint": "Habla y se escribe aquí. No se graba nada.",
+      "stopHint": "Dejar de escuchar",
+      "notAllowed": "El navegador no tiene permiso para usar el micrófono. Permítelo para este sitio e inténtalo de nuevo.",
+      "noMicrophone": "No se encontró ningún micrófono.",
+      "failed": "El dictado se detuvo. Inténtalo de nuevo.",
+      "startHintServer": "Habla y se escribe mientras hablas. El audio va a tu modelo de voz para transcribirlo y no se guarda.",
+      "transcribing": "Terminando…",
+      "language": "Idioma hablado",
+      "auto": "Auto",
+      "nothingHeard": "No se oyó nada.",
+      "fellBack": "Usando por ahora el dictado del propio navegador.",
+      "autoLong": "Auto (detectar el idioma)",
+      "engine": "Transcrito por",
+      "engineAi": "Modelo de voz (más preciso)",
+      "engineBrowser": "Navegador (escribe mientras hablas)",
+      "liveFellBack": "Escribiendo en tramos de unos segundos en lugar de palabra por palabra."
+    },
+    "stepEmpty": "Sin escribir todavía",
+    "removeTitle": "¿Quitar este motivo?",
+    "removeBody": "Su condición, causa, corrección y confirmación se eliminan con él al guardar la orden de trabajo.",
+    "removeAttached": "Sus observaciones y archivos siguen en el trabajo, ya no archivados bajo un motivo.",
+    "removeConfirm": "Quitar motivo",
+    "saveFirst": "Guarda primero la orden de trabajo"
   },
   "videoCall": {
     "title": "Videollamada",
@@ -493,5 +568,127 @@
       "joinLine": "Únete a la videollamada aquí: {url}",
       "emailSubject": "Videollamada sobre tu {vehicle}"
     }
+  },
+  "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",
+    "vehicleChangesOnSave": "La orden de trabajo pasa a este vehículo al guardar.",
+    "phone": "Teléfono",
+    "email": "Correo electrónico",
+    "notSet": "Sin definir",
+    "noCustomer": "No hay cliente en esta orden de trabajo.",
+    "invoiceTitle": "Factura y pago",
+    "filesTitle": "Archivos y multimedia",
+    "activity": {
+      "title": "Actividad",
+      "messageSent": "Mensaje enviado al cliente",
+      "linkShared": "Enlace de la factura compartido",
+      "linkViewed": "{count, plural, one {El cliente abrió el enlace} other {El cliente abrió el enlace # veces}}",
+      "paymentTaken": "Pago de {amount} registrado"
+    },
+    "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}",
+    "openedBy": "Abierta el {date} por {name}",
+    "editTitle": "Editar título",
+    "titleField": "Título",
+    "media": {
+      "tabs": {
+        "photos": "Fotos",
+        "diagnostics": "Diagnóstico"
+      },
+      "summary": "{count, plural, one {# archivo} other {# archivos}} · se comparten con el cliente solo si se marcan",
+      "customerCanSee": "Visible para el cliente",
+      "showToCustomer": "Mostrar al cliente",
+      "hideFromCustomer": "Ocultar al cliente",
+      "add": {
+        "image": "Añadir fotos",
+        "document": "Añadir documentos",
+        "diagnostic": "Añadir informe de diagnóstico",
+        "video": "Añadir vídeo"
+      },
+      "dropHint": "Suelta archivos aquí o haz clic para explorar",
+      "added": "{count, plural, one {# archivo añadido} other {# archivos añadidos}}",
+      "failed": "No se pudo subir {name}",
+      "wrongType": "No es el tipo de archivo adecuado para esta pestaña: {names}",
+      "limit": "Límite alcanzado ({count}/{max})"
+    },
+    "moneyBar": {
+      "total": "Total",
+      "paid": "Pagado",
+      "balance": "Saldo pendiente",
+      "settled": "Pagado por completo",
+      "takePayment": "Cobrar",
+      "preview": "Vista previa de la factura",
+      "previewShort": "Vista previa",
+      "send": "Enviar al cliente",
+      "sendShort": "Enviar"
+    },
+    "vehicleInfo": {
+      "more": "Más información",
+      "less": "Menos información",
+      "copy": "Copiar {label}",
+      "copied": "Copiado",
+      "lastInspection": "Última inspección periódica",
+      "purchaseDate": "Fecha de compra",
+      "purchasePrice": "Precio de compra",
+      "overdue": "vencida",
+      "customerSince": "Cliente desde"
+    }
+  },
+  "photoHandoff": {
+    "button": "Añadir desde el móvil",
+    "dialogTitle": "Añadir fotos y documentos desde un móvil",
+    "dialogBody": "Haz o elige fotos, o comparte un PDF, con cualquier móvil. Todo llega directamente a esta orden de trabajo.",
+    "expires": "El código funciona hasta las {time}.",
+    "waiting": "Esperando fotos y documentos…",
+    "received": "{count, plural, one {# archivo recibido} other {# archivos recibidos}}",
+    "copyLink": "Copiar enlace",
+    "linkCopied": "Enlace copiado",
+    "failed": "No se pudo crear un código. Inténtalo de nuevo.",
+    "done": "Hecho",
+    "pageTitle": "Añadir fotos y documentos",
+    "forJob": "Orden de trabajo {number}",
+    "takePhoto": "Hacer foto",
+    "choosePhotos": "Elegir fotos",
+    "hint": "Todo llega directamente a la orden de trabajo. Haz todas las fotos que necesites.",
+    "uploading": "Subiendo…",
+    "added": "Añadida",
+    "notAdded": "No añadida",
+    "retry": "Inténtalo de nuevo",
+    "addedCount": "{count, plural, one {# archivo añadido} other {# archivos añadidos}}",
+    "expiredTitle": "Este código ha caducado",
+    "invalidTitle": "Este enlace no funciona",
+    "errors": {
+      "expired": "Pide un código nuevo en el mostrador. Cada uno funciona durante 30 minutos.",
+      "invalid": "Vuelve a escanear el código de la orden de trabajo.",
+      "limit": "Esta orden de trabajo ya tiene todas las fotos que admite.",
+      "type": "Aquí solo se pueden añadir fotos y documentos PDF.",
+      "tooLarge": "Ese archivo es demasiado grande.",
+      "failed": "La foto no se añadió. Comprueba la conexión e inténtalo de nuevo.",
+      "codeLimit": "Este código ya ha añadido todas las fotos que permite un código. Escanea un código nuevo en el mostrador."
+    },
+    "concernHeading": "Las fotos van a este motivo",
+    "howTitle": "Cómo funciona",
+    "step1": "Apunta la cámara del móvil al código y abre el enlace.",
+    "step2": "Haz fotos o elige fotos o documentos PDF que ya estén en el móvil.",
+    "step3": "Aparecen en esta orden de trabajo en cuanto llegan.",
+    "noSignIn": "No hace falta iniciar sesión en el móvil.",
+    "customerToo": "Lo puede usar el personal, y también el cliente: deja que escanee el código o envíale el enlace.",
+    "scope": "El enlace solo añade fotos y documentos a esta orden de trabajo y deja de funcionar a los 30 minutos.",
+    "scanHint": "Escanea con la cámara del móvil",
+    "chooseDocument": "Subir un documento",
+    "privacy": "Solo el taller ve lo que añades aquí."
   }
 }

+ 23 - 1
messages/es/settings.json

@@ -1229,7 +1229,29 @@
     "dateLabel": "Fecha",
     "dateTimeLabel": "Fecha y hora",
     "saveSettings": "Guardar configuración",
-    "saved": "Configuración guardada"
+    "saved": "Configuración guardada",
+    "fontLabel": "Tipo de letra",
+    "fontHint": "Elige las tipografías de la aplicación. La elección se guarda en este dispositivo.",
+    "fontSampleHeading": "Orden de trabajo",
+    "fontSampleText": "Cambiar bobina de encendido y bujías",
+    "fonts": {
+      "default": {
+        "name": "Predeterminada",
+        "description": "Geist, limpia y neutra."
+      },
+      "workshop": {
+        "name": "Taller",
+        "description": "IBM Plex con títulos en mayúsculas estrechas, como una hoja de trabajo."
+      },
+      "precise": {
+        "name": "Precisa",
+        "description": "Inter y JetBrains Mono. Pensada para pantallas llenas de tablas y cifras."
+      },
+      "legible": {
+        "name": "Legible",
+        "description": "Atkinson Hyperlegible. 0 y O, 1 y l nunca se parecen; fácil de leer desde el otro lado del taller."
+      }
+    }
   },
   "data": {
     "title": "Gestión de datos",

+ 8 - 2
messages/es/workBoard.json

@@ -78,7 +78,10 @@
     "addTechnician": "Agregar Técnico",
     "standaloneOnly": "Un nombre en el tablero para alguien que no inicia sesión, como un aprendiz o un externo. Para un mecánico que usa la aplicación, añádalo en la página de Equipo.",
     "linkedTo": "Entra como {name}",
-    "linkedManagedOnTeam": "Se gestiona en la página de Equipo, junto con su acceso a la aplicación."
+    "linkedManagedOnTeam": "Se gestiona en la página de Equipo, junto con su acceso a la aplicación.",
+    "skills": "Habilidades",
+    "skillsPlaceholder": "Diagnóstico, certificado en VE",
+    "skillsHint": "Se muestra bajo el nombre donde se reparte el trabajo. Opcional."
   },
   "jobDetail": {
     "viewDetails": "Ver Detalles",
@@ -154,6 +157,9 @@
     "workBay": "Puesto de trabajo",
     "status": "Estado",
     "noTime": "Sin hora todavía",
-    "none": "Nadie todavía"
+    "none": "Nadie todavía",
+    "promised": "Prometido",
+    "promiseLate": "Retrasado",
+    "promiseOverdue": "Prometido al cliente para un momento ya pasado"
   }
 }

+ 7 - 1
messages/es/workOrders.json

@@ -31,7 +31,9 @@
       "status": "Estado",
       "tech": "Técnico",
       "date": "Fecha",
-      "total": "Total"
+      "total": "Total",
+      "promised": "Prometido",
+      "promisedOverdue": "Prometido {date}, retrasado"
     },
     "emailSubject": "Actualización de Estado del Vehículo: {status}",
     "changeStatus": "Cambiar estado",
@@ -39,6 +41,10 @@
       "open": "Abrir orden de trabajo",
       "openVehicle": "Abrir vehículo",
       "openCustomer": "Abrir cliente"
+    },
+    "dueToday": "Para hoy",
+    "columns": {
+      "label": "Columnas"
     }
   },
   "vehiclePicker": {

+ 21 - 2
messages/fr/integrations.json

@@ -60,7 +60,8 @@
       "safety": "Rappels et plaintes de propriétaires"
     },
     "ai": {
-      "chat": "Textes et résumés"
+      "chat": "Textes et résumés",
+      "transcribe": "Dictée"
     },
     "payments": {
       "checkout": "Payer depuis le lien de la facture"
@@ -156,7 +157,8 @@
       "syncNowHelp": "Synchroniser maintenant lance un passage immédiatement au lieu d'attendre celui de l'heure. Il recherche 25 véhicules à la fois et continue seul jusqu'à ce que tous les véhicules à vérifier soient traités."
     },
     "supersedes": "Connecter {next} déconnecte {current}. Un seul peut être actif à la fois : {current} cesse d'être utilisé dès que {next} est en service.",
-    "batchRetried": "{count, plural, =0 {Rien à réessayer} one {# tâche remise en file} other {# tâches remises en file}}"
+    "batchRetried": "{count, plural, =0 {Rien à réessayer} one {# tâche remise en file} other {# tâches remises en file}}",
+    "advanced": "Avancé"
   },
   "activity": {
     "title": "Activité",
@@ -578,6 +580,23 @@
         "refreshFleet": "Tenir à jour les données de rappels et de plaintes pour chaque véhicule de cet atelier",
         "refreshFleetHelp": "Une fois par jour, les modèles non actualisés depuis une semaine sont récupérés à nouveau, pour que la page du véhicule n'attende jamais et qu'un nouveau rappel apparaisse dans la semaine. Seuls la marque, le modèle et l'année sont envoyés."
       }
+    },
+    "speech-to-text": {
+      "description": "La dictée sur un ordre de travail, transcrite par un modèle vocal plutôt que par le navigateur : bien plus précise, dans toutes les langues, et elle connaît le vocabulaire de l'atelier. Indépendant de votre fournisseur d'IA, que vous pouvez garder tel quel. Utilise votre propre clé OpenAI, ou votre propre serveur compatible avec l'API de transcription d'OpenAI. L'enregistrement est envoyé pour être transcrit et n'est pas conservé.",
+      "fields": {
+        "apiKey": "Clé API",
+        "apiKeyHelp": "Une clé OpenAI, sur platform.openai.com sous API keys ; OpenAI facture l'usage à la minute. Pour votre propre serveur, seulement s'il en demande une.",
+        "baseUrl": "Adresse du serveur",
+        "baseUrlHelp": "Laissez vide pour utiliser OpenAI. Pour votre propre serveur (un Whisper local, LocalAI, Groq), l'adresse où commence son API de type OpenAI, chemin compris, par exemple /v1. Torqvoice y ajoute /audio/transcriptions."
+      },
+      "settings": {
+        "model": "Modèle",
+        "modelHelp": "Le modèle vocal à utiliser. gpt-4o-mini-transcribe est précis et bon marché ; gpt-4o-transcribe est le plus grand. Votre propre serveur répond en général à whisper-1.",
+        "mode": "Qui choisit comment la dictée est transcrite",
+        "modeHelp": "Le navigateur écrit pendant que vous parlez mais il est moins bon, surtout dans les langues moins répandues. Le modèle vocal écrit quand vous vous arrêtez.",
+        "modeAi": "Toujours le modèle vocal",
+        "modeChoice": "Chacun choisit : modèle vocal ou navigateur"
+      }
     }
   },
   "jobKinds": {

+ 202 - 5
messages/fr/service.json

@@ -21,7 +21,8 @@
       "title": "Le travail doit avoir un titre avant d'être enregistré.",
       "negative": "Les quantités, heures, prix et coûts ne peuvent pas être négatifs.",
       "partName": "Chaque pièce avec un prix doit avoir un nom.",
-      "laborDescription": "Chaque ligne de main-d'œuvre avec des heures ou un taux doit avoir une description."
+      "laborDescription": "Chaque ligne de main-d'œuvre avec des heures ou un taux doit avoir une description.",
+      "concernCondition": "Un point avec une cause, une correction ou un contrôle doit avoir son symptôme renseigné."
     }
   },
   "header": {
@@ -164,6 +165,11 @@
     "deleteRow": "Supprimer la ligne",
     "descriptionMissingHint": "Les lignes sans description ne sont pas enregistrées."
   },
+  "liveLabor": {
+    "title": "{count, plural, one {Un technicien a ajouté de la main-d’œuvre à cette intervention} other {Un technicien a ajouté # lignes de main-d’œuvre à cette intervention}}",
+    "body": "Elle reste hors de la liste pendant votre saisie. Votre prochain enregistrement l’inclura de toute façon.",
+    "show": "Afficher"
+  },
   "totals": {
     "title": "Totaux",
     "parts": "Pièces",
@@ -198,7 +204,9 @@
     "aiOverwriteReplace": "Remplacer",
     "aiOverwriteAppend": "Ajouter",
     "aiOverwriteCancel": "Annuler",
-    "aiDontShowAgain": "Ne plus afficher"
+    "aiDontShowAgain": "Ne plus afficher",
+    "internalTitle": "Notes internes",
+    "publicTitle": "Notes client"
   },
   "invoice": {
     "title": "Résumé de la Facture",
@@ -248,7 +256,10 @@
     "failedDelete": "Impossible de supprimer le paiement",
     "deleteTitle": "Supprimer le Paiement",
     "deleteDescription": "Cela supprimera ce dossier de paiement.",
-    "failedPdf": "Impossible de générer la facture PDF"
+    "failedPdf": "Impossible de générer la facture PDF",
+    "received": "Paiements reçus",
+    "noneYet": "Rien de payé pour l'instant.",
+    "balanceDue": "Solde dû"
   },
   "attachments": {
     "deleteTitle": "Supprimer la Pièce Jointe",
@@ -357,6 +368,7 @@
     "date": "Date",
     "startTime": "Heure de début",
     "endTime": "Heure de fin",
+    "durationPresets": "Durée",
     "technician": "Technicien",
     "selectTechnician": "Sélectionner un technicien...",
     "searchOrCreate": "Rechercher ou créer un technicien...",
@@ -394,7 +406,17 @@
     "pickNextAvailable": "Choisir le prochain créneau libre",
     "findingSlot": "Recherche d'un créneau…",
     "slotPicked": "Placé sur le prochain créneau libre",
-    "noSlotFound": "Aucun créneau libre trouvé dans les prochaines semaines"
+    "noSlotFound": "Aucun créneau libre trouvé dans les prochaines semaines",
+    "assignedTitle": "Technicien assigné",
+    "boardLink": "Tableau de travail",
+    "someoneElse": "Quelqu'un d'autre",
+    "loadOf": "{booked} / {capacity} h",
+    "loadFree": "{hours} h libres",
+    "loadFull": "Complet",
+    "loadOn": "Heures réservées le {date}",
+    "promisedAt": "Promis au client",
+    "promisedPlaceholder": "Pas encore promis",
+    "promisedClear": "Effacer"
   },
   "notifications": {
     "title": "Notifications client",
@@ -447,7 +469,60 @@
     "placeholder": "Il y a un bruit à l'arrière quand je freine",
     "remove": "Retirer ce point",
     "notAnswered": "Rien trouvé à ce sujet pour l'instant",
-    "answered": "{count} constat(s) à ce sujet"
+    "answered": "{count} constat(s) à ce sujet",
+    "storyTitle": "Description du travail",
+    "storyHint": "Symptôme, cause, correction, contrôle. Cliquez sur une étape pour l'ouvrir ; une ligne par point signalé par le client ou constaté par vous.",
+    "steps": {
+      "condition": "Symptôme",
+      "cause": "Cause",
+      "correction": "Correction",
+      "confirm": "Contrôle"
+    },
+    "conditionPlaceholder": "Ce que le client a signalé, ou ce que vous avez constaté",
+    "causePlaceholder": "La cause réelle révélée par les tests",
+    "correctionPlaceholder": "Ce qui a été fait pour y remédier",
+    "confirmPlaceholder": "Comment la réparation a été vérifiée : essai routier, nouveau diagnostic, symptôme disparu",
+    "markConfirmed": "Contrôlé",
+    "confirmedBy": "Contrôlé par {name}, {date}",
+    "confirmedAt": "Contrôlé le {date}",
+    "confirmedOnSave": "Signé à votre nom à l'enregistrement",
+    "confirmedCount": "{confirmed} sur {total} contrôlés",
+    "findingsCount": "{count, plural, one {# observation} other {# observations}}",
+    "addFinding": "Ajouter une observation",
+    "unconfirmedTitle": "Terminer avec des points non contrôlés ?",
+    "unconfirmedBody": "{count, plural, one {Un point n'a pas été contrôlé comme réparé :} other {# points n'ont pas été contrôlés comme réparés :}}",
+    "unconfirmedProceed": "Terminer quand même",
+    "addMedia": "Ajouter des médias",
+    "mediaUploading": "Envoi…",
+    "mediaAdded": "{count, plural, one {# fichier ajouté} other {# fichiers ajoutés}}",
+    "mediaFailed": "Impossible d'envoyer {name}",
+    "mediaTypes": "Choisissez une photo (JPG, PNG, WebP) ou une vidéo.",
+    "dictate": {
+      "start": "Dicter",
+      "listening": "À l'écoute",
+      "startHint": "Parlez et le texte s'écrit ici. Rien n'est enregistré.",
+      "stopHint": "Arrêter l'écoute",
+      "notAllowed": "Le navigateur n'a pas été autorisé à utiliser le micro. Autorisez-le pour ce site et réessayez.",
+      "noMicrophone": "Aucun micro trouvé.",
+      "failed": "La dictée s'est arrêtée. Réessayez.",
+      "startHintServer": "Parlez et le texte s'écrit au fil de vos paroles. L'audio est envoyé à votre modèle vocal pour être transcrit et n'est pas conservé.",
+      "transcribing": "Finalisation…",
+      "language": "Langue parlée",
+      "auto": "Auto",
+      "nothingHeard": "Rien n'a été entendu.",
+      "fellBack": "Dictée du navigateur utilisée pour l'instant.",
+      "autoLong": "Auto (détecter la langue)",
+      "engine": "Transcrit par",
+      "engineAi": "Modèle vocal (plus précis)",
+      "engineBrowser": "Navigateur (écrit pendant que vous parlez)",
+      "liveFellBack": "Écriture par tranches de quelques secondes au lieu de mot à mot."
+    },
+    "stepEmpty": "Pas encore rédigé",
+    "removeTitle": "Retirer ce point ?",
+    "removeBody": "Son symptôme, sa cause, sa correction et son contrôle sont retirés avec lui à l'enregistrement de l'ordre de travail.",
+    "removeAttached": "Ses observations et médias restent sur le travail, sans être rattachés à un point.",
+    "removeConfirm": "Retirer le point",
+    "saveFirst": "Enregistrez d'abord l'ordre de travail"
   },
   "videoCall": {
     "title": "Appel vidéo",
@@ -493,5 +568,127 @@
       "joinLine": "Rejoignez l'appel vidéo ici : {url}",
       "emailSubject": "Appel vidéo concernant votre {vehicle}"
     }
+  },
+  "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",
+    "vehicleChangesOnSave": "L'ordre de travail passe sur ce véhicule à l'enregistrement.",
+    "phone": "Téléphone",
+    "email": "E-mail",
+    "notSet": "Non renseigné",
+    "noCustomer": "Aucun client sur cet ordre de travail.",
+    "invoiceTitle": "Facture et paiement",
+    "filesTitle": "Fichiers et médias",
+    "activity": {
+      "title": "Activité",
+      "messageSent": "Message envoyé au client",
+      "linkShared": "Lien de la facture partagé",
+      "linkViewed": "{count, plural, one {Le client a ouvert le lien} other {Le client a ouvert le lien # fois}}",
+      "paymentTaken": "Paiement de {amount} enregistré"
+    },
+    "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}",
+    "openedBy": "Ouvert le {date} par {name}",
+    "editTitle": "Modifier le titre",
+    "titleField": "Titre",
+    "media": {
+      "tabs": {
+        "photos": "Photos",
+        "diagnostics": "Diagnostics"
+      },
+      "summary": "{count, plural, one {# fichier} other {# fichiers}} · montrés au client seulement s'ils sont marqués",
+      "customerCanSee": "Visible par le client",
+      "showToCustomer": "Montrer au client",
+      "hideFromCustomer": "Masquer au client",
+      "add": {
+        "image": "Ajouter des photos",
+        "document": "Ajouter des documents",
+        "diagnostic": "Ajouter un rapport de diagnostic",
+        "video": "Ajouter une vidéo"
+      },
+      "dropHint": "Déposez des fichiers ou cliquez pour parcourir",
+      "added": "{count, plural, one {# fichier ajouté} other {# fichiers ajoutés}}",
+      "failed": "Impossible d'envoyer {name}",
+      "wrongType": "Type de fichier non adapté à cet onglet : {names}",
+      "limit": "Limite atteinte ({count}/{max})"
+    },
+    "moneyBar": {
+      "total": "Total",
+      "paid": "Payé",
+      "balance": "Solde dû",
+      "settled": "Payé en totalité",
+      "takePayment": "Encaisser",
+      "preview": "Aperçu de la facture",
+      "previewShort": "Aperçu",
+      "send": "Envoyer au client",
+      "sendShort": "Envoyer"
+    },
+    "vehicleInfo": {
+      "more": "Plus d'infos",
+      "less": "Moins d'infos",
+      "copy": "Copier {label}",
+      "copied": "Copié",
+      "lastInspection": "Dernier contrôle périodique",
+      "purchaseDate": "Date d'achat",
+      "purchasePrice": "Prix d'achat",
+      "overdue": "en retard",
+      "customerSince": "Client depuis"
+    }
+  },
+  "photoHandoff": {
+    "button": "Ajouter depuis le téléphone",
+    "dialogTitle": "Ajouter des photos et des documents depuis un téléphone",
+    "dialogBody": "Prenez ou choisissez des photos, ou partagez un PDF, sur n’importe quel téléphone. Tout arrive directement sur cet ordre de travail.",
+    "expires": "Le code est valable jusqu'à {time}.",
+    "waiting": "En attente de photos et de documents…",
+    "received": "{count, plural, one {# fichier reçu} other {# fichiers reçus}}",
+    "copyLink": "Copier le lien",
+    "linkCopied": "Lien copié",
+    "failed": "Impossible de créer un code. Réessayez.",
+    "done": "Terminé",
+    "pageTitle": "Ajouter des photos et des documents",
+    "forJob": "Ordre de travail {number}",
+    "takePhoto": "Prendre une photo",
+    "choosePhotos": "Choisir des photos",
+    "hint": "Tout arrive directement sur l’ordre de travail. Prenez autant de photos que nécessaire.",
+    "uploading": "Envoi…",
+    "added": "Ajoutée",
+    "notAdded": "Non ajoutée",
+    "retry": "Réessayer",
+    "addedCount": "{count, plural, one {# fichier ajouté} other {# fichiers ajoutés}}",
+    "expiredTitle": "Ce code a expiré",
+    "invalidTitle": "Ce lien ne fonctionne pas",
+    "errors": {
+      "expired": "Demandez un nouveau code à l'accueil. Chaque code est valable 30 minutes.",
+      "invalid": "Scannez de nouveau le code sur l'ordre de travail.",
+      "limit": "Cet ordre de travail a déjà autant de photos qu’il peut en contenir.",
+      "type": "Seuls les photos et les documents PDF peuvent être ajoutés ici.",
+      "tooLarge": "Ce fichier est trop volumineux.",
+      "failed": "La photo n'a pas été ajoutée. Vérifiez la connexion et réessayez.",
+      "codeLimit": "Ce code a déjà ajouté autant de photos qu’un code le permet. Scannez un nouveau code à l’accueil."
+    },
+    "concernHeading": "Les photos vont sur ce point",
+    "howTitle": "Comment ça marche",
+    "step1": "Pointez l’appareil photo du téléphone vers le code et ouvrez le lien.",
+    "step2": "Prenez des photos, ou choisissez des photos ou des documents PDF déjà sur le téléphone.",
+    "step3": "Elles apparaissent sur cet ordre de travail dès leur arrivée.",
+    "noSignIn": "Aucune connexion nécessaire sur le téléphone.",
+    "customerToo": "Le personnel peut l’utiliser, et le client aussi : laissez-le scanner le code ou envoyez-lui le lien.",
+    "scope": "Le lien ajoute uniquement des photos et des documents à cet ordre de travail et cesse de fonctionner après 30 minutes.",
+    "scanHint": "Scannez avec l’appareil photo du téléphone",
+    "chooseDocument": "Envoyer un document",
+    "privacy": "Seul l’atelier voit ce que vous ajoutez ici."
   }
 }

+ 23 - 1
messages/fr/settings.json

@@ -1229,7 +1229,29 @@
     "dateLabel": "Date",
     "dateTimeLabel": "Date et heure",
     "saveSettings": "Enregistrer les paramètres",
-    "saved": "Paramètres enregistrés"
+    "saved": "Paramètres enregistrés",
+    "fontLabel": "Police",
+    "fontHint": "Choisissez les polices de l'application. Le choix est enregistré sur cet appareil.",
+    "fontSampleHeading": "Ordre de travail",
+    "fontSampleText": "Remplacer la bobine d'allumage et les bougies",
+    "fonts": {
+      "default": {
+        "name": "Par défaut",
+        "description": "Geist, net et neutre."
+      },
+      "workshop": {
+        "name": "Atelier",
+        "description": "IBM Plex avec des titres en capitales étroites, comme une fiche de travail."
+      },
+      "precise": {
+        "name": "Précis",
+        "description": "Inter et JetBrains Mono. Conçu pour les écrans remplis de tableaux et de chiffres."
+      },
+      "legible": {
+        "name": "Lisible",
+        "description": "Atkinson Hyperlegible. 0 et O, 1 et l ne se ressemblent jamais ; facile à lire depuis l'autre bout de l'atelier."
+      }
+    }
   },
   "data": {
     "title": "Gestion des données",

+ 8 - 2
messages/fr/workBoard.json

@@ -78,7 +78,10 @@
     "addTechnician": "Ajouter un Technicien",
     "standaloneOnly": "Un nom sur le tableau pour quelqu'un qui ne se connecte pas, un apprenti ou un sous-traitant. Pour un mécanicien qui utilise l'application, passez par la page Équipe.",
     "linkedTo": "Se connecte en tant que {name}",
-    "linkedManagedOnTeam": "Géré depuis la page Équipe, avec son accès à l'application."
+    "linkedManagedOnTeam": "Géré depuis la page Équipe, avec son accès à l'application.",
+    "skills": "Compétences",
+    "skillsPlaceholder": "Diagnostic, habilité VE",
+    "skillsHint": "Affiché sous le nom là où le travail est attribué. Facultatif."
   },
   "jobDetail": {
     "viewDetails": "Voir les Détails",
@@ -154,6 +157,9 @@
     "workBay": "Poste de travail",
     "status": "Statut",
     "noTime": "Pas encore d'horaire",
-    "none": "Personne pour l'instant"
+    "none": "Personne pour l'instant",
+    "promised": "Promis",
+    "promiseLate": "En retard",
+    "promiseOverdue": "Promis au client pour un moment déjà passé"
   }
 }

+ 7 - 1
messages/fr/workOrders.json

@@ -31,7 +31,9 @@
       "status": "Statut",
       "tech": "Technicien",
       "date": "Date",
-      "total": "Total"
+      "total": "Total",
+      "promised": "Promis",
+      "promisedOverdue": "Promis {date}, en retard"
     },
     "emailSubject": "Mise à jour du Statut du Véhicule: {status}",
     "changeStatus": "Changer le statut",
@@ -39,6 +41,10 @@
       "open": "Ouvrir l'ordre de travail",
       "openVehicle": "Ouvrir le véhicule",
       "openCustomer": "Ouvrir le client"
+    },
+    "dueToday": "Dû aujourd'hui",
+    "columns": {
+      "label": "Colonnes"
     }
   },
   "vehiclePicker": {

+ 21 - 2
messages/it/integrations.json

@@ -60,7 +60,8 @@
       "safety": "Richiami e reclami dei proprietari"
     },
     "ai": {
-      "chat": "Testi e riepiloghi"
+      "chat": "Testi e riepiloghi",
+      "transcribe": "Dettatura"
     },
     "payments": {
       "checkout": "Paga dal link della fattura"
@@ -156,7 +157,8 @@
       "syncNowHelp": "Sincronizza ora avvia subito un passaggio invece di attendere quello orario. Cerca 25 veicoli alla volta e prosegue da solo finché tutti i veicoli da controllare sono stati fatti."
     },
     "supersedes": "Collegare {next} scollega {current}. Solo uno può essere attivo alla volta, quindi {current} smette di essere usato appena {next} entra in servizio.",
-    "batchRetried": "{count, plural, =0 {Niente da riprovare} one {# attività rimessa in coda} other {# attività rimesse in coda}}"
+    "batchRetried": "{count, plural, =0 {Niente da riprovare} one {# attività rimessa in coda} other {# attività rimesse in coda}}",
+    "advanced": "Avanzate"
   },
   "activity": {
     "title": "Attività",
@@ -578,6 +580,23 @@
         "refreshFleet": "Tieni aggiornati i dati su richiami e reclami per ogni veicolo di questa officina",
         "refreshFleetHelp": "Una volta al giorno i modelli non aggiornati nell'ultima settimana vengono richiesti di nuovo, così la pagina del veicolo non attende mai e un nuovo richiamo compare entro una settimana. Vengono inviati solo marca, modello e anno."
       }
+    },
+    "speech-to-text": {
+      "description": "La dettatura su un ordine di lavoro viene trascritta da un modello vocale invece che dal browser: molto più precisa, in qualsiasi lingua e conosce le parole dell'officina. È separata dal tuo fornitore di IA, quindi puoi lasciarlo com'è. Usa la tua chiave OpenAI o un tuo server che parli l'API di trascrizione di OpenAI. La registrazione viene inviata per la trascrizione e non viene conservata.",
+      "fields": {
+        "apiKey": "Chiave API",
+        "apiKeyHelp": "Una chiave OpenAI da platform.openai.com, in API keys; OpenAI fattura l'uso al minuto. Per un tuo server, solo se la richiede.",
+        "baseUrl": "Indirizzo del server",
+        "baseUrlHelp": "Lascia vuoto per usare OpenAI. Per un tuo server (un Whisper locale, LocalAI, Groq), il punto in cui inizia la sua API in stile OpenAI, percorso incluso, ad esempio /v1. Torqvoice vi aggiunge /audio/transcriptions."
+      },
+      "settings": {
+        "model": "Modello",
+        "modelHelp": "Il modello vocale da richiedere. gpt-4o-mini-transcribe è preciso ed economico; gpt-4o-transcribe è quello più grande. Un tuo server di solito risponde a whisper-1.",
+        "mode": "Chi sceglie come viene trascritta la dettatura",
+        "modeHelp": "Il browser scrive mentre parli ma è meno preciso, soprattutto nelle lingue meno diffuse. Il modello vocale scrive quando ti fermi.",
+        "modeAi": "Sempre il modello vocale",
+        "modeChoice": "Sceglie ciascuno: modello vocale o browser"
+      }
     }
   },
   "jobKinds": {

+ 202 - 5
messages/it/service.json

@@ -21,7 +21,8 @@
       "title": "Il lavoro deve avere un titolo prima di poter essere salvato.",
       "negative": "Quantità, ore, prezzi e costi non possono essere negativi.",
       "partName": "Ogni ricambio con un prezzo deve avere un nome.",
-      "laborDescription": "Ogni riga di manodopera con ore o tariffa deve avere una descrizione."
+      "laborDescription": "Ogni riga di manodopera con ore o tariffa deve avere una descrizione.",
+      "concernCondition": "Una segnalazione con causa, intervento o verifica deve avere il sintomo compilato."
     }
   },
   "header": {
@@ -164,6 +165,11 @@
     "deleteRow": "Elimina riga",
     "descriptionMissingHint": "Le righe senza descrizione non vengono salvate."
   },
+  "liveLabor": {
+    "title": "{count, plural, one {Un tecnico ha aggiunto manodopera a questo lavoro} other {Un tecnico ha aggiunto # righe di manodopera a questo lavoro}}",
+    "body": "Resta fuori dall’elenco mentre modifichi. Verrà salvata comunque al prossimo salvataggio.",
+    "show": "Mostra"
+  },
   "totals": {
     "title": "Totali",
     "parts": "Pezzi",
@@ -198,7 +204,9 @@
     "aiOverwriteReplace": "Sostituisci",
     "aiOverwriteAppend": "Aggiungi",
     "aiOverwriteCancel": "Annulla",
-    "aiDontShowAgain": "Non mostrare più"
+    "aiDontShowAgain": "Non mostrare più",
+    "internalTitle": "Note interne",
+    "publicTitle": "Note per il cliente"
   },
   "invoice": {
     "title": "Riepilogo Fattura",
@@ -248,7 +256,10 @@
     "failedDelete": "Impossibile eliminare il pagamento",
     "deleteTitle": "Elimina Pagamento",
     "deleteDescription": "Questo rimuoverà questo record di pagamento.",
-    "failedPdf": "Impossibile generare PDF fattura"
+    "failedPdf": "Impossibile generare PDF fattura",
+    "received": "Pagamenti ricevuti",
+    "noneYet": "Ancora nessun pagamento.",
+    "balanceDue": "Importo dovuto"
   },
   "attachments": {
     "deleteTitle": "Elimina Allegato",
@@ -357,6 +368,7 @@
     "date": "Data",
     "startTime": "Ora di inizio",
     "endTime": "Ora di fine",
+    "durationPresets": "Durata",
     "technician": "Tecnico",
     "selectTechnician": "Seleziona tecnico...",
     "searchOrCreate": "Cerca o crea tecnico...",
@@ -394,7 +406,17 @@
     "pickNextAvailable": "Scegli la prossima fascia libera",
     "findingSlot": "Ricerca di una fascia…",
     "slotPicked": "Spostato sulla prossima fascia libera",
-    "noSlotFound": "Nessuna fascia libera trovata nelle prossime settimane"
+    "noSlotFound": "Nessuna fascia libera trovata nelle prossime settimane",
+    "assignedTitle": "Tecnico assegnato",
+    "boardLink": "Lavagna di lavoro",
+    "someoneElse": "Qualcun altro",
+    "loadOf": "{booked} / {capacity} h",
+    "loadFree": "{hours} h libere",
+    "loadFull": "Al completo",
+    "loadOn": "Ore prenotate il {date}",
+    "promisedAt": "Promesso al cliente",
+    "promisedPlaceholder": "Non ancora promesso",
+    "promisedClear": "Cancella"
   },
   "notifications": {
     "title": "Notifiche al cliente",
@@ -447,7 +469,60 @@
     "placeholder": "C'è un rumore dietro quando freno",
     "remove": "Rimuovi questo punto",
     "notAnswered": "Non è ancora stato trovato nulla",
-    "answered": "{count} riscontro/i su questo"
+    "answered": "{count} riscontro/i su questo",
+    "storyTitle": "Descrizione del lavoro",
+    "storyHint": "Sintomo, causa, intervento, verifica. Clicca su un passaggio per aprirlo; una riga per ogni cosa segnalata dal cliente o osservata da te.",
+    "steps": {
+      "condition": "Sintomo",
+      "cause": "Causa",
+      "correction": "Intervento",
+      "confirm": "Verifica"
+    },
+    "conditionPlaceholder": "Cosa ha segnalato il cliente o cosa hai osservato",
+    "causePlaceholder": "Quale causa principale hanno rivelato le prove",
+    "correctionPlaceholder": "Cosa è stato fatto per risolverlo",
+    "confirmPlaceholder": "Come è stata verificata la riparazione: prova su strada, nuova diagnosi, sintomo scomparso",
+    "markConfirmed": "Verificato",
+    "confirmedBy": "Verificato da {name}, {date}",
+    "confirmedAt": "Verificato il {date}",
+    "confirmedOnSave": "Firmato con il tuo nome al salvataggio",
+    "confirmedCount": "{confirmed} di {total} verificati",
+    "findingsCount": "{count, plural, one {# osservazione} other {# osservazioni}}",
+    "addFinding": "Aggiungi osservazione",
+    "unconfirmedTitle": "Completare con segnalazioni non verificate?",
+    "unconfirmedBody": "{count, plural, one {Una segnalazione non è stata verificata come risolta:} other {# segnalazioni non sono state verificate come risolte:}}",
+    "unconfirmedProceed": "Completa comunque",
+    "addMedia": "Aggiungi foto o video",
+    "mediaUploading": "Caricamento…",
+    "mediaAdded": "{count, plural, one {# file aggiunto} other {# file aggiunti}}",
+    "mediaFailed": "Impossibile caricare {name}",
+    "mediaTypes": "Scegli una foto (JPG, PNG, WebP) o un video.",
+    "dictate": {
+      "start": "Detta",
+      "listening": "In ascolto",
+      "startHint": "Parla e il testo viene scritto qui. Non viene registrato nulla.",
+      "stopHint": "Smetti di ascoltare",
+      "notAllowed": "Il browser non ha il permesso di usare il microfono. Consentilo per questo sito e riprova.",
+      "noMicrophone": "Nessun microfono trovato.",
+      "failed": "La dettatura si è interrotta. Riprova.",
+      "startHintServer": "Parla e il testo viene scritto mentre parli. L'audio va al tuo modello vocale per la trascrizione e non viene conservato.",
+      "transcribing": "Completamento…",
+      "language": "Lingua parlata",
+      "auto": "Auto",
+      "nothingHeard": "Non si è sentito nulla.",
+      "fellBack": "Per ora si usa la dettatura del browser.",
+      "autoLong": "Auto (rileva la lingua)",
+      "engine": "Trascritto da",
+      "engineAi": "Modello vocale (più preciso)",
+      "engineBrowser": "Browser (scrive mentre parli)",
+      "liveFellBack": "Scrittura a blocchi di qualche secondo invece che parola per parola."
+    },
+    "stepEmpty": "Non ancora scritto",
+    "removeTitle": "Rimuovere questa segnalazione?",
+    "removeBody": "Sintomo, causa, intervento e verifica vengono rimossi insieme a essa quando salvi l'ordine di lavoro.",
+    "removeAttached": "Le sue osservazioni e i file restano sul lavoro, non più collegati a una segnalazione.",
+    "removeConfirm": "Rimuovi segnalazione",
+    "saveFirst": "Salva prima l'ordine di lavoro"
   },
   "videoCall": {
     "title": "Videochiamata",
@@ -493,5 +568,127 @@
       "joinLine": "Partecipa alla videochiamata qui: {url}",
       "emailSubject": "Videochiamata per la tua {vehicle}"
     }
+  },
+  "modern": {
+    "statusLabel": "Stato dell'ordine di lavoro",
+    "back": "Indietro",
+    "backToClassic": "Torna al layout classico",
+    "openCustomer": "Apri cliente",
+    "serviceHistory": "Storico interventi",
+    "changeVehicle": "Cambia veicolo",
+    "vehicleChangesOnSave": "L'ordine di lavoro passa a questo veicolo quando salvi.",
+    "phone": "Telefono",
+    "email": "Email",
+    "notSet": "Non impostato",
+    "noCustomer": "Nessun cliente su questo ordine di lavoro.",
+    "invoiceTitle": "Fattura e pagamento",
+    "filesTitle": "File e media",
+    "activity": {
+      "title": "Attività",
+      "messageSent": "Messaggio inviato al cliente",
+      "linkShared": "Link della fattura condiviso",
+      "linkViewed": "{count, plural, one {Il cliente ha aperto il link} other {Il cliente ha aperto il link # volte}}",
+      "paymentTaken": "Pagamento di {amount} registrato"
+    },
+    "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}",
+    "openedBy": "Aperto il {date} da {name}",
+    "editTitle": "Modifica titolo",
+    "titleField": "Titolo",
+    "media": {
+      "tabs": {
+        "photos": "Foto",
+        "diagnostics": "Diagnosi"
+      },
+      "summary": "{count, plural, one {# file} other {# file}} · condivisi con il cliente solo se contrassegnati",
+      "customerCanSee": "Visibile al cliente",
+      "showToCustomer": "Mostra al cliente",
+      "hideFromCustomer": "Nascondi al cliente",
+      "add": {
+        "image": "Aggiungi foto",
+        "document": "Aggiungi documenti",
+        "diagnostic": "Aggiungi rapporto diagnostico",
+        "video": "Aggiungi video"
+      },
+      "dropHint": "Trascina qui i file o clicca per sfogliare",
+      "added": "{count, plural, one {# file aggiunto} other {# file aggiunti}}",
+      "failed": "Impossibile caricare {name}",
+      "wrongType": "Tipo di file non adatto a questa scheda: {names}",
+      "limit": "Limite raggiunto ({count}/{max})"
+    },
+    "moneyBar": {
+      "total": "Totale",
+      "paid": "Pagato",
+      "balance": "Importo dovuto",
+      "settled": "Pagato per intero",
+      "takePayment": "Incassa",
+      "preview": "Anteprima fattura",
+      "previewShort": "Anteprima",
+      "send": "Invia al cliente",
+      "sendShort": "Invia"
+    },
+    "vehicleInfo": {
+      "more": "Più info",
+      "less": "Meno info",
+      "copy": "Copia {label}",
+      "copied": "Copiato",
+      "lastInspection": "Ultima revisione periodica",
+      "purchaseDate": "Data di acquisto",
+      "purchasePrice": "Prezzo di acquisto",
+      "overdue": "scaduta",
+      "customerSince": "Cliente dal"
+    }
+  },
+  "photoHandoff": {
+    "button": "Aggiungi dal telefono",
+    "dialogTitle": "Aggiungi foto e documenti da un telefono",
+    "dialogBody": "Scatta o scegli foto, o condividi un PDF, da qualsiasi telefono. Tutto arriva direttamente in questo ordine di lavoro.",
+    "expires": "Il codice è valido fino alle {time}.",
+    "waiting": "In attesa di foto e documenti…",
+    "received": "{count, plural, one {# file ricevuto} other {# file ricevuti}}",
+    "copyLink": "Copia link",
+    "linkCopied": "Link copiato",
+    "failed": "Impossibile creare un codice. Riprova.",
+    "done": "Fatto",
+    "pageTitle": "Aggiungi foto e documenti",
+    "forJob": "Ordine di lavoro {number}",
+    "takePhoto": "Scatta foto",
+    "choosePhotos": "Scegli foto",
+    "hint": "Tutto arriva direttamente nell’ordine di lavoro. Scatta tutte le foto che servono.",
+    "uploading": "Caricamento…",
+    "added": "Aggiunta",
+    "notAdded": "Non aggiunta",
+    "retry": "Riprova",
+    "addedCount": "{count, plural, one {# file aggiunto} other {# file aggiunti}}",
+    "expiredTitle": "Questo codice è scaduto",
+    "invalidTitle": "Questo link non funziona",
+    "errors": {
+      "expired": "Chiedi un nuovo codice al banco accettazione. Ognuno vale 30 minuti.",
+      "invalid": "Inquadra di nuovo il codice sull'ordine di lavoro.",
+      "limit": "Questo ordine di lavoro ha già tutte le foto che può contenere.",
+      "type": "Qui si possono aggiungere solo foto e documenti PDF.",
+      "tooLarge": "Il file è troppo grande.",
+      "failed": "La foto non è stata aggiunta. Controlla la connessione e riprova.",
+      "codeLimit": "Con questo codice sono già state aggiunte tutte le foto consentite. Inquadra un nuovo codice al banco accettazione."
+    },
+    "concernHeading": "Le foto vanno in questa segnalazione",
+    "howTitle": "Come funziona",
+    "step1": "Inquadra il codice con la fotocamera del telefono e apri il link.",
+    "step2": "Scatta foto, o scegli foto o documenti PDF già presenti sul telefono.",
+    "step3": "Compaiono in questo ordine di lavoro man mano che arrivano.",
+    "noSignIn": "Sul telefono non serve accedere.",
+    "customerToo": "Può usarlo il personale, e anche il cliente: fagli inquadrare il codice o mandagli il link.",
+    "scope": "Il link aggiunge solo foto e documenti a questo ordine di lavoro e smette di funzionare dopo 30 minuti.",
+    "scanHint": "Inquadra con la fotocamera del telefono",
+    "chooseDocument": "Carica un documento",
+    "privacy": "Solo l’officina vede ciò che aggiungi qui."
   }
 }

+ 23 - 1
messages/it/settings.json

@@ -1229,7 +1229,29 @@
     "dateLabel": "Data",
     "dateTimeLabel": "Data e ora",
     "saveSettings": "Salva impostazioni",
-    "saved": "Impostazioni salvate"
+    "saved": "Impostazioni salvate",
+    "fontLabel": "Carattere",
+    "fontHint": "Scegli i caratteri tipografici dell'app. La scelta viene salvata su questo dispositivo.",
+    "fontSampleHeading": "Ordine di lavoro",
+    "fontSampleText": "Sostituire bobina di accensione e candele",
+    "fonts": {
+      "default": {
+        "name": "Predefinito",
+        "description": "Geist, pulito e neutro."
+      },
+      "workshop": {
+        "name": "Officina",
+        "description": "IBM Plex con titoli in maiuscolo condensato, come una scheda di lavoro."
+      },
+      "precise": {
+        "name": "Preciso",
+        "description": "Inter e JetBrains Mono. Pensato per schermate piene di tabelle e cifre."
+      },
+      "legible": {
+        "name": "Leggibile",
+        "description": "Atkinson Hyperlegible. 0 e O, 1 e l non si confondono mai; facile da leggere dall'altra parte dell'officina."
+      }
+    }
   },
   "data": {
     "title": "Gestione dati",

+ 8 - 2
messages/it/workBoard.json

@@ -78,7 +78,10 @@
     "addTechnician": "Aggiungi Tecnico",
     "standaloneOnly": "Un nome sulla lavagna per chi non accede, come un apprendista o un esterno. Per un meccanico che usa l'app, aggiungilo dalla pagina Team.",
     "linkedTo": "Accede come {name}",
-    "linkedManagedOnTeam": "Gestito dalla pagina Team, insieme all'accesso all'app."
+    "linkedManagedOnTeam": "Gestito dalla pagina Team, insieme all'accesso all'app.",
+    "skills": "Competenze",
+    "skillsPlaceholder": "Diagnosi, certificato veicoli elettrici",
+    "skillsHint": "Mostrato sotto il nome dove si assegnano i lavori. Facoltativo."
   },
   "jobDetail": {
     "viewDetails": "Visualizza Dettagli",
@@ -154,6 +157,9 @@
     "workBay": "Postazione",
     "status": "Stato",
     "noTime": "Nessun orario ancora",
-    "none": "Ancora nessuno"
+    "none": "Ancora nessuno",
+    "promised": "Promesso",
+    "promiseLate": "In ritardo",
+    "promiseOverdue": "Promesso al cliente per un momento già passato"
   }
 }

+ 7 - 1
messages/it/workOrders.json

@@ -31,7 +31,9 @@
       "status": "Stato",
       "tech": "Tecnico",
       "date": "Data",
-      "total": "Totale"
+      "total": "Totale",
+      "promised": "Promesso",
+      "promisedOverdue": "Promesso {date}, in ritardo"
     },
     "emailSubject": "Aggiornamento Stato Veicolo: {status}",
     "changeStatus": "Cambia stato",
@@ -39,6 +41,10 @@
       "open": "Apri ordine di lavoro",
       "openVehicle": "Apri veicolo",
       "openCustomer": "Apri cliente"
+    },
+    "dueToday": "In scadenza oggi",
+    "columns": {
+      "label": "Colonne"
     }
   },
   "vehiclePicker": {

+ 21 - 2
messages/lt/integrations.json

@@ -60,7 +60,8 @@
       "safety": "Atšaukimai ir savininkų skundai"
     },
     "ai": {
-      "chat": "Tekstai ir santraukos"
+      "chat": "Tekstai ir santraukos",
+      "transcribe": "Diktavimas"
     },
     "payments": {
       "checkout": "Mokėti iš sąskaitos nuorodos"
@@ -156,7 +157,8 @@
       "syncNowHelp": "„Sinchronizuoti dabar“ iš karto pradeda perėjimą, nelaukdamas valandinio. Tikrinama po 25 transporto priemones ir tęsiama savaime, kol apdorojamos visos, kurioms atėjo laikas."
     },
     "supersedes": "Prijungus {next}, {current} bus atjungtas. Vienu metu gali veikti tik vienas, todėl {current} nustos būti naudojamas, kai tik {next} pradės veikti.",
-    "batchRetried": "{count, plural, =0 {Nėra ko kartoti} one {# užduotis vėl eilėje} few {# užduotys vėl eilėje} many {# užduoties vėl eilėje} other {# užduočių vėl eilėje}}"
+    "batchRetried": "{count, plural, =0 {Nėra ko kartoti} one {# užduotis vėl eilėje} few {# užduotys vėl eilėje} many {# užduoties vėl eilėje} other {# užduočių vėl eilėje}}",
+    "advanced": "Išplėstiniai"
   },
   "activity": {
     "title": "Veikla",
@@ -578,6 +580,23 @@
         "refreshFleet": "Nuolat atnaujinti atšaukimų ir skundų duomenis visoms šių dirbtuvių transporto priemonėms",
         "refreshFleetHelp": "Kartą per dieną modeliai, neatnaujinti per pastarąją savaitę, užklausiami iš naujo, kad transporto priemonės puslapiui niekada nereikėtų laukti, o naujas atšaukimas pasirodytų per savaitę. Siunčiami tik markė, modelis ir metai."
       }
+    },
+    "speech-to-text": {
+      "description": "Diktavimas darbo užsakyme, kurį užrašo kalbos modelis, o ne naršyklė: daug tiksliau, bet kuria kalba, ir jis žino dirbtuvių žodžius. Atskirta nuo jūsų DI paslaugų teikėjo, todėl jį galite palikti tokį, koks yra. Naudojamas jūsų pačių OpenAI raktas arba jūsų serveris, kalbantis OpenAI transkripcijos API. Įrašas siunčiamas užrašyti ir nesaugomas.",
+      "fields": {
+        "apiKey": "API raktas",
+        "apiKeyHelp": "OpenAI raktas iš platform.openai.com skilties API keys; OpenAI apmokestina naudojimą pagal minutes. Jūsų serveriui tik tada, jei jis jo reikalauja.",
+        "baseUrl": "Serverio adresas",
+        "baseUrlHelp": "Palikite tuščią, jei naudojate OpenAI. Jūsų serveriui (vietiniam Whisper, LocalAI, Groq): kur prasideda jo OpenAI tipo API, įskaitant kelią, pavyzdžiui /v1. Torqvoice prie jo prideda /audio/transcriptions."
+      },
+      "settings": {
+        "model": "Modelis",
+        "modelHelp": "Kurio kalbos modelio prašyti. gpt-4o-mini-transcribe yra tikslus ir pigus; gpt-4o-transcribe yra didesnis. Jūsų serveris paprastai atsako į whisper-1.",
+        "mode": "Kas renkasi, kaip užrašomas diktavimas",
+        "modeHelp": "Naršyklė rašo jums kalbant, bet veikia prasčiau, ypač mažesnėmis kalbomis. Kalbos modelis rašo, kai baigiate kalbėti.",
+        "modeAi": "Visada kalbos modelis",
+        "modeChoice": "Kiekvienas renkasi: kalbos modelis ar naršyklė"
+      }
     }
   },
   "jobKinds": {

+ 202 - 5
messages/lt/service.json

@@ -21,7 +21,8 @@
       "title": "Užsakymui reikia pavadinimo, kad jį būtų galima išsaugoti.",
       "negative": "Kiekiai, valandos, kainos ir sąnaudos negali būti neigiami.",
       "partName": "Kiekviena dalis su kaina turi turėti pavadinimą.",
-      "laborDescription": "Kiekviena darbo eilutė su valandomis ar įkainiu turi turėti aprašymą."
+      "laborDescription": "Kiekviena darbo eilutė su valandomis ar įkainiu turi turėti aprašymą.",
+      "concernCondition": "Punktui su priežastimi, pataisymu ar patikrinimu reikia užpildyti būklę."
     }
   },
   "header": {
@@ -164,6 +165,11 @@
     "deleteRow": "Ištrinti eilutę",
     "descriptionMissingHint": "Eilutės be aprašymo neišsaugomos."
   },
+  "liveLabor": {
+    "title": "{count, plural, one {Mechanikas pridėjo # darbo eilutę prie šio užsakymo} few {Mechanikas pridėjo # darbo eilutes prie šio užsakymo} many {Mechanikas pridėjo # darbo eilutės prie šio užsakymo} other {Mechanikas pridėjo # darbo eilučių prie šio užsakymo}}",
+    "body": "Kol redaguojate, ji nerodoma sąraše. Kitą kartą išsaugant ji bus įtraukta.",
+    "show": "Rodyti"
+  },
   "totals": {
     "title": "Sumos",
     "parts": "Dalys",
@@ -198,7 +204,9 @@
     "aiOverwriteReplace": "Pakeisti",
     "aiOverwriteAppend": "Pridėti",
     "aiOverwriteCancel": "Atšaukti",
-    "aiDontShowAgain": "Daugiau nerodyti"
+    "aiDontShowAgain": "Daugiau nerodyti",
+    "internalTitle": "Vidinės pastabos",
+    "publicTitle": "Pastabos klientui"
   },
   "invoice": {
     "title": "Sąskaitos santrauka",
@@ -248,7 +256,10 @@
     "failedDelete": "Nepavyko ištrinti mokėjimo",
     "deleteTitle": "Ištrinti mokėjimą",
     "deleteDescription": "Bus pašalintas šis mokėjimo įrašas.",
-    "failedPdf": "Nepavyko sugeneruoti PDF sąskaitos"
+    "failedPdf": "Nepavyko sugeneruoti PDF sąskaitos",
+    "received": "Gauti mokėjimai",
+    "noneYet": "Dar nieko nesumokėta.",
+    "balanceDue": "Mokėtina suma"
   },
   "attachments": {
     "deleteTitle": "Ištrinti priedą",
@@ -357,6 +368,7 @@
     "date": "Data",
     "startTime": "Pradžios laikas",
     "endTime": "Pabaigos laikas",
+    "durationPresets": "Trukmė",
     "technician": "Technikas",
     "selectTechnician": "Pasirinkite techniką...",
     "searchOrCreate": "Ieškoti arba sukurti techniką...",
@@ -394,7 +406,17 @@
     "pickNextAvailable": "Rinktis artimiausią laisvą laiką",
     "findingSlot": "Ieškoma laiko…",
     "slotPicked": "Priskirta artimiausiam laisvam laikui",
-    "noSlotFound": "Artimiausiomis savaitėmis laisvo laiko nerasta"
+    "noSlotFound": "Artimiausiomis savaitėmis laisvo laiko nerasta",
+    "assignedTitle": "Priskirtas technikas",
+    "boardLink": "Darbų lenta",
+    "someoneElse": "Kitas žmogus",
+    "loadOf": "{booked} / {capacity} val.",
+    "loadFree": "{hours} val. laisva",
+    "loadFull": "Visiškai užimta",
+    "loadOn": "Užimtos valandos {date}",
+    "promisedAt": "Pažadėta klientui",
+    "promisedPlaceholder": "Dar nepažadėta",
+    "promisedClear": "Išvalyti"
   },
   "notifications": {
     "title": "Kliento pranešimai",
@@ -447,7 +469,60 @@
     "placeholder": "Stabdant gale girdisi garsas",
     "remove": "Pašalinti šį punktą",
     "notAnswered": "Kol kas nieko nenustatyta",
-    "answered": "Nustatymų: {count}"
+    "answered": "Nustatymų: {count}",
+    "storyTitle": "Darbo aprašymas",
+    "storyHint": "Būklė, priežastis, pataisymas, patikrinimas. Spustelėkite žingsnį, kad jį atidarytumėte; po vieną eilutę kiekvienam dalykui, kurį paminėjo klientas ar pastebėjote jūs.",
+    "steps": {
+      "condition": "Būklė",
+      "cause": "Priežastis",
+      "correction": "Pataisymas",
+      "confirm": "Patikrinimas"
+    },
+    "conditionPlaceholder": "Ką pranešė klientas arba ką pastebėjote jūs",
+    "causePlaceholder": "Kokią pagrindinę priežastį parodė patikra",
+    "correctionPlaceholder": "Kas padaryta, kad būtų sutvarkyta",
+    "confirmPlaceholder": "Kaip patikrintas pataisymas: bandomasis važiavimas, pakartotinis skenavimas, simptomo nebėra",
+    "markConfirmed": "Patikrinta",
+    "confirmedBy": "Patikrino {name}, {date}",
+    "confirmedAt": "Patikrinta {date}",
+    "confirmedOnSave": "Išsaugant pažymima jūsų vardu",
+    "confirmedCount": "Patikrinta {confirmed} iš {total}",
+    "findingsCount": "{count, plural, one {# pastebėjimas} few {# pastebėjimai} many {# pastebėjimo} other {# pastebėjimų}}",
+    "addFinding": "Pridėti pastebėjimą",
+    "unconfirmedTitle": "Užbaigti su nepatikrintais punktais?",
+    "unconfirmedBody": "{count, plural, one {Nepatikrinta, ar sutvarkytas # punktas:} few {Nepatikrinta, ar sutvarkyti # punktai:} many {Nepatikrinta, ar sutvarkyta # punkto:} other {Nepatikrinta, ar sutvarkyta # punktų:}}",
+    "unconfirmedProceed": "Vis tiek užbaigti",
+    "addMedia": "Pridėti nuotraukų ar vaizdo įrašų",
+    "mediaUploading": "Įkeliama…",
+    "mediaAdded": "{count, plural, one {Pridėtas # failas} few {Pridėti # failai} many {Pridėta # failo} other {Pridėta # failų}}",
+    "mediaFailed": "Nepavyko įkelti {name}",
+    "mediaTypes": "Pasirinkite nuotrauką (JPG, PNG, WebP) arba vaizdo įrašą.",
+    "dictate": {
+      "start": "Diktuoti",
+      "listening": "Klausoma",
+      "startHint": "Kalbėkite, ir tai bus užrašyta čia. Garsas neįrašomas.",
+      "stopHint": "Nebeklausyti",
+      "notAllowed": "Naršyklei neleista naudoti mikrofono. Leiskite tai šiai svetainei ir bandykite dar kartą.",
+      "noMicrophone": "Mikrofonas nerastas.",
+      "failed": "Diktavimas sustojo. Bandykite dar kartą.",
+      "startHintServer": "Kalbėkite, ir tai bus užrašoma jums kalbant. Garsas siunčiamas jūsų kalbos modeliui užrašyti ir nesaugomas.",
+      "transcribing": "Baigiama…",
+      "language": "Kalbama kalba",
+      "auto": "Automatiškai",
+      "nothingHeard": "Nieko neišgirsta.",
+      "fellBack": "Kol kas naudojamas pačios naršyklės diktavimas.",
+      "autoLong": "Automatiškai (atpažinti kalbą)",
+      "engine": "Užrašo",
+      "engineAi": "Kalbos modelis (tiksliau)",
+      "engineBrowser": "Naršyklė (rašo jums kalbant)",
+      "liveFellBack": "Rašoma kelių sekundžių dalimis, o ne žodis po žodžio."
+    },
+    "stepEmpty": "Dar neparašyta",
+    "removeTitle": "Pašalinti šį punktą?",
+    "removeBody": "Išsaugojus darbo užsakymą, kartu pašalinami jo būklė, priežastis, pataisymas ir patikrinimas.",
+    "removeAttached": "Jo pastebėjimai ir failai lieka prie darbo, tik nebepriskirti jokiam punktui.",
+    "removeConfirm": "Pašalinti punktą",
+    "saveFirst": "Pirmiausia išsaugokite darbo užsakymą"
   },
   "videoCall": {
     "title": "Vaizdo skambutis",
@@ -493,5 +568,127 @@
       "joinLine": "Prisijunkite prie vaizdo skambučio čia: {url}",
       "emailSubject": "Vaizdo skambutis dėl jūsų {vehicle}"
     }
+  },
+  "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ę",
+    "vehicleChangesOnSave": "Išsaugojus darbo užsakymas bus perkeltas šiai transporto priemonei.",
+    "phone": "Telefonas",
+    "email": "El. paštas",
+    "notSet": "Nenustatyta",
+    "noCustomer": "Šiame darbo užsakyme nėra kliento.",
+    "invoiceTitle": "Sąskaita ir mokėjimas",
+    "filesTitle": "Failai ir nuotraukos",
+    "activity": {
+      "title": "Veikla",
+      "messageSent": "Žinutė išsiųsta klientui",
+      "linkShared": "Pasidalyta sąskaitos nuoroda",
+      "linkViewed": "{count, plural, one {Klientas atidarė nuorodą # kartą} few {Klientas atidarė nuorodą # kartus} many {Klientas atidarė nuorodą # karto} other {Klientas atidarė nuorodą # kartų}}",
+      "paymentTaken": "Užregistruotas {amount} mokėjimas"
+    },
+    "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}",
+    "openedBy": "Atidarė {name}, {date}",
+    "editTitle": "Redaguoti pavadinimą",
+    "titleField": "Pavadinimas",
+    "media": {
+      "tabs": {
+        "photos": "Nuotraukos",
+        "diagnostics": "Diagnostika"
+      },
+      "summary": "{count, plural, one {# failas} few {# failai} many {# failo} other {# failų}} · klientui rodomi tik pažymėti",
+      "customerCanSee": "Klientas mato",
+      "showToCustomer": "Rodyti klientui",
+      "hideFromCustomer": "Slėpti nuo kliento",
+      "add": {
+        "image": "Pridėti nuotraukų",
+        "document": "Pridėti dokumentų",
+        "diagnostic": "Pridėti diagnostikos ataskaitą",
+        "video": "Pridėti vaizdo įrašą"
+      },
+      "dropHint": "Nutempkite failus arba spustelėkite, kad pasirinktumėte",
+      "added": "{count, plural, one {Pridėtas # failas} few {Pridėti # failai} many {Pridėta # failo} other {Pridėta # failų}}",
+      "failed": "Nepavyko įkelti {name}",
+      "wrongType": "Netinkamas failo tipas šiam skirtukui: {names}",
+      "limit": "Pasiekta riba ({count}/{max})"
+    },
+    "moneyBar": {
+      "total": "Iš viso",
+      "paid": "Sumokėta",
+      "balance": "Mokėtina suma",
+      "settled": "Visiškai apmokėta",
+      "takePayment": "Priimti mokėjimą",
+      "preview": "Peržiūrėti sąskaitą",
+      "previewShort": "Peržiūra",
+      "send": "Siųsti klientui",
+      "sendShort": "Siųsti"
+    },
+    "vehicleInfo": {
+      "more": "Daugiau informacijos",
+      "less": "Mažiau informacijos",
+      "copy": "Kopijuoti: {label}",
+      "copied": "Nukopijuota",
+      "lastInspection": "Paskutinė periodinė apžiūra",
+      "purchaseDate": "Pirkimo data",
+      "purchasePrice": "Pirkimo kaina",
+      "overdue": "pavėluota",
+      "customerSince": "Klientas nuo"
+    }
+  },
+  "photoHandoff": {
+    "button": "Pridėti iš telefono",
+    "dialogTitle": "Pridėti nuotraukas ir dokumentus iš telefono",
+    "dialogBody": "Fotografuokite, pasirinkite nuotraukas arba pasidalykite PDF bet kuriame telefone. Viskas iškart atsiranda šiame darbo užsakyme.",
+    "expires": "Kodas galioja iki {time}.",
+    "waiting": "Laukiama nuotraukų ir dokumentų…",
+    "received": "{count, plural, one {Gautas # failas} few {Gauti # failai} many {Gauta # failo} other {Gauta # failų}}",
+    "copyLink": "Kopijuoti nuorodą",
+    "linkCopied": "Nuoroda nukopijuota",
+    "failed": "Nepavyko sukurti kodo. Bandykite dar kartą.",
+    "done": "Baigta",
+    "pageTitle": "Pridėti nuotraukas ir dokumentus",
+    "forJob": "Darbo užsakymas {number}",
+    "takePhoto": "Fotografuoti",
+    "choosePhotos": "Pasirinkti nuotraukas",
+    "hint": "Viskas iškart atsiranda darbo užsakyme. Fotografuokite tiek, kiek reikia.",
+    "uploading": "Įkeliama…",
+    "added": "Pridėta",
+    "notAdded": "Nepridėta",
+    "retry": "Bandyti dar kartą",
+    "addedCount": "{count, plural, one {Pridėtas # failas} few {Pridėti # failai} many {Pridėta # failo} other {Pridėta # failų}}",
+    "expiredTitle": "Šio kodo galiojimas baigėsi",
+    "invalidTitle": "Ši nuoroda neveikia",
+    "errors": {
+      "expired": "Paprašykite serviso darbuotojų naujo kodo. Kiekvienas kodas galioja 30 minučių.",
+      "invalid": "Dar kartą nuskaitykite darbo užsakymo kodą.",
+      "limit": "Šiame darbo užsakyme jau yra tiek nuotraukų, kiek galima.",
+      "type": "Čia galima pridėti tik nuotraukas ir PDF dokumentus.",
+      "tooLarge": "Failas per didelis.",
+      "failed": "Nuotrauka nepridėta. Patikrinkite ryšį ir bandykite dar kartą.",
+      "codeLimit": "Šiuo kodu jau pridėta tiek nuotraukų, kiek leidžia vienas kodas. Nuskenuokite naują kodą."
+    },
+    "concernHeading": "Nuotraukos priskiriamos šiam punktui",
+    "howTitle": "Kaip tai veikia",
+    "step1": "Nukreipkite telefono kamerą į kodą ir atidarykite nuorodą.",
+    "step2": "Fotografuokite arba pasirinkite telefone jau esančias nuotraukas ar PDF dokumentus.",
+    "step3": "Jos atsiranda šiame darbo užsakyme vos gavus.",
+    "noSignIn": "Telefone prisijungti nereikia.",
+    "customerToo": "Gali naudotis darbuotojai, ir klientas taip pat: leiskite jam nuskenuoti kodą arba nusiųskite nuorodą.",
+    "scope": "Nuoroda tik prideda nuotraukas ir dokumentus prie šio darbo užsakymo ir nustoja veikti po 30 minučių.",
+    "scanHint": "Nuskenuokite telefono kamera",
+    "chooseDocument": "Įkelti dokumentą",
+    "privacy": "Tai, ką čia pridėsite, mato tik servisas."
   }
 }

+ 23 - 1
messages/lt/settings.json

@@ -1229,7 +1229,29 @@
     "dateLabel": "Data",
     "dateTimeLabel": "Data ir laikas",
     "saveSettings": "Išsaugoti nustatymus",
-    "saved": "Nustatymai išsaugoti"
+    "saved": "Nustatymai išsaugoti",
+    "fontLabel": "Šriftas",
+    "fontHint": "Pasirinkite šriftus, kuriais rodoma programa. Pasirinkimas išsaugomas šiame įrenginyje.",
+    "fontSampleHeading": "Darbo užsakymas",
+    "fontSampleText": "Pakeisti uždegimo ritę ir žvakes",
+    "fonts": {
+      "default": {
+        "name": "Numatytasis",
+        "description": "Geist, švarus ir neutralus."
+      },
+      "workshop": {
+        "name": "Dirbtuvės",
+        "description": "IBM Plex su siauromis didžiųjų raidžių antraštėmis, kaip darbo kortelėje."
+      },
+      "precise": {
+        "name": "Tikslus",
+        "description": "Inter ir JetBrains Mono. Sukurta ekranams, pilniems lentelių ir skaičių."
+      },
+      "legible": {
+        "name": "Įskaitomas",
+        "description": "Atkinson Hyperlegible. 0 ir O, 1 ir l niekada neatrodo panašiai; lengva skaityti iš kito dirbtuvių galo."
+      }
+    }
   },
   "data": {
     "title": "Duomenų valdymas",

+ 8 - 2
messages/lt/workBoard.json

@@ -78,7 +78,10 @@
     "addTechnician": "Pridėti techniką",
     "standaloneOnly": "Vardas lentoje tam, kuris neprisijungia: praktikantui ar rangovui. Mechaniką, kuris naudoja programėlę, pridėkite Komandos puslapyje.",
     "linkedTo": "Prisijungia kaip {name}",
-    "linkedManagedOnTeam": "Valdoma Komandos puslapyje kartu su prieiga prie programėlės."
+    "linkedManagedOnTeam": "Valdoma Komandos puslapyje kartu su prieiga prie programėlės.",
+    "skills": "Įgūdžiai",
+    "skillsPlaceholder": "Diagnostika, elektromobilių sertifikatas",
+    "skillsHint": "Rodoma po vardu ten, kur skirstomi darbai. Neprivaloma."
   },
   "jobDetail": {
     "viewDetails": "Peržiūrėti informaciją",
@@ -154,6 +157,9 @@
     "workBay": "Darbo vieta",
     "status": "Būsena",
     "noTime": "Laikas dar nenustatytas",
-    "none": "Kol kas niekas"
+    "none": "Kol kas niekas",
+    "promised": "Pažadėta",
+    "promiseLate": "Vėluoja",
+    "promiseOverdue": "Klientui pažadėta jau praėjusiam laikui"
   }
 }

+ 7 - 1
messages/lt/workOrders.json

@@ -31,7 +31,9 @@
       "status": "Būsena",
       "tech": "Technikas",
       "date": "Data",
-      "total": "Suma"
+      "total": "Suma",
+      "promised": "Pažadėta",
+      "promisedOverdue": "Pažadėta {date}, vėluoja"
     },
     "emailSubject": "Transporto priemonės būsenos atnaujinimas: {status}",
     "changeStatus": "Keisti būseną",
@@ -39,6 +41,10 @@
       "open": "Atidaryti darbo užsakymą",
       "openVehicle": "Atidaryti transporto priemonę",
       "openCustomer": "Atidaryti klientą"
+    },
+    "dueToday": "Šiandienai",
+    "columns": {
+      "label": "Stulpeliai"
     }
   },
   "vehiclePicker": {

+ 21 - 2
messages/nb/integrations.json

@@ -60,7 +60,8 @@
       "safety": "Tilbakekallinger og eierklager"
     },
     "ai": {
-      "chat": "Tekst og sammendrag"
+      "chat": "Tekst og sammendrag",
+      "transcribe": "Diktering"
     },
     "payments": {
       "checkout": "Betal fra fakturalenken"
@@ -156,7 +157,8 @@
       "syncNowHelp": "Synkroniser nå starter en gjennomgang med en gang i stedet for å vente på den timevise. Den slår opp 25 kjøretøy om gangen og fortsetter av seg selv til alle med forfalt sjekk er ferdige."
     },
     "supersedes": "Å koble til {next} kobler fra {current}. Bare én kan være i bruk om gangen, så {current} tas ut av bruk i det {next} settes i gang.",
-    "batchRetried": "{count, plural, =0 {Ingenting å prøve på nytt} one {# jobb lagt i kø igjen} other {# jobber lagt i kø igjen}}"
+    "batchRetried": "{count, plural, =0 {Ingenting å prøve på nytt} one {# jobb lagt i kø igjen} other {# jobber lagt i kø igjen}}",
+    "advanced": "Avansert"
   },
   "activity": {
     "title": "Aktivitet",
@@ -578,6 +580,23 @@
         "refreshFleet": "Hold tilbakekallings- og klagedata oppdatert for alle kjøretøy i dette verkstedet",
         "refreshFleetHelp": "En gang om dagen hentes modeller som ikke er oppdatert siste uke på nytt, så kjøretøysiden aldri venter og en ny tilbakekalling vises innen en uke. Bare merke, modell og år sendes."
       }
+    },
+    "speech-to-text": {
+      "description": "Diktering på en arbeidsordre, skrevet ned av en talemodell i stedet for nettleseren: langt mer nøyaktig, alle språk, og den kan verkstedets ord. Atskilt fra AI-leverandøren din, så den kan du beholde som den er. Bruker din egen OpenAI-nøkkel, eller en egen server som snakker OpenAIs API for transkribering. Opptaket sendes for å skrives ned og lagres ikke.",
+      "fields": {
+        "apiKey": "API-nøkkel",
+        "apiKeyHelp": "En OpenAI-nøkkel fra platform.openai.com under API keys; OpenAI fakturerer bruken per minutt. For en egen server bare hvis den krever en.",
+        "baseUrl": "Serveradresse",
+        "baseUrlHelp": "La stå tomt for å bruke OpenAI. For en egen server (en lokal Whisper, LocalAI, Groq): der serverens OpenAI-lignende API starter, inkludert stien, for eksempel /v1. Torqvoice legger til /audio/transcriptions."
+      },
+      "settings": {
+        "model": "Modell",
+        "modelHelp": "Talemodellen som skal brukes. gpt-4o-mini-transcribe er nøyaktig og billig; gpt-4o-transcribe er den større. En egen server svarer vanligvis på whisper-1.",
+        "mode": "Hvem velger hvordan diktering skrives ned",
+        "modeHelp": "Nettleseren skriver mens du snakker, men er svakere, særlig på mindre språk. Talemodellen skriver når du stopper.",
+        "modeAi": "Alltid talemodellen",
+        "modeChoice": "Hver person velger: talemodell eller nettleser"
+      }
     }
   },
   "jobKinds": {

+ 202 - 5
messages/nb/service.json

@@ -21,7 +21,8 @@
       "title": "Jobben må ha en tittel før den kan lagres.",
       "negative": "Antall, timer, priser og kostnader kan ikke være negative.",
       "partName": "Alle deler med pris må ha et navn.",
-      "laborDescription": "Alle arbeidslinjer med timer eller sats må ha en beskrivelse."
+      "laborDescription": "Alle arbeidslinjer med timer eller sats må ha en beskrivelse.",
+      "concernCondition": "Et punkt med årsak, utbedring eller kontroll må ha tilstanden fylt ut."
     }
   },
   "header": {
@@ -164,6 +165,11 @@
     "deleteRow": "Slett rad",
     "descriptionMissingHint": "Rader uten beskrivelse lagres ikke."
   },
+  "liveLabor": {
+    "title": "{count, plural, one {En mekaniker la til arbeid på denne jobben} other {En mekaniker la til # arbeidslinjer på denne jobben}}",
+    "body": "Den holdes utenfor listen mens du redigerer. Den blir lagret uansett neste gang du lagrer.",
+    "show": "Vis den"
+  },
   "totals": {
     "title": "Totalt",
     "parts": "Deler",
@@ -198,7 +204,9 @@
     "aiOverwriteReplace": "Erstatt",
     "aiOverwriteAppend": "Legg til",
     "aiOverwriteCancel": "Avbryt",
-    "aiDontShowAgain": "Ikke vis dette igjen"
+    "aiDontShowAgain": "Ikke vis dette igjen",
+    "internalTitle": "Interne notater",
+    "publicTitle": "Notater til kunden"
   },
   "invoice": {
     "title": "Fakturaoversikt",
@@ -248,7 +256,10 @@
     "failedDelete": "Kunne ikke slette betaling",
     "deleteTitle": "Slett betaling",
     "deleteDescription": "Dette vil fjerne denne betalingsregistreringen.",
-    "failedPdf": "Kunne ikke generere PDF-faktura"
+    "failedPdf": "Kunne ikke generere PDF-faktura",
+    "received": "Mottatte betalinger",
+    "noneYet": "Ingenting betalt ennå.",
+    "balanceDue": "Utestående"
   },
   "attachments": {
     "deleteTitle": "Slett vedlegg",
@@ -357,6 +368,7 @@
     "date": "Dato",
     "startTime": "Starttid",
     "endTime": "Sluttid",
+    "durationPresets": "Varighet",
     "technician": "Tekniker",
     "selectTechnician": "Velg tekniker...",
     "searchOrCreate": "Søk eller opprett tekniker...",
@@ -394,7 +406,17 @@
     "pickNextAvailable": "Velg neste ledige tid",
     "findingSlot": "Finner en tid…",
     "slotPicked": "Booket inn på neste ledige tid",
-    "noSlotFound": "Fant ingen ledig tid de neste ukene"
+    "noSlotFound": "Fant ingen ledig tid de neste ukene",
+    "assignedTitle": "Tilordnet tekniker",
+    "boardLink": "Arbeidstavle",
+    "someoneElse": "Noen andre",
+    "loadOf": "{booked} / {capacity} t",
+    "loadFree": "{hours} t ledig",
+    "loadFull": "Fullbooket",
+    "loadOn": "Bookede timer {date}",
+    "promisedAt": "Lovet kunden",
+    "promisedPlaceholder": "Ikke lovet ennå",
+    "promisedClear": "Fjern"
   },
   "notifications": {
     "title": "Kundevarsler",
@@ -447,7 +469,60 @@
     "placeholder": "Det kommer en lyd bakfra når jeg bremser",
     "remove": "Fjern dette punktet",
     "notAnswered": "Ingenting funnet på dette ennå",
-    "answered": "{count} funn på dette"
+    "answered": "{count} funn på dette",
+    "storyTitle": "Jobbeskrivelse",
+    "storyHint": "Tilstand, årsak, utbedring, kontroll. Klikk på et steg for å åpne det; én rad for hver ting kunden nevnte eller du observerte.",
+    "steps": {
+      "condition": "Tilstand",
+      "cause": "Årsak",
+      "correction": "Utbedring",
+      "confirm": "Kontroll"
+    },
+    "conditionPlaceholder": "Hva kunden meldte, eller hva du observerte",
+    "causePlaceholder": "Hva testingen viste var den egentlige årsaken",
+    "correctionPlaceholder": "Hva som ble gjort for å rette det",
+    "confirmPlaceholder": "Hvordan utbedringen ble kontrollert: prøvekjøring, ny skanning, symptomet borte",
+    "markConfirmed": "Kontrollert",
+    "confirmedBy": "Kontrollert av {name}, {date}",
+    "confirmedAt": "Kontrollert {date}",
+    "confirmedOnSave": "Stemples med navnet ditt når du lagrer",
+    "confirmedCount": "{confirmed} av {total} kontrollert",
+    "findingsCount": "{count, plural, one {# observasjon} other {# observasjoner}}",
+    "addFinding": "Legg til observasjon",
+    "unconfirmedTitle": "Fullføre med punkter som ikke er kontrollert?",
+    "unconfirmedBody": "{count, plural, one {Ett punkt er ikke kontrollert som utbedret:} other {# punkter er ikke kontrollert som utbedret:}}",
+    "unconfirmedProceed": "Fullfør likevel",
+    "addMedia": "Legg til media",
+    "mediaUploading": "Laster opp…",
+    "mediaAdded": "{count, plural, one {# fil lagt til} other {# filer lagt til}}",
+    "mediaFailed": "Kunne ikke laste opp {name}",
+    "mediaTypes": "Velg et bilde (JPG, PNG, WebP) eller en video.",
+    "dictate": {
+      "start": "Dikter",
+      "listening": "Lytter",
+      "startHint": "Snakk, så skrives det her. Ingenting tas opp.",
+      "stopHint": "Slutt å lytte",
+      "notAllowed": "Nettleseren fikk ikke bruke mikrofonen. Gi dette nettstedet tilgang og prøv igjen.",
+      "noMicrophone": "Fant ingen mikrofon.",
+      "failed": "Dikteringen stoppet. Prøv igjen.",
+      "startHintServer": "Snakk, så skrives det mens du snakker. Lyden går til talemodellen din for å skrives ned og lagres ikke.",
+      "transcribing": "Gjør ferdig…",
+      "language": "Språk som snakkes",
+      "auto": "Auto",
+      "nothingHeard": "Ingenting ble hørt.",
+      "fellBack": "Bruker nettleserens egen diktering inntil videre.",
+      "autoLong": "Auto (gjenkjenn språket)",
+      "engine": "Skrives ned av",
+      "engineAi": "Talemodell (mer nøyaktig)",
+      "engineBrowser": "Nettleser (skriver mens du snakker)",
+      "liveFellBack": "Skriver i bolker på noen sekunder i stedet for ord for ord."
+    },
+    "stepEmpty": "Ikke skrevet ennå",
+    "removeTitle": "Fjerne dette punktet?",
+    "removeBody": "Tilstand, årsak, utbedring og kontroll fjernes sammen med det når du lagrer arbeidsordren.",
+    "removeAttached": "Observasjonene og mediene blir liggende på jobben, men ikke lenger under et punkt.",
+    "removeConfirm": "Fjern punktet",
+    "saveFirst": "Lagre arbeidsordren først"
   },
   "videoCall": {
     "title": "Videosamtale",
@@ -493,5 +568,127 @@
       "joinLine": "Bli med i videosamtalen her: {url}",
       "emailSubject": "Videosamtale om din {vehicle}"
     }
+  },
+  "modern": {
+    "statusLabel": "Status for arbeidsordren",
+    "back": "Tilbake",
+    "backToClassic": "Tilbake til den klassiske visningen",
+    "openCustomer": "Åpne kunde",
+    "serviceHistory": "Servicehistorikk",
+    "changeVehicle": "Bytt kjøretøy",
+    "vehicleChangesOnSave": "Arbeidsordren flyttes til dette kjøretøyet når du lagrer.",
+    "phone": "Telefon",
+    "email": "E-post",
+    "notSet": "Ikke satt",
+    "noCustomer": "Ingen kunde på denne arbeidsordren.",
+    "invoiceTitle": "Faktura og betaling",
+    "filesTitle": "Filer og media",
+    "activity": {
+      "title": "Aktivitet",
+      "messageSent": "Melding sendt til kunden",
+      "linkShared": "Fakturalenke delt",
+      "linkViewed": "{count, plural, one {Kunden åpnet lenken} other {Kunden åpnet lenken # ganger}}",
+      "paymentTaken": "Betaling på {amount} registrert"
+    },
+    "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}",
+    "openedBy": "Åpnet {date} av {name}",
+    "editTitle": "Rediger tittel",
+    "titleField": "Tittel",
+    "media": {
+      "tabs": {
+        "photos": "Bilder",
+        "diagnostics": "Diagnostikk"
+      },
+      "summary": "{count, plural, one {# fil} other {# filer}} · deles med kunden bare når de er merket",
+      "customerCanSee": "Kunden kan se",
+      "showToCustomer": "Vis for kunden",
+      "hideFromCustomer": "Skjul for kunden",
+      "add": {
+        "image": "Legg til bilder",
+        "document": "Legg til dokumenter",
+        "diagnostic": "Legg til diagnoserapport",
+        "video": "Legg til video"
+      },
+      "dropHint": "Slipp filer her eller klikk for å velge",
+      "added": "{count, plural, one {# fil lagt til} other {# filer lagt til}}",
+      "failed": "Kunne ikke laste opp {name}",
+      "wrongType": "Feil filtype for denne fanen: {names}",
+      "limit": "Grensen er nådd ({count}/{max})"
+    },
+    "moneyBar": {
+      "total": "Totalt",
+      "paid": "Betalt",
+      "balance": "Utestående",
+      "settled": "Fullt betalt",
+      "takePayment": "Ta imot betaling",
+      "preview": "Forhåndsvis faktura",
+      "previewShort": "Forhåndsvis",
+      "send": "Send til kunden",
+      "sendShort": "Send"
+    },
+    "vehicleInfo": {
+      "more": "Mer info",
+      "less": "Mindre info",
+      "copy": "Kopier {label}",
+      "copied": "Kopiert",
+      "lastInspection": "Forrige EU-kontroll",
+      "purchaseDate": "Kjøpsdato",
+      "purchasePrice": "Kjøpspris",
+      "overdue": "forfalt",
+      "customerSince": "Kunde siden"
+    }
+  },
+  "photoHandoff": {
+    "button": "Legg til fra mobil",
+    "dialogTitle": "Legg til bilder og dokumenter fra en telefon",
+    "dialogBody": "Ta eller velg bilder, eller del en PDF, på en hvilken som helst telefon. Alt havner rett på denne arbeidsordren.",
+    "expires": "Koden virker til {time}.",
+    "waiting": "Venter på bilder og dokumenter…",
+    "received": "{count, plural, one {# fil mottatt} other {# filer mottatt}}",
+    "copyLink": "Kopier lenke",
+    "linkCopied": "Lenke kopiert",
+    "failed": "Kunne ikke lage en kode. Prøv igjen.",
+    "done": "Ferdig",
+    "pageTitle": "Legg til bilder og dokumenter",
+    "forJob": "Arbeidsordre {number}",
+    "takePhoto": "Ta bilde",
+    "choosePhotos": "Velg bilder",
+    "hint": "Alt havner rett på arbeidsordren. Ta så mange bilder du trenger.",
+    "uploading": "Laster opp…",
+    "added": "Lagt til",
+    "notAdded": "Ikke lagt til",
+    "retry": "Prøv igjen",
+    "addedCount": "{count, plural, one {# fil lagt til} other {# filer lagt til}}",
+    "expiredTitle": "Denne koden har utløpt",
+    "invalidTitle": "Denne lenken virker ikke",
+    "errors": {
+      "expired": "Be om en ny kode i skranken. Hver kode virker i 30 minutter.",
+      "invalid": "Skann koden på arbeidsordren på nytt.",
+      "limit": "Denne arbeidsordren har allerede så mange bilder som den kan ta.",
+      "type": "Her kan du bare legge til bilder og PDF-dokumenter.",
+      "tooLarge": "Filen er for stor.",
+      "failed": "Bildet ble ikke lagt til. Sjekk tilkoblingen og prøv igjen.",
+      "codeLimit": "Denne koden har lagt til så mange bilder som én kode kan. Skann en ny kode i resepsjonen."
+    },
+    "concernHeading": "Bildene legges under dette punktet",
+    "howTitle": "Slik fungerer det",
+    "step1": "Rett telefonkameraet mot koden og åpne lenken.",
+    "step2": "Ta bilder, eller velg bilder eller PDF-dokumenter som allerede ligger på telefonen.",
+    "step3": "De dukker opp på denne arbeidsordren etter hvert som de kommer inn.",
+    "noSignIn": "Ingen innlogging på telefonen.",
+    "customerToo": "Ansatte kan bruke den, og det kan kunden også: la kunden skanne koden, eller send lenken.",
+    "scope": "Lenken kan bare legge til bilder og dokumenter på denne arbeidsordren, og slutter å virke etter 30 minutter.",
+    "scanHint": "Skann med telefonkameraet",
+    "chooseDocument": "Last opp et dokument",
+    "privacy": "Bare verkstedet ser det du legger til her."
   }
 }

+ 23 - 1
messages/nb/settings.json

@@ -1229,7 +1229,29 @@
     "dateLabel": "Dato",
     "dateTimeLabel": "Dato og tid",
     "saveSettings": "Lagre innstillinger",
-    "saved": "Innstillinger lagret"
+    "saved": "Innstillinger lagret",
+    "fontLabel": "Skrift",
+    "fontHint": "Velg skrifttypene appen bruker. Valget lagres på denne enheten.",
+    "fontSampleHeading": "Arbeidsordre",
+    "fontSampleText": "Bytt tennspole og tennplugger",
+    "fonts": {
+      "default": {
+        "name": "Standard",
+        "description": "Geist, ren og nøytral."
+      },
+      "workshop": {
+        "name": "Verksted",
+        "description": "IBM Plex med smale store bokstaver i overskriftene, som på et arbeidskort."
+      },
+      "precise": {
+        "name": "Presis",
+        "description": "Inter og JetBrains Mono. Laget for skjermer fulle av tabeller og tall."
+      },
+      "legible": {
+        "name": "Lettlest",
+        "description": "Atkinson Hyperlegible. 0 og O, 1 og l ligner aldri på hverandre; lett å lese fra andre siden av verkstedet."
+      }
+    }
   },
   "data": {
     "title": "Datahåndtering",

+ 8 - 2
messages/nb/workBoard.json

@@ -78,7 +78,10 @@
     "addTechnician": "Legg til tekniker",
     "standaloneOnly": "Et navn på tavlen for noen som ikke logger inn, for eksempel en lærling eller innleid. En mekaniker som bruker appen legger du til på Team-siden.",
     "linkedTo": "Logger inn som {name}",
-    "linkedManagedOnTeam": "Styres fra Team-siden, sammen med apptilgangen."
+    "linkedManagedOnTeam": "Styres fra Team-siden, sammen med apptilgangen.",
+    "skills": "Kompetanse",
+    "skillsPlaceholder": "Diagnostikk, elbilsertifisert",
+    "skillsHint": "Vises under navnet der jobbene fordeles. Valgfritt."
   },
   "jobDetail": {
     "viewDetails": "Vis detaljer",
@@ -154,6 +157,9 @@
     "workBay": "Arbeidsplass",
     "status": "Status",
     "noTime": "Ingen tid satt ennå",
-    "none": "Ingen ennå"
+    "none": "Ingen ennå",
+    "promised": "Lovet",
+    "promiseLate": "Forsinket",
+    "promiseOverdue": "Lovet kunden tidligere enn nå"
   }
 }

+ 7 - 1
messages/nb/workOrders.json

@@ -31,7 +31,9 @@
       "status": "Status",
       "tech": "Tekniker",
       "date": "Dato",
-      "total": "Total"
+      "total": "Total",
+      "promised": "Lovet",
+      "promisedOverdue": "Lovet {date}, forsinket"
     },
     "emailSubject": "Kjøretøystatus oppdatert: {status}",
     "changeStatus": "Endre status",
@@ -39,6 +41,10 @@
       "open": "Åpne arbeidsordre",
       "openVehicle": "Åpne kjøretøy",
       "openCustomer": "Åpne kunde"
+    },
+    "dueToday": "Frist i dag",
+    "columns": {
+      "label": "Kolonner"
     }
   },
   "vehiclePicker": {

+ 21 - 2
messages/nl/integrations.json

@@ -60,7 +60,8 @@
       "safety": "Terugroepacties en klachten van eigenaren"
     },
     "ai": {
-      "chat": "Teksten en samenvattingen"
+      "chat": "Teksten en samenvattingen",
+      "transcribe": "Dicteren"
     },
     "payments": {
       "checkout": "Betalen via de factuurlink"
@@ -156,7 +157,8 @@
       "syncNowHelp": "Nu synchroniseren start meteen een ronde in plaats van op de uurlijkse te wachten. Het zoekt 25 voertuigen tegelijk op en gaat vanzelf door tot alle voertuigen aan de beurt zijn geweest."
     },
     "supersedes": "Als u {next} verbindt, wordt {current} losgekoppeld. Er kan er maar één tegelijk actief zijn, dus {current} wordt niet meer gebruikt zodra {next} live gaat.",
-    "batchRetried": "{count, plural, =0 {Niets om opnieuw te proberen} one {# taak opnieuw in de wachtrij} other {# taken opnieuw in de wachtrij}}"
+    "batchRetried": "{count, plural, =0 {Niets om opnieuw te proberen} one {# taak opnieuw in de wachtrij} other {# taken opnieuw in de wachtrij}}",
+    "advanced": "Geavanceerd"
   },
   "activity": {
     "title": "Activiteit",
@@ -578,6 +580,23 @@
         "refreshFleet": "Terugroep- en klachtgegevens actueel houden voor elk voertuig in deze werkplaats",
         "refreshFleetHelp": "Eén keer per dag worden modellen die de afgelopen week niet zijn vernieuwd opnieuw opgehaald, zodat de voertuigpagina nooit wacht en een nieuwe terugroepactie binnen een week zichtbaar is. Alleen merk, model en bouwjaar worden verstuurd."
       }
+    },
+    "speech-to-text": {
+      "description": "Dicteren op een werkorder, uitgeschreven door een spraakmodel in plaats van door de browser: veel nauwkeuriger, in elke taal, en het kent de woorden van een werkplaats. Los van uw AI-provider, dus die kunt u laten zoals hij is. Gebruikt uw eigen OpenAI-sleutel, of een eigen server die de transcriptie-API van OpenAI spreekt. De opname wordt alleen verstuurd om uit te schrijven en wordt niet bewaard.",
+      "fields": {
+        "apiKey": "API-sleutel",
+        "apiKeyHelp": "Een OpenAI-sleutel van platform.openai.com onder API keys; OpenAI rekent het gebruik per minuut af. Bij een eigen server alleen als die erom vraagt.",
+        "baseUrl": "Serveradres",
+        "baseUrlHelp": "Laat leeg om OpenAI te gebruiken. Bij een eigen server (een lokale Whisper, LocalAI, Groq) het adres waar de OpenAI-achtige API begint, inclusief het pad, zoals /v1. Torqvoice zet er /audio/transcriptions achter."
+      },
+      "settings": {
+        "model": "Model",
+        "modelHelp": "Het spraakmodel dat wordt aangesproken. gpt-4o-mini-transcribe is nauwkeurig en goedkoop; gpt-4o-transcribe is het grotere. Een eigen server luistert meestal naar whisper-1.",
+        "mode": "Wie kiest hoe dicteren wordt uitgeschreven",
+        "modeHelp": "De browser schrijft mee terwijl u spreekt, maar is zwakker, vooral in kleinere talen. Het spraakmodel schrijft zodra u stopt.",
+        "modeAi": "Altijd het spraakmodel",
+        "modeChoice": "Ieder kiest zelf: spraakmodel of browser"
+      }
     }
   },
   "jobKinds": {

+ 202 - 5
messages/nl/service.json

@@ -21,7 +21,8 @@
       "title": "De opdracht heeft een titel nodig voordat deze kan worden opgeslagen.",
       "negative": "Aantallen, uren, prijzen en kosten kunnen niet negatief zijn.",
       "partName": "Elk onderdeel met een prijs heeft een naam nodig.",
-      "laborDescription": "Elke arbeidsregel met uren of tarief heeft een omschrijving nodig."
+      "laborDescription": "Elke arbeidsregel met uren of tarief heeft een omschrijving nodig.",
+      "concernCondition": "Een melding met een oorzaak, oplossing of controle moet een ingevulde klacht hebben."
     }
   },
   "header": {
@@ -164,6 +165,11 @@
     "deleteRow": "Rij verwijderen",
     "descriptionMissingHint": "Regels zonder omschrijving worden niet opgeslagen."
   },
+  "liveLabor": {
+    "title": "{count, plural, one {Een monteur heeft arbeid aan deze order toegevoegd} other {Een monteur heeft # arbeidsregels aan deze order toegevoegd}}",
+    "body": "Die blijft buiten de lijst terwijl je bewerkt. Bij je volgende keer opslaan gaat die sowieso mee.",
+    "show": "Tonen"
+  },
   "totals": {
     "title": "Totalen",
     "parts": "Onderdelen",
@@ -198,7 +204,9 @@
     "aiOverwriteReplace": "Vervangen",
     "aiOverwriteAppend": "Toevoegen",
     "aiOverwriteCancel": "Annuleren",
-    "aiDontShowAgain": "Niet meer tonen"
+    "aiDontShowAgain": "Niet meer tonen",
+    "internalTitle": "Interne opmerkingen",
+    "publicTitle": "Opmerkingen voor de klant"
   },
   "invoice": {
     "title": "Factuursamenvatting",
@@ -248,7 +256,10 @@
     "failedDelete": "Betaling verwijderen mislukt",
     "deleteTitle": "Betaling verwijderen",
     "deleteDescription": "Dit zal deze betalingsrecord verwijderen.",
-    "failedPdf": "PDF-factuur genereren mislukt"
+    "failedPdf": "PDF-factuur genereren mislukt",
+    "received": "Ontvangen betalingen",
+    "noneYet": "Nog niets betaald.",
+    "balanceDue": "Verschuldigd bedrag"
   },
   "attachments": {
     "deleteTitle": "Bijlage verwijderen",
@@ -357,6 +368,7 @@
     "date": "Datum",
     "startTime": "Starttijd",
     "endTime": "Eindtijd",
+    "durationPresets": "Duur",
     "technician": "Technicus",
     "selectTechnician": "Selecteer technicus...",
     "searchOrCreate": "Zoek of maak technicus aan...",
@@ -394,7 +406,17 @@
     "pickNextAvailable": "Kies eerstvolgende vrije slot",
     "findingSlot": "Slot zoeken…",
     "slotPicked": "Ingepland op het eerstvolgende vrije slot",
-    "noSlotFound": "Geen vrij slot gevonden in de komende weken"
+    "noSlotFound": "Geen vrij slot gevonden in de komende weken",
+    "assignedTitle": "Toegewezen technicus",
+    "boardLink": "Werkbord",
+    "someoneElse": "Iemand anders",
+    "loadOf": "{booked} / {capacity} u",
+    "loadFree": "{hours} u vrij",
+    "loadFull": "Volgeboekt",
+    "loadOn": "Geboekte uren op {date}",
+    "promisedAt": "Toegezegd aan klant",
+    "promisedPlaceholder": "Nog niet toegezegd",
+    "promisedClear": "Wissen"
   },
   "notifications": {
     "title": "Klantmeldingen",
@@ -447,7 +469,60 @@
     "placeholder": "Er komt een geluid van achteren als ik rem",
     "remove": "Dit punt verwijderen",
     "notAnswered": "Hier is nog niets op gevonden",
-    "answered": "{count} bevinding(en) hierop"
+    "answered": "{count} bevinding(en) hierop",
+    "storyTitle": "Werkomschrijving",
+    "storyHint": "Klacht, oorzaak, oplossing, controle. Klik op een stap om die te openen; één regel voor elk punt dat de klant noemde of dat u zelf zag.",
+    "steps": {
+      "condition": "Klacht",
+      "cause": "Oorzaak",
+      "correction": "Oplossing",
+      "confirm": "Controle"
+    },
+    "conditionPlaceholder": "Wat de klant meldde, of wat u zelf zag",
+    "causePlaceholder": "Wat uit het testen bleek de werkelijke oorzaak te zijn",
+    "correctionPlaceholder": "Wat er is gedaan om het op te lossen",
+    "confirmPlaceholder": "Hoe de oplossing is gecontroleerd: proefrit, nieuwe scan, symptoom weg",
+    "markConfirmed": "Bevestigd",
+    "confirmedBy": "Bevestigd door {name}, {date}",
+    "confirmedAt": "Bevestigd op {date}",
+    "confirmedOnSave": "Krijgt uw naam wanneer u opslaat",
+    "confirmedCount": "{confirmed} van {total} bevestigd",
+    "findingsCount": "{count, plural, one {# observatie} other {# observaties}}",
+    "addFinding": "Observatie toevoegen",
+    "unconfirmedTitle": "Afronden met onbevestigde meldingen?",
+    "unconfirmedBody": "{count, plural, one {Van één melding is niet bevestigd dat die is verholpen:} other {Van # meldingen is niet bevestigd dat ze zijn verholpen:}}",
+    "unconfirmedProceed": "Toch afronden",
+    "addMedia": "Media toevoegen",
+    "mediaUploading": "Bezig met uploaden…",
+    "mediaAdded": "{count, plural, one {# bestand toegevoegd} other {# bestanden toegevoegd}}",
+    "mediaFailed": "Kon {name} niet uploaden",
+    "mediaTypes": "Kies een foto (JPG, PNG, WebP) of een video.",
+    "dictate": {
+      "start": "Dicteren",
+      "listening": "Luistert",
+      "startHint": "Spreek en het wordt hier uitgeschreven. Er wordt niets opgenomen.",
+      "stopHint": "Stoppen met luisteren",
+      "notAllowed": "De browser mocht de microfoon niet gebruiken. Sta het toe voor deze site en probeer het opnieuw.",
+      "noMicrophone": "Geen microfoon gevonden.",
+      "failed": "Dicteren gestopt. Probeer het opnieuw.",
+      "startHintServer": "Spreek en het wordt uitgeschreven terwijl u praat. Het geluid gaat naar uw spraakmodel om uit te schrijven en wordt niet bewaard.",
+      "transcribing": "Bezig met afronden…",
+      "language": "Gesproken taal",
+      "auto": "Automatisch",
+      "nothingHeard": "Er werd niets gehoord.",
+      "fellBack": "Voorlopig wordt het dicteren van de browser zelf gebruikt.",
+      "autoLong": "Automatisch (taal herkennen)",
+      "engine": "Uitgeschreven door",
+      "engineAi": "Spraakmodel (nauwkeuriger)",
+      "engineBrowser": "Browser (schrijft mee terwijl u spreekt)",
+      "liveFellBack": "Schrijft in stappen van een paar seconden in plaats van woord voor woord."
+    },
+    "stepEmpty": "Nog niet ingevuld",
+    "removeTitle": "Deze melding verwijderen?",
+    "removeBody": "De klacht, oorzaak, oplossing en controle worden mee verwijderd wanneer u de werkorder opslaat.",
+    "removeAttached": "De bijbehorende observaties en media blijven bij de klus, alleen niet meer onder een melding.",
+    "removeConfirm": "Melding verwijderen",
+    "saveFirst": "Sla eerst de werkorder op"
   },
   "videoCall": {
     "title": "Videogesprek",
@@ -493,5 +568,127 @@
       "joinLine": "Neem hier deel aan het videogesprek: {url}",
       "emailSubject": "Videogesprek over je {vehicle}"
     }
+  },
+  "modern": {
+    "statusLabel": "Status van de werkorder",
+    "back": "Terug",
+    "backToClassic": "Terug naar de klassieke indeling",
+    "openCustomer": "Klant openen",
+    "serviceHistory": "Servicegeschiedenis",
+    "changeVehicle": "Voertuig wijzigen",
+    "vehicleChangesOnSave": "De werkorder gaat bij opslaan over naar dit voertuig.",
+    "phone": "Telefoon",
+    "email": "E-mail",
+    "notSet": "Niet ingevuld",
+    "noCustomer": "Geen klant op deze werkorder.",
+    "invoiceTitle": "Factuur en betaling",
+    "filesTitle": "Bestanden en media",
+    "activity": {
+      "title": "Activiteit",
+      "messageSent": "Bericht naar de klant gestuurd",
+      "linkShared": "Factuurlink gedeeld",
+      "linkViewed": "{count, plural, one {Klant heeft de link geopend} other {Klant heeft de link # keer geopend}}",
+      "paymentTaken": "Betaling van {amount} geregistreerd"
+    },
+    "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}",
+    "openedBy": "Geopend op {date} door {name}",
+    "editTitle": "Titel bewerken",
+    "titleField": "Titel",
+    "media": {
+      "tabs": {
+        "photos": "Foto's",
+        "diagnostics": "Diagnose"
+      },
+      "summary": "{count, plural, one {# bestand} other {# bestanden}} · alleen gemarkeerde worden met de klant gedeeld",
+      "customerCanSee": "Zichtbaar voor klant",
+      "showToCustomer": "Aan de klant tonen",
+      "hideFromCustomer": "Voor de klant verbergen",
+      "add": {
+        "image": "Foto's toevoegen",
+        "document": "Documenten toevoegen",
+        "diagnostic": "Diagnostisch verslag toevoegen",
+        "video": "Video toevoegen"
+      },
+      "dropHint": "Sleep bestanden hierheen of klik om te bladeren",
+      "added": "{count, plural, one {# bestand toegevoegd} other {# bestanden toegevoegd}}",
+      "failed": "Kon {name} niet uploaden",
+      "wrongType": "Niet het juiste soort bestand voor dit tabblad: {names}",
+      "limit": "Limiet bereikt ({count}/{max})"
+    },
+    "moneyBar": {
+      "total": "Totaal",
+      "paid": "Betaald",
+      "balance": "Verschuldigd bedrag",
+      "settled": "Volledig betaald",
+      "takePayment": "Betaling ontvangen",
+      "preview": "Voorbeeld factuur",
+      "previewShort": "Voorbeeld",
+      "send": "Naar klant sturen",
+      "sendShort": "Sturen"
+    },
+    "vehicleInfo": {
+      "more": "Meer info",
+      "less": "Minder info",
+      "copy": "{label} kopiëren",
+      "copied": "Gekopieerd",
+      "lastInspection": "Laatste periodieke keuring",
+      "purchaseDate": "Aankoopdatum",
+      "purchasePrice": "Aankoopprijs",
+      "overdue": "verlopen",
+      "customerSince": "Klant sinds"
+    }
+  },
+  "photoHandoff": {
+    "button": "Toevoegen via telefoon",
+    "dialogTitle": "Foto’s en documenten toevoegen met een telefoon",
+    "dialogBody": "Maak of kies foto’s, of deel een pdf, op elke telefoon. Alles komt direct bij deze werkorder.",
+    "expires": "De code werkt tot {time}.",
+    "waiting": "Wachten op foto’s en documenten…",
+    "received": "{count, plural, one {# bestand ontvangen} other {# bestanden ontvangen}}",
+    "copyLink": "Link kopiëren",
+    "linkCopied": "Link gekopieerd",
+    "failed": "Kon geen code maken. Probeer het opnieuw.",
+    "done": "Klaar",
+    "pageTitle": "Foto’s en documenten toevoegen",
+    "forJob": "Werkorder {number}",
+    "takePhoto": "Foto maken",
+    "choosePhotos": "Foto's kiezen",
+    "hint": "Alles komt direct bij de werkorder. Maak zoveel foto’s als u nodig hebt.",
+    "uploading": "Bezig met uploaden…",
+    "added": "Toegevoegd",
+    "notAdded": "Niet toegevoegd",
+    "retry": "Opnieuw proberen",
+    "addedCount": "{count, plural, one {# bestand toegevoegd} other {# bestanden toegevoegd}}",
+    "expiredTitle": "Deze code is verlopen",
+    "invalidTitle": "Deze link werkt niet",
+    "errors": {
+      "expired": "Vraag bij de balie om een nieuwe code. Elke code werkt 30 minuten.",
+      "invalid": "Scan de code op de werkorder opnieuw.",
+      "limit": "Deze werkorder heeft al zoveel foto’s als erbij kunnen.",
+      "type": "Hier kunnen alleen foto’s en pdf-documenten worden toegevoegd.",
+      "tooLarge": "Dat bestand is te groot.",
+      "failed": "De foto is niet toegevoegd. Controleer de verbinding en probeer het opnieuw.",
+      "codeLimit": "Met deze code zijn al zoveel foto’s toegevoegd als één code toestaat. Scan een nieuwe code bij de balie."
+    },
+    "concernHeading": "De foto’s horen bij deze melding",
+    "howTitle": "Zo werkt het",
+    "step1": "Richt de camera van de telefoon op de code en open de link.",
+    "step2": "Maak foto’s, of kies foto’s of pdf-documenten die al op de telefoon staan.",
+    "step3": "Ze verschijnen bij deze werkorder zodra ze binnenkomen.",
+    "noSignIn": "Op de telefoon is geen inlog nodig.",
+    "customerToo": "Medewerkers kunnen het gebruiken, en de klant ook: laat hem de code scannen of stuur hem de link.",
+    "scope": "De link voegt alleen foto’s en documenten toe aan deze werkorder en werkt 30 minuten.",
+    "scanHint": "Scan met de camera van de telefoon",
+    "chooseDocument": "Document uploaden",
+    "privacy": "Alleen de werkplaats ziet wat u hier toevoegt."
   }
 }

+ 23 - 1
messages/nl/settings.json

@@ -1229,7 +1229,29 @@
     "dateLabel": "Datum",
     "dateTimeLabel": "Datum en tijd",
     "saveSettings": "Instellingen opslaan",
-    "saved": "Instellingen opgeslagen"
+    "saved": "Instellingen opgeslagen",
+    "fontLabel": "Lettertype",
+    "fontHint": "Kies de lettertypen waarin de app wordt weergegeven. De keuze wordt op dit apparaat opgeslagen.",
+    "fontSampleHeading": "Werkorder",
+    "fontSampleText": "Bobine en bougies vervangen",
+    "fonts": {
+      "default": {
+        "name": "Standaard",
+        "description": "Geist, strak en neutraal."
+      },
+      "workshop": {
+        "name": "Werkplaats",
+        "description": "IBM Plex met smalle koppen in hoofdletters, zoals een werkbon."
+      },
+      "precise": {
+        "name": "Precies",
+        "description": "Inter en JetBrains Mono. Gemaakt voor schermen vol tabellen en cijfers."
+      },
+      "legible": {
+        "name": "Goed leesbaar",
+        "description": "Atkinson Hyperlegible. 0 en O, 1 en l lijken nooit op elkaar; ook van de andere kant van de werkplaats goed te lezen."
+      }
+    }
   },
   "data": {
     "title": "Gegevensbeheer",

+ 8 - 2
messages/nl/workBoard.json

@@ -78,7 +78,10 @@
     "addTechnician": "Technicus toevoegen",
     "standaloneOnly": "Een naam op het bord voor iemand die niet inlogt, zoals een leerling of een externe. Een monteur die de app gebruikt voegt u toe op de Team-pagina.",
     "linkedTo": "Logt in als {name}",
-    "linkedManagedOnTeam": "Wordt beheerd op de Team-pagina, samen met de app-toegang."
+    "linkedManagedOnTeam": "Wordt beheerd op de Team-pagina, samen met de app-toegang.",
+    "skills": "Vaardigheden",
+    "skillsPlaceholder": "Diagnose, EV-gecertificeerd",
+    "skillsHint": "Wordt onder de naam getoond waar werk wordt verdeeld. Optioneel."
   },
   "jobDetail": {
     "viewDetails": "Details weergeven",
@@ -154,6 +157,9 @@
     "workBay": "Werkplek",
     "status": "Status",
     "noTime": "Nog geen tijd",
-    "none": "Nog niemand"
+    "none": "Nog niemand",
+    "promised": "Beloofd",
+    "promiseLate": "Te laat",
+    "promiseOverdue": "Aan de klant beloofd voor een moment dat al voorbij is"
   }
 }

+ 7 - 1
messages/nl/workOrders.json

@@ -31,7 +31,9 @@
       "status": "Status",
       "tech": "Technicus",
       "date": "Datum",
-      "total": "Totaal"
+      "total": "Totaal",
+      "promised": "Beloofd",
+      "promisedOverdue": "Beloofd {date}, te laat"
     },
     "emailSubject": "Voertuigstatus bijgewerkt: {status}",
     "changeStatus": "Status wijzigen",
@@ -39,6 +41,10 @@
       "open": "Werkorder openen",
       "openVehicle": "Voertuig openen",
       "openCustomer": "Klant openen"
+    },
+    "dueToday": "Vandaag klaar",
+    "columns": {
+      "label": "Kolommen"
     }
   },
   "vehiclePicker": {

+ 21 - 2
messages/pl/integrations.json

@@ -60,7 +60,8 @@
       "safety": "Akcje serwisowe i skargi właścicieli"
     },
     "ai": {
-      "chat": "Teksty i podsumowania"
+      "chat": "Teksty i podsumowania",
+      "transcribe": "Dyktowanie"
     },
     "payments": {
       "checkout": "Płatność z linku do faktury"
@@ -156,7 +157,8 @@
       "syncNowHelp": "Synchronizuj teraz uruchamia przebieg od razu zamiast czekać na cogodzinny. Sprawdza po 25 pojazdów i kontynuuje sam, aż wszystkie oczekujące pojazdy zostaną obsłużone."
     },
     "supersedes": "Podłączenie {next} odłączy {current}. Naraz może działać tylko jedno, więc {current} przestanie być używane, gdy tylko {next} zacznie działać.",
-    "batchRetried": "{count, plural, =0 {Nie ma czego ponawiać} one {# zadanie ponownie w kolejce} few {# zadania ponownie w kolejce} many {# zadań ponownie w kolejce} other {# zadania ponownie w kolejce}}"
+    "batchRetried": "{count, plural, =0 {Nie ma czego ponawiać} one {# zadanie ponownie w kolejce} few {# zadania ponownie w kolejce} many {# zadań ponownie w kolejce} other {# zadania ponownie w kolejce}}",
+    "advanced": "Zaawansowane"
   },
   "activity": {
     "title": "Aktywność",
@@ -578,6 +580,23 @@
         "refreshFleet": "Utrzymuj aktualne dane o akcjach serwisowych i skargach dla każdego pojazdu w tym warsztacie",
         "refreshFleetHelp": "Raz dziennie modele nieodświeżone w ostatnim tygodniu są pobierane ponownie, aby strona pojazdu nigdy nie czekała, a nowa akcja serwisowa pojawiła się w ciągu tygodnia. Wysyłane są tylko marka, model i rocznik."
       }
+    },
+    "speech-to-text": {
+      "description": "Dyktowanie w zleceniu zapisywane przez model mowy zamiast przeglądarki: znacznie dokładniej, w każdym języku i ze znajomością słownictwa warsztatowego. Działa niezależnie od Twojego dostawcy AI, więc możesz go zostawić bez zmian. Używa Twojego własnego klucza OpenAI albo własnego serwera obsługującego API transkrypcji OpenAI. Nagranie jest wysyłane do zapisania i nie jest przechowywane.",
+      "fields": {
+        "apiKey": "Klucz API",
+        "apiKeyHelp": "Klucz OpenAI z platform.openai.com, sekcja API keys; OpenAI rozlicza użycie za minutę. Przy własnym serwerze tylko wtedy, gdy go wymaga.",
+        "baseUrl": "Adres serwera",
+        "baseUrlHelp": "Zostaw puste, aby używać OpenAI. Dla własnego serwera (lokalny Whisper, LocalAI, Groq) podaj, gdzie zaczyna się jego API w stylu OpenAI, łącznie ze ścieżką, np. /v1. Torqvoice dokleja do niego /audio/transcriptions."
+      },
+      "settings": {
+        "model": "Model",
+        "modelHelp": "Model mowy, którego używać. gpt-4o-mini-transcribe jest dokładny i tani; gpt-4o-transcribe to większy model. Własny serwer zwykle odpowiada na whisper-1.",
+        "mode": "Kto wybiera, jak zapisywane jest dyktowanie",
+        "modeHelp": "Przeglądarka pisze na bieżąco, ale gorzej, zwłaszcza w mniejszych językach. Model mowy pisze, gdy skończysz mówić.",
+        "modeAi": "Zawsze model mowy",
+        "modeChoice": "Każdy wybiera sam: model mowy lub przeglądarka"
+      }
     }
   },
   "jobKinds": {

+ 202 - 5
messages/pl/service.json

@@ -21,7 +21,8 @@
       "title": "Zlecenie musi mieć tytuł, zanim będzie można je zapisać.",
       "negative": "Ilości, godziny, ceny i koszty nie mogą być ujemne.",
       "partName": "Każda część z ceną musi mieć nazwę.",
-      "laborDescription": "Każda pozycja robocizny z godzinami lub stawką musi mieć opis."
+      "laborDescription": "Każda pozycja robocizny z godzinami lub stawką musi mieć opis.",
+      "concernCondition": "Zgłoszenie z przyczyną, naprawą lub weryfikacją musi mieć wypełniony objaw."
     }
   },
   "header": {
@@ -164,6 +165,11 @@
     "deleteRow": "Usuń wiersz",
     "descriptionMissingHint": "Wiersze bez opisu nie są zapisywane."
   },
+  "liveLabor": {
+    "title": "{count, plural, one {Mechanik dodał pozycję robocizny do tego zlecenia} few {Mechanik dodał # pozycje robocizny do tego zlecenia} many {Mechanik dodał # pozycji robocizny do tego zlecenia} other {Mechanik dodał # pozycji robocizny do tego zlecenia}}",
+    "body": "Pozostaje poza listą podczas edycji. Następny zapis i tak ją uwzględni.",
+    "show": "Pokaż"
+  },
   "totals": {
     "title": "Razem",
     "parts": "Części",
@@ -198,7 +204,9 @@
     "aiOverwriteReplace": "Zastąp",
     "aiOverwriteAppend": "Dołącz",
     "aiOverwriteCancel": "Anuluj",
-    "aiDontShowAgain": "Nie pokazuj ponownie"
+    "aiDontShowAgain": "Nie pokazuj ponownie",
+    "internalTitle": "Notatki wewnętrzne",
+    "publicTitle": "Notatki dla klienta"
   },
   "invoice": {
     "title": "Podsumowanie Faktury",
@@ -248,7 +256,10 @@
     "failedDelete": "Nie udało się usunąć płatności",
     "deleteTitle": "Usuń Płatność",
     "deleteDescription": "Spowoduje to usunięcie tego rekordu płatności.",
-    "failedPdf": "Nie udało się wygenerować faktury PDF"
+    "failedPdf": "Nie udało się wygenerować faktury PDF",
+    "received": "Otrzymane płatności",
+    "noneYet": "Nic jeszcze nie zapłacono.",
+    "balanceDue": "Pozostało do zapłaty"
   },
   "attachments": {
     "deleteTitle": "Usuń Załącznik",
@@ -357,6 +368,7 @@
     "date": "Data",
     "startTime": "Czas rozpoczęcia",
     "endTime": "Czas zakończenia",
+    "durationPresets": "Czas trwania",
     "technician": "Technik",
     "selectTechnician": "Wybierz technika...",
     "searchOrCreate": "Szukaj lub utwórz technika...",
@@ -394,7 +406,17 @@
     "pickNextAvailable": "Wybierz najbliższy wolny termin",
     "findingSlot": "Szukanie terminu…",
     "slotPicked": "Zaplanowano na najbliższy wolny termin",
-    "noSlotFound": "Nie znaleziono wolnego terminu w najbliższych tygodniach"
+    "noSlotFound": "Nie znaleziono wolnego terminu w najbliższych tygodniach",
+    "assignedTitle": "Przypisany technik",
+    "boardLink": "Tablica zleceń",
+    "someoneElse": "Ktoś inny",
+    "loadOf": "{booked} / {capacity} h",
+    "loadFree": "Wolne: {hours} h",
+    "loadFull": "Pełne obłożenie",
+    "loadOn": "Zajęte godziny w dniu {date}",
+    "promisedAt": "Termin obiecany klientowi",
+    "promisedPlaceholder": "Jeszcze nie obiecano",
+    "promisedClear": "Wyczyść"
   },
   "notifications": {
     "title": "Powiadomienia klienta",
@@ -447,7 +469,60 @@
     "placeholder": "Przy hamowaniu słychać hałas z tyłu",
     "remove": "Usuń ten punkt",
     "notAnswered": "Nic jeszcze nie ustalono",
-    "answered": "Ustalenia: {count}"
+    "answered": "Ustalenia: {count}",
+    "storyTitle": "Opis pracy",
+    "storyHint": "Objaw, przyczyna, naprawa, weryfikacja. Kliknij krok, aby go otworzyć; jeden wiersz na każdą rzecz zgłoszoną przez klienta lub zauważoną przez ciebie.",
+    "steps": {
+      "condition": "Objaw",
+      "cause": "Przyczyna",
+      "correction": "Naprawa",
+      "confirm": "Weryfikacja"
+    },
+    "conditionPlaceholder": "Co zgłosił klient lub co zauważono",
+    "causePlaceholder": "Co według testów jest główną przyczyną",
+    "correctionPlaceholder": "Co zrobiono, aby to naprawić",
+    "confirmPlaceholder": "Jak sprawdzono naprawę: jazda próbna, ponowny odczyt błędów, objaw ustąpił",
+    "markConfirmed": "Zweryfikowane",
+    "confirmedBy": "Zweryfikowano: {name}, {date}",
+    "confirmedAt": "Zweryfikowano {date}",
+    "confirmedOnSave": "Przy zapisie zostanie oznaczone Twoim imieniem",
+    "confirmedCount": "Zweryfikowano {confirmed} z {total}",
+    "findingsCount": "{count, plural, one {# obserwacja} few {# obserwacje} many {# obserwacji} other {# obserwacji}}",
+    "addFinding": "Dodaj obserwację",
+    "unconfirmedTitle": "Zakończyć z niezweryfikowanymi zgłoszeniami?",
+    "unconfirmedBody": "{count, plural, one {Jedno zgłoszenie nie zostało zweryfikowane jako naprawione:} few {# zgłoszenia nie zostały zweryfikowane jako naprawione:} many {# zgłoszeń nie zostało zweryfikowanych jako naprawione:} other {# zgłoszenia nie zostało zweryfikowanych jako naprawione:}}",
+    "unconfirmedProceed": "Zakończ mimo to",
+    "addMedia": "Dodaj multimedia",
+    "mediaUploading": "Przesyłanie…",
+    "mediaAdded": "{count, plural, one {Dodano # plik} few {Dodano # pliki} many {Dodano # plików} other {Dodano # pliku}}",
+    "mediaFailed": "Nie udało się przesłać {name}",
+    "mediaTypes": "Wybierz zdjęcie (JPG, PNG, WebP) lub wideo.",
+    "dictate": {
+      "start": "Dyktuj",
+      "listening": "Słucham",
+      "startHint": "Mów, a tekst pojawi się tutaj. Nic nie jest nagrywane.",
+      "stopHint": "Przestań słuchać",
+      "notAllowed": "Przeglądarka nie dostała dostępu do mikrofonu. Zezwól na niego dla tej strony i spróbuj ponownie.",
+      "noMicrophone": "Nie znaleziono mikrofonu.",
+      "failed": "Dyktowanie zatrzymane. Spróbuj ponownie.",
+      "startHintServer": "Mów, a tekst zapisuje się na bieżąco. Dźwięk trafia do Twojego modelu mowy do zapisania i nie jest przechowywany.",
+      "transcribing": "Kończenie…",
+      "language": "Język mówiony",
+      "auto": "Auto",
+      "nothingHeard": "Niczego nie usłyszano.",
+      "fellBack": "Na razie używane jest dyktowanie wbudowane w przeglądarkę.",
+      "autoLong": "Auto (wykryj język)",
+      "engine": "Zapisuje",
+      "engineAi": "Model mowy (dokładniejszy)",
+      "engineBrowser": "Przeglądarka (pisze na bieżąco)",
+      "liveFellBack": "Tekst pojawia się co kilka sekund zamiast słowo po słowie."
+    },
+    "stepEmpty": "Jeszcze nie wpisano",
+    "removeTitle": "Usunąć to zgłoszenie?",
+    "removeBody": "Jego objaw, przyczyna, naprawa i weryfikacja zostaną usunięte razem z nim przy zapisie zlecenia.",
+    "removeAttached": "Jego obserwacje i multimedia zostają w zleceniu, ale nie są już przypisane do zgłoszenia.",
+    "removeConfirm": "Usuń zgłoszenie",
+    "saveFirst": "Najpierw zapisz zlecenie"
   },
   "videoCall": {
     "title": "Rozmowa wideo",
@@ -493,5 +568,127 @@
       "joinLine": "Dołącz do rozmowy wideo tutaj: {url}",
       "emailSubject": "Rozmowa wideo dotycząca Twojego {vehicle}"
     }
+  },
+  "modern": {
+    "statusLabel": "Status zlecenia",
+    "back": "Wstecz",
+    "backToClassic": "Wróć do klasycznego układu",
+    "openCustomer": "Otwórz klienta",
+    "serviceHistory": "Historia serwisowa",
+    "changeVehicle": "Zmień pojazd",
+    "vehicleChangesOnSave": "Po zapisaniu zlecenie zostanie przeniesione na ten pojazd.",
+    "phone": "Telefon",
+    "email": "E-mail",
+    "notSet": "Nie ustawiono",
+    "noCustomer": "To zlecenie nie ma klienta.",
+    "invoiceTitle": "Faktura i płatność",
+    "filesTitle": "Pliki i multimedia",
+    "activity": {
+      "title": "Aktywność",
+      "messageSent": "Wysłano wiadomość do klienta",
+      "linkShared": "Udostępniono link do faktury",
+      "linkViewed": "{count, plural, =1 {Klient otworzył link} one {Klient otworzył link # raz} few {Klient otworzył link # razy} many {Klient otworzył link # razy} other {Klient otworzył link # razy}}",
+      "paymentTaken": "Zarejestrowano płatność {amount}"
+    },
+    "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}",
+    "openedBy": "Otwarto {date}, przez: {name}",
+    "editTitle": "Edytuj tytuł",
+    "titleField": "Tytuł",
+    "media": {
+      "tabs": {
+        "photos": "Zdjęcia",
+        "diagnostics": "Diagnostyka"
+      },
+      "summary": "{count, plural, one {# plik} few {# pliki} many {# plików} other {# pliku}} · widoczne dla klienta tylko po oznaczeniu",
+      "customerCanSee": "Widoczne dla klienta",
+      "showToCustomer": "Pokaż klientowi",
+      "hideFromCustomer": "Ukryj przed klientem",
+      "add": {
+        "image": "Dodaj zdjęcia",
+        "document": "Dodaj dokumenty",
+        "diagnostic": "Dodaj raport diagnostyczny",
+        "video": "Dodaj wideo"
+      },
+      "dropHint": "Upuść pliki lub kliknij, aby wybrać",
+      "added": "{count, plural, one {Dodano # plik} few {Dodano # pliki} many {Dodano # plików} other {Dodano # pliku}}",
+      "failed": "Nie udało się przesłać {name}",
+      "wrongType": "Nieodpowiedni rodzaj pliku dla tej karty: {names}",
+      "limit": "Osiągnięto limit ({count}/{max})"
+    },
+    "moneyBar": {
+      "total": "Razem",
+      "paid": "Zapłacono",
+      "balance": "Pozostało do zapłaty",
+      "settled": "Zapłacono w całości",
+      "takePayment": "Przyjmij płatność",
+      "preview": "Podgląd faktury",
+      "previewShort": "Podgląd",
+      "send": "Wyślij klientowi",
+      "sendShort": "Wyślij"
+    },
+    "vehicleInfo": {
+      "more": "Więcej informacji",
+      "less": "Mniej informacji",
+      "copy": "Kopiuj: {label}",
+      "copied": "Skopiowano",
+      "lastInspection": "Ostatni przegląd okresowy",
+      "purchaseDate": "Data zakupu",
+      "purchasePrice": "Cena zakupu",
+      "overdue": "po terminie",
+      "customerSince": "Klient od"
+    }
+  },
+  "photoHandoff": {
+    "button": "Dodaj z telefonu",
+    "dialogTitle": "Dodaj zdjęcia i dokumenty z telefonu",
+    "dialogBody": "Zrób lub wybierz zdjęcia albo udostępnij PDF na dowolnym telefonie. Wszystko trafia prosto do tego zlecenia.",
+    "expires": "Kod działa do {time}.",
+    "waiting": "Czekam na zdjęcia i dokumenty…",
+    "received": "{count, plural, one {Odebrano # plik} few {Odebrano # pliki} many {Odebrano # plików} other {Odebrano # pliku}}",
+    "copyLink": "Kopiuj link",
+    "linkCopied": "Link skopiowany",
+    "failed": "Nie udało się utworzyć kodu. Spróbuj ponownie.",
+    "done": "Gotowe",
+    "pageTitle": "Dodaj zdjęcia i dokumenty",
+    "forJob": "Zlecenie {number}",
+    "takePhoto": "Zrób zdjęcie",
+    "choosePhotos": "Wybierz zdjęcia",
+    "hint": "Wszystko trafia prosto do zlecenia. Zrób tyle zdjęć, ile potrzebujesz.",
+    "uploading": "Przesyłanie…",
+    "added": "Dodano",
+    "notAdded": "Nie dodano",
+    "retry": "Spróbuj ponownie",
+    "addedCount": "{count, plural, one {Dodano # plik} few {Dodano # pliki} many {Dodano # plików} other {Dodano # pliku}}",
+    "expiredTitle": "Ten kod wygasł",
+    "invalidTitle": "Ten link nie działa",
+    "errors": {
+      "expired": "Poproś o nowy kod w recepcji. Każdy kod działa przez 30 minut.",
+      "invalid": "Zeskanuj ponownie kod na zleceniu.",
+      "limit": "To zlecenie ma już tyle zdjęć, ile może pomieścić.",
+      "type": "Tutaj można dodawać tylko zdjęcia i dokumenty PDF.",
+      "tooLarge": "Ten plik jest za duży.",
+      "failed": "Nie dodano zdjęcia. Sprawdź połączenie i spróbuj ponownie.",
+      "codeLimit": "Tym kodem dodano już tyle zdjęć, ile pozwala jeden kod. Zeskanuj nowy kod w recepcji."
+    },
+    "concernHeading": "Zdjęcia trafią do tego zgłoszenia",
+    "howTitle": "Jak to działa",
+    "step1": "Skieruj aparat telefonu na kod i otwórz link.",
+    "step2": "Zrób zdjęcia albo wybierz zdjęcia lub dokumenty PDF, które są już w telefonie.",
+    "step3": "Pojawiają się w tym zleceniu, gdy tylko dotrą.",
+    "noSignIn": "Na telefonie nie trzeba się logować.",
+    "customerToo": "Mogą z tego korzystać pracownicy, a także klient: pozwól mu zeskanować kod albo wyślij mu link.",
+    "scope": "Link tylko dodaje zdjęcia i dokumenty do tego zlecenia i przestaje działać po 30 minutach.",
+    "scanHint": "Zeskanuj aparatem telefonu",
+    "chooseDocument": "Prześlij dokument",
+    "privacy": "To, co tu dodasz, widzi tylko warsztat."
   }
 }

+ 23 - 1
messages/pl/settings.json

@@ -1229,7 +1229,29 @@
     "dateLabel": "Data",
     "dateTimeLabel": "Data i czas",
     "saveSettings": "Zapisz ustawienia",
-    "saved": "Ustawienia zapisane"
+    "saved": "Ustawienia zapisane",
+    "fontLabel": "Czcionka",
+    "fontHint": "Wybierz kroje pisma używane w aplikacji. Wybór jest zapisywany na tym urządzeniu.",
+    "fontSampleHeading": "Zlecenie",
+    "fontSampleText": "Wymiana cewki zapłonowej i świec zapłonowych",
+    "fonts": {
+      "default": {
+        "name": "Domyślna",
+        "description": "Geist, czysta i neutralna."
+      },
+      "workshop": {
+        "name": "Warsztatowa",
+        "description": "IBM Plex z wąskimi wersalikami w nagłówkach, jak na karcie zlecenia."
+      },
+      "precise": {
+        "name": "Precyzyjna",
+        "description": "Inter i JetBrains Mono. Stworzona dla ekranów pełnych tabel i liczb."
+      },
+      "legible": {
+        "name": "Czytelna",
+        "description": "Atkinson Hyperlegible. 0 i O, 1 i l nigdy nie wyglądają podobnie; łatwo czytać z drugiego końca warsztatu."
+      }
+    }
   },
   "data": {
     "title": "Zarządzanie danymi",

+ 8 - 2
messages/pl/workBoard.json

@@ -78,7 +78,10 @@
     "addTechnician": "Dodaj Technika",
     "standaloneOnly": "Nazwisko na tablicy dla kogoś, kto się nie loguje, na przykład ucznia lub podwykonawcy. Mechanika korzystającego z aplikacji dodasz na stronie Zespół.",
     "linkedTo": "Loguje się jako {name}",
-    "linkedManagedOnTeam": "Zarządzane na stronie Zespół, razem z dostępem do aplikacji."
+    "linkedManagedOnTeam": "Zarządzane na stronie Zespół, razem z dostępem do aplikacji.",
+    "skills": "Umiejętności",
+    "skillsPlaceholder": "Diagnostyka, certyfikat EV",
+    "skillsHint": "Wyświetlane pod nazwiskiem tam, gdzie przydziela się pracę. Opcjonalne."
   },
   "jobDetail": {
     "viewDetails": "Wyświetl Szczegóły",
@@ -154,6 +157,9 @@
     "workBay": "Stanowisko",
     "status": "Status",
     "noTime": "Brak godziny",
-    "none": "Jeszcze nikt"
+    "none": "Jeszcze nikt",
+    "promised": "Obiecane",
+    "promiseLate": "Po terminie",
+    "promiseOverdue": "Obiecane klientowi na termin, który już minął"
   }
 }

+ 7 - 1
messages/pl/workOrders.json

@@ -31,7 +31,9 @@
       "status": "Status",
       "tech": "Technik",
       "date": "Data",
-      "total": "Razem"
+      "total": "Razem",
+      "promised": "Obiecane",
+      "promisedOverdue": "Obiecane {date}, po terminie"
     },
     "emailSubject": "Aktualizacja Statusu Pojazdu: {status}",
     "changeStatus": "Zmień status",
@@ -39,6 +41,10 @@
       "open": "Otwórz zlecenie",
       "openVehicle": "Otwórz pojazd",
       "openCustomer": "Otwórz klienta"
+    },
+    "dueToday": "Na dziś",
+    "columns": {
+      "label": "Kolumny"
     }
   },
   "vehiclePicker": {

+ 21 - 2
messages/pt-BR/integrations.json

@@ -60,7 +60,8 @@
       "safety": "Recalls e reclamações de proprietários"
     },
     "ai": {
-      "chat": "Textos e resumos"
+      "chat": "Textos e resumos",
+      "transcribe": "Ditado"
     },
     "payments": {
       "checkout": "Pagar pelo link da fatura"
@@ -156,7 +157,8 @@
       "syncNowHelp": "Sincronizar agora inicia uma passagem imediatamente em vez de esperar a de hora em hora. Consulta 25 veículos por vez e continua sozinha até terminar todos os veículos pendentes."
     },
     "supersedes": "Conectar a {next} desconecta a {current}. Só uma pode estar ativa por vez, então {current} deixa de ser usada assim que {next} entrar no ar.",
-    "batchRetried": "{count, plural, =0 {Nada para tentar de novo} one {# tarefa de volta à fila} other {# tarefas de volta à fila}}"
+    "batchRetried": "{count, plural, =0 {Nada para tentar de novo} one {# tarefa de volta à fila} other {# tarefas de volta à fila}}",
+    "advanced": "Avançado"
   },
   "activity": {
     "title": "Atividade",
@@ -578,6 +580,23 @@
         "refreshFleet": "Manter os dados de recalls e reclamações atualizados para todos os veículos desta oficina",
         "refreshFleetHelp": "Uma vez por dia, os modelos não atualizados na última semana são buscados de novo, para que a página do veículo nunca espere e um novo recall apareça em até uma semana. Só marca, modelo e ano são enviados."
       }
+    },
+    "speech-to-text": {
+      "description": "Ditado na ordem de serviço, transcrito por um modelo de voz em vez do navegador: muito mais preciso, em qualquer idioma, e conhece o vocabulário de oficina. É separado do seu provedor de IA, então você pode manter esse como está. Usa a sua própria chave da OpenAI ou um servidor próprio que fale a API de transcrição da OpenAI. A gravação é enviada para ser transcrita e não é guardada.",
+      "fields": {
+        "apiKey": "Chave de API",
+        "apiKeyHelp": "Uma chave da OpenAI, criada em platform.openai.com, em API keys; a OpenAI cobra o uso por minuto. Para um servidor próprio, só se ele pedir uma.",
+        "baseUrl": "Endereço do servidor",
+        "baseUrlHelp": "Deixe vazio para usar a OpenAI. Para um servidor próprio (um Whisper local, LocalAI, Groq), informe onde começa a API no estilo da OpenAI, incluindo o caminho, por exemplo /v1. O Torqvoice acrescenta /audio/transcriptions a esse endereço."
+      },
+      "settings": {
+        "model": "Modelo",
+        "modelHelp": "O modelo de voz a ser usado. gpt-4o-mini-transcribe é preciso e barato; gpt-4o-transcribe é o maior. Um servidor próprio geralmente responde a whisper-1.",
+        "mode": "Quem escolhe como o ditado é transcrito",
+        "modeHelp": "O navegador escreve enquanto você fala, mas é mais fraco, principalmente em idiomas menos falados. O modelo de voz escreve quando você para.",
+        "modeAi": "Sempre o modelo de voz",
+        "modeChoice": "Cada pessoa escolhe: modelo de voz ou navegador"
+      }
     }
   },
   "jobKinds": {

+ 202 - 5
messages/pt-BR/service.json

@@ -21,7 +21,8 @@
       "title": "A ordem precisa de um título antes de ser salva.",
       "negative": "Quantidades, horas, preços e custos não podem ser negativos.",
       "partName": "Toda peça com preço precisa de um nome.",
-      "laborDescription": "Toda linha de mão de obra com horas ou taxa precisa de uma descrição."
+      "laborDescription": "Toda linha de mão de obra com horas ou taxa precisa de uma descrição.",
+      "concernCondition": "Um relato com causa, correção ou confirmação precisa ter a condição preenchida."
     }
   },
   "header": {
@@ -164,6 +165,11 @@
     "deleteRow": "Excluir linha",
     "descriptionMissingHint": "Linhas sem descrição não são salvas."
   },
+  "liveLabor": {
+    "title": "{count, plural, one {Um técnico adicionou mão de obra a esta ordem} other {Um técnico adicionou # linhas de mão de obra a esta ordem}}",
+    "body": "Ela fica fora da lista enquanto você edita. O próximo salvamento a inclui de qualquer forma.",
+    "show": "Mostrar"
+  },
   "totals": {
     "title": "Totais",
     "parts": "Peças",
@@ -198,7 +204,9 @@
     "aiOverwriteReplace": "Substituir",
     "aiOverwriteAppend": "Adicionar",
     "aiOverwriteCancel": "Cancelar",
-    "aiDontShowAgain": "Não mostrar novamente"
+    "aiDontShowAgain": "Não mostrar novamente",
+    "internalTitle": "Notas internas",
+    "publicTitle": "Notas para o cliente"
   },
   "invoice": {
     "title": "Resumo da Fatura",
@@ -248,7 +256,10 @@
     "failedDelete": "Falha ao deletar pagamento",
     "deleteTitle": "Deletar Pagamento",
     "deleteDescription": "Isto vai remover este registro de pagamento.",
-    "failedPdf": "Falha ao gerar PDF da fatura"
+    "failedPdf": "Falha ao gerar PDF da fatura",
+    "received": "Pagamentos recebidos",
+    "noneYet": "Nada pago ainda.",
+    "balanceDue": "Saldo devedor"
   },
   "attachments": {
     "deleteTitle": "Deletar Anexo",
@@ -357,6 +368,7 @@
     "date": "Data",
     "startTime": "Hora de início",
     "endTime": "Hora de término",
+    "durationPresets": "Duração",
     "technician": "Técnico",
     "selectTechnician": "Selecionar técnico...",
     "searchOrCreate": "Buscar ou criar técnico...",
@@ -394,7 +406,17 @@
     "pickNextAvailable": "Escolher o próximo horário livre",
     "findingSlot": "Procurando um horário…",
     "slotPicked": "Agendado no próximo horário livre",
-    "noSlotFound": "Nenhum horário livre encontrado nas próximas semanas"
+    "noSlotFound": "Nenhum horário livre encontrado nas próximas semanas",
+    "assignedTitle": "Técnico responsável",
+    "boardLink": "Quadro de Trabalho",
+    "someoneElse": "Outra pessoa",
+    "loadOf": "{booked} / {capacity} h",
+    "loadFree": "{hours} h livres",
+    "loadFull": "Agenda cheia",
+    "loadOn": "Horas agendadas em {date}",
+    "promisedAt": "Prometido ao cliente",
+    "promisedPlaceholder": "Ainda não prometido",
+    "promisedClear": "Limpar"
   },
   "notifications": {
     "title": "Notificações ao cliente",
@@ -447,7 +469,60 @@
     "placeholder": "Faz um barulho atrás quando eu freio",
     "remove": "Remover este item",
     "notAnswered": "Nada encontrado sobre isso ainda",
-    "answered": "{count} constatação(ões) sobre isso"
+    "answered": "{count} constatação(ões) sobre isso",
+    "storyTitle": "Descrição do serviço",
+    "storyHint": "Condição, causa, correção, confirmação. Clique em uma etapa para abri-la; uma linha para cada coisa que o cliente relatou ou que você observou.",
+    "steps": {
+      "condition": "Condição",
+      "cause": "Causa",
+      "correction": "Correção",
+      "confirm": "Confirmação"
+    },
+    "conditionPlaceholder": "O que o cliente relatou, ou o que você observou",
+    "causePlaceholder": "O que os testes mostraram ser a causa raiz",
+    "correctionPlaceholder": "O que foi feito para resolver",
+    "confirmPlaceholder": "Como o reparo foi verificado: test drive, nova leitura com o scanner, sintoma resolvido",
+    "markConfirmed": "Confirmado",
+    "confirmedBy": "Confirmado por {name}, {date}",
+    "confirmedAt": "Confirmado em {date}",
+    "confirmedOnSave": "Marcado com o seu nome ao salvar",
+    "confirmedCount": "{confirmed} de {total} confirmados",
+    "findingsCount": "{count, plural, one {# observação} other {# observações}}",
+    "addFinding": "Adicionar observação",
+    "unconfirmedTitle": "Concluir com relatos não confirmados?",
+    "unconfirmedBody": "{count, plural, one {Um relato ainda não foi confirmado como resolvido:} other {# relatos ainda não foram confirmados como resolvidos:}}",
+    "unconfirmedProceed": "Concluir mesmo assim",
+    "addMedia": "Adicionar mídia",
+    "mediaUploading": "Enviando…",
+    "mediaAdded": "{count, plural, one {# arquivo adicionado} other {# arquivos adicionados}}",
+    "mediaFailed": "Não foi possível enviar {name}",
+    "mediaTypes": "Escolha uma foto (JPG, PNG, WebP) ou um vídeo.",
+    "dictate": {
+      "start": "Ditar",
+      "listening": "Ouvindo",
+      "startHint": "Fale e o texto aparece aqui. Nada é gravado.",
+      "stopHint": "Parar de ouvir",
+      "notAllowed": "O navegador não teve permissão para usar o microfone. Permita o acesso para este site e tente novamente.",
+      "noMicrophone": "Nenhum microfone encontrado.",
+      "failed": "O ditado parou. Tente novamente.",
+      "startHintServer": "Fale e o texto aparece enquanto você fala. O áudio vai para o seu modelo de voz para ser transcrito e não é guardado.",
+      "transcribing": "Finalizando…",
+      "language": "Idioma falado",
+      "auto": "Automático",
+      "nothingHeard": "Nada foi ouvido.",
+      "fellBack": "Usando o ditado do próprio navegador por enquanto.",
+      "autoLong": "Automático (detectar o idioma)",
+      "engine": "Transcrito por",
+      "engineAi": "Modelo de voz (mais preciso)",
+      "engineBrowser": "Navegador (escreve enquanto você fala)",
+      "liveFellBack": "Escrevendo em blocos de alguns segundos em vez de palavra por palavra."
+    },
+    "stepEmpty": "Ainda não preenchido",
+    "removeTitle": "Remover este relato?",
+    "removeBody": "A condição, a causa, a correção e a confirmação dele são removidas junto quando você salvar a ordem de serviço.",
+    "removeAttached": "As observações e mídias dele continuam no serviço, mas deixam de ficar vinculadas a um relato.",
+    "removeConfirm": "Remover relato",
+    "saveFirst": "Salve a ordem de serviço primeiro"
   },
   "videoCall": {
     "title": "Videochamada",
@@ -493,5 +568,127 @@
       "joinLine": "Entre na videochamada aqui: {url}",
       "emailSubject": "Videochamada sobre o seu {vehicle}"
     }
+  },
+  "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",
+    "vehicleChangesOnSave": "A ordem de serviço passa para este veículo quando você salvar.",
+    "phone": "Telefone",
+    "email": "E-mail",
+    "notSet": "Não definido",
+    "noCustomer": "Nenhum cliente nesta ordem de serviço.",
+    "invoiceTitle": "Fatura e pagamento",
+    "filesTitle": "Arquivos e mídia",
+    "activity": {
+      "title": "Atividade",
+      "messageSent": "Mensagem enviada ao cliente",
+      "linkShared": "Link da fatura compartilhado",
+      "linkViewed": "{count, plural, one {O cliente abriu o link} other {O cliente abriu o link # vezes}}",
+      "paymentTaken": "Pagamento de {amount} registrado"
+    },
+    "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}",
+    "openedBy": "Aberta em {date} por {name}",
+    "editTitle": "Editar título",
+    "titleField": "Título",
+    "media": {
+      "tabs": {
+        "photos": "Fotos",
+        "diagnostics": "Diagnóstico"
+      },
+      "summary": "{count, plural, one {# arquivo} other {# arquivos}} · o cliente só vê o que estiver marcado",
+      "customerCanSee": "Visível para o cliente",
+      "showToCustomer": "Mostrar ao cliente",
+      "hideFromCustomer": "Ocultar do cliente",
+      "add": {
+        "image": "Adicionar fotos",
+        "document": "Adicionar documentos",
+        "diagnostic": "Adicionar relatório de diagnóstico",
+        "video": "Adicionar vídeo"
+      },
+      "dropHint": "Arraste arquivos aqui ou clique para procurar",
+      "added": "{count, plural, one {# arquivo adicionado} other {# arquivos adicionados}}",
+      "failed": "Não foi possível enviar {name}",
+      "wrongType": "Tipo de arquivo errado para esta aba: {names}",
+      "limit": "Limite atingido ({count}/{max})"
+    },
+    "moneyBar": {
+      "total": "Total",
+      "paid": "Pago",
+      "balance": "Saldo devedor",
+      "settled": "Pago integralmente",
+      "takePayment": "Receber pagamento",
+      "preview": "Pré-visualizar fatura",
+      "previewShort": "Pré-visualizar",
+      "send": "Enviar ao cliente",
+      "sendShort": "Enviar"
+    },
+    "vehicleInfo": {
+      "more": "Mais informações",
+      "less": "Menos informações",
+      "copy": "Copiar {label}",
+      "copied": "Copiado",
+      "lastInspection": "Última inspeção periódica",
+      "purchaseDate": "Data de compra",
+      "purchasePrice": "Preço de compra",
+      "overdue": "vencida",
+      "customerSince": "Cliente desde"
+    }
+  },
+  "photoHandoff": {
+    "button": "Adicionar pelo celular",
+    "dialogTitle": "Adicionar fotos e documentos pelo celular",
+    "dialogBody": "Tire ou escolha fotos, ou compartilhe um PDF, em qualquer celular. Tudo vai direto para esta ordem de serviço.",
+    "expires": "O código vale até as {time}.",
+    "waiting": "Aguardando fotos e documentos…",
+    "received": "{count, plural, one {# arquivo recebido} other {# arquivos recebidos}}",
+    "copyLink": "Copiar link",
+    "linkCopied": "Link copiado",
+    "failed": "Não foi possível gerar o código. Tente novamente.",
+    "done": "Concluído",
+    "pageTitle": "Adicionar fotos e documentos",
+    "forJob": "Ordem de serviço {number}",
+    "takePhoto": "Tirar foto",
+    "choosePhotos": "Escolher fotos",
+    "hint": "Tudo vai direto para a ordem de serviço. Tire quantas fotos precisar.",
+    "uploading": "Enviando…",
+    "added": "Adicionada",
+    "notAdded": "Não adicionada",
+    "retry": "Tentar novamente",
+    "addedCount": "{count, plural, one {# arquivo adicionado} other {# arquivos adicionados}}",
+    "expiredTitle": "Este código expirou",
+    "invalidTitle": "Este link não funciona",
+    "errors": {
+      "expired": "Peça um novo código no balcão. Cada código vale por 30 minutos.",
+      "invalid": "Escaneie de novo o código na ordem de serviço.",
+      "limit": "Esta ordem de serviço já tem todas as fotos que comporta.",
+      "type": "Aqui só é possível adicionar fotos e documentos PDF.",
+      "tooLarge": "Esse arquivo é grande demais.",
+      "failed": "A foto não foi adicionada. Verifique a conexão e tente novamente.",
+      "codeLimit": "Este código já adicionou todas as fotos que um código permite. Escaneie um novo código no balcão."
+    },
+    "concernHeading": "As fotos vão para este relato",
+    "howTitle": "Como funciona",
+    "step1": "Aponte a câmera do celular para o código e abra o link.",
+    "step2": "Tire fotos, ou escolha fotos ou documentos PDF que já estão no celular.",
+    "step3": "Elas aparecem nesta ordem de serviço assim que chegam.",
+    "noSignIn": "Não é preciso entrar na conta no celular.",
+    "customerToo": "A equipe pode usar, e o cliente também: deixe-o escanear o código ou envie o link para ele.",
+    "scope": "O link só adiciona fotos e documentos a esta ordem de serviço e para de funcionar após 30 minutos.",
+    "scanHint": "Escaneie com a câmera do celular",
+    "chooseDocument": "Enviar um documento",
+    "privacy": "Só a oficina vê o que você adiciona aqui."
   }
 }

+ 23 - 1
messages/pt-BR/settings.json

@@ -1229,7 +1229,29 @@
     "dateLabel": "Data",
     "dateTimeLabel": "Data e hora",
     "saveSettings": "Salvar configurações",
-    "saved": "Configurações salvas"
+    "saved": "Configurações salvas",
+    "fontLabel": "Fonte",
+    "fontHint": "Escolha as fontes usadas no aplicativo. A escolha é salva neste dispositivo.",
+    "fontSampleHeading": "Ordem de serviço",
+    "fontSampleText": "Trocar bobina de ignição e velas",
+    "fonts": {
+      "default": {
+        "name": "Padrão",
+        "description": "Geist, limpa e neutra."
+      },
+      "workshop": {
+        "name": "Oficina",
+        "description": "IBM Plex com títulos em maiúsculas condensadas, como uma ficha de serviço."
+      },
+      "precise": {
+        "name": "Precisa",
+        "description": "Inter e JetBrains Mono. Feita para telas cheias de tabelas e números."
+      },
+      "legible": {
+        "name": "Legível",
+        "description": "Atkinson Hyperlegible. 0 e O, 1 e l nunca se confundem; fácil de ler do outro lado da oficina."
+      }
+    }
   },
   "data": {
     "title": "Gerenciamento de dados",

+ 8 - 2
messages/pt-BR/workBoard.json

@@ -78,7 +78,10 @@
     "addTechnician": "Adicionar Técnico",
     "standaloneOnly": "Um nome no quadro para alguém que não faz login, como um aprendiz ou terceirizado. Para um mecânico que usa o aplicativo, adicione na página Equipe.",
     "linkedTo": "Entra como {name}",
-    "linkedManagedOnTeam": "Gerenciado na página Equipe, junto com o acesso ao aplicativo."
+    "linkedManagedOnTeam": "Gerenciado na página Equipe, junto com o acesso ao aplicativo.",
+    "skills": "Habilidades",
+    "skillsPlaceholder": "Diagnóstico, certificado em veículos elétricos",
+    "skillsHint": "Aparece abaixo do nome onde o trabalho é distribuído. Opcional."
   },
   "jobDetail": {
     "viewDetails": "Ver Detalhes",
@@ -154,6 +157,9 @@
     "workBay": "Baia",
     "status": "Status",
     "noTime": "Ainda sem horário",
-    "none": "Ainda ninguém"
+    "none": "Ainda ninguém",
+    "promised": "Prometido",
+    "promiseLate": "Atrasado",
+    "promiseOverdue": "Prometido ao cliente para um momento que já passou"
   }
 }

+ 7 - 1
messages/pt-BR/workOrders.json

@@ -31,7 +31,9 @@
       "status": "Status",
       "tech": "Técnico",
       "date": "Data",
-      "total": "Total"
+      "total": "Total",
+      "promised": "Prometido",
+      "promisedOverdue": "Prometido {date}, atrasado"
     },
     "emailSubject": "Atualização de Status do Veículo: {status}",
     "changeStatus": "Alterar status",
@@ -39,6 +41,10 @@
       "open": "Abrir ordem de serviço",
       "openVehicle": "Abrir veículo",
       "openCustomer": "Abrir cliente"
+    },
+    "dueToday": "Para hoje",
+    "columns": {
+      "label": "Colunas"
     }
   },
   "vehiclePicker": {

+ 21 - 2
messages/pt-PT/integrations.json

@@ -60,7 +60,8 @@
       "safety": "Recalls e reclamações de proprietários"
     },
     "ai": {
-      "chat": "Textos e resumos"
+      "chat": "Textos e resumos",
+      "transcribe": "Ditado"
     },
     "payments": {
       "checkout": "Pagamento pelo link da fatura"
@@ -156,7 +157,8 @@
       "syncNowHelp": "Sincronizar agora inicia uma passagem de imediato, em vez de esperar pela passagem horária. Consulta 25 viaturas de cada vez e continua sozinha até terminar todas as viaturas pendentes."
     },
     "supersedes": "Ligar {next} desliga {current}. Só pode estar uma ativa de cada vez, por isso {current} deixa de ser utilizada assim que {next} entrar em funcionamento.",
-    "batchRetried": "{count, plural, =0 {Nada para tentar novamente} one {# tarefa de volta à fila} other {# tarefas de volta à fila}}"
+    "batchRetried": "{count, plural, =0 {Nada para tentar novamente} one {# tarefa de volta à fila} other {# tarefas de volta à fila}}",
+    "advanced": "Avançado"
   },
   "activity": {
     "title": "Atividade",
@@ -578,6 +580,23 @@
         "refreshFleet": "Manter atualizados os dados de recalls e reclamações de todas as viaturas desta oficina",
         "refreshFleetHelp": "Uma vez por dia, os modelos não atualizados na última semana são obtidos de novo, para que a página da viatura nunca tenha de esperar e um novo recall apareça no prazo de uma semana. Só são enviados a marca, o modelo e o ano."
       }
+    },
+    "speech-to-text": {
+      "description": "Ditado na ordem de serviço, transcrito por um modelo de voz em vez do navegador: muito mais preciso, em qualquer idioma, e conhece o vocabulário de oficina. É independente do seu fornecedor de IA, por isso pode manter esse como está. Usa a sua própria chave da OpenAI ou um servidor seu que fale a API de transcrição da OpenAI. A gravação é enviada para ser transcrita e não é guardada.",
+      "fields": {
+        "apiKey": "Chave de API",
+        "apiKeyHelp": "Uma chave da OpenAI, criada em platform.openai.com, em API keys; a OpenAI cobra a utilização ao minuto. Para um servidor seu, só se o servidor a pedir.",
+        "baseUrl": "Endereço do servidor",
+        "baseUrlHelp": "Deixe vazio para usar a OpenAI. Para um servidor seu (um Whisper local, LocalAI, Groq), indique onde começa a API ao estilo da OpenAI, incluindo o caminho, por exemplo /v1. O Torqvoice acrescenta-lhe /audio/transcriptions."
+      },
+      "settings": {
+        "model": "Modelo",
+        "modelHelp": "O modelo de voz a pedir. gpt-4o-mini-transcribe é preciso e barato; gpt-4o-transcribe é o maior. Um servidor seu normalmente responde a whisper-1.",
+        "mode": "Quem escolhe como o ditado é transcrito",
+        "modeHelp": "O navegador escreve enquanto fala, mas é mais fraco, sobretudo em idiomas menos falados. O modelo de voz escreve quando para.",
+        "modeAi": "Sempre o modelo de voz",
+        "modeChoice": "Cada pessoa escolhe: modelo de voz ou navegador"
+      }
     }
   },
   "jobKinds": {

+ 202 - 5
messages/pt-PT/service.json

@@ -21,7 +21,8 @@
       "title": "A ordem de serviço precisa de um título antes de ser guardada.",
       "negative": "Quantidades, horas, preços e custos não podem ser negativos.",
       "partName": "Todas as peças com preço precisam de um nome.",
-      "laborDescription": "Todas as linhas de mão de obra com horas ou taxa precisam de uma descrição."
+      "laborDescription": "Todas as linhas de mão de obra com horas ou taxa precisam de uma descrição.",
+      "concernCondition": "Uma queixa com causa, correção ou confirmação tem de ter a condição preenchida."
     }
   },
   "header": {
@@ -164,6 +165,11 @@
     "deleteRow": "Eliminar linha",
     "descriptionMissingHint": "As linhas sem descrição não são guardadas."
   },
+  "liveLabor": {
+    "title": "{count, plural, one {Um técnico adicionou mão de obra a esta folha de obra} other {Um técnico adicionou # linhas de mão de obra a esta folha de obra}}",
+    "body": "Fica fora da lista enquanto edita. A próxima gravação inclui-a de qualquer forma.",
+    "show": "Mostrar"
+  },
   "totals": {
     "title": "Totais",
     "parts": "Peças",
@@ -198,7 +204,9 @@
     "aiOverwriteReplace": "Substituir",
     "aiOverwriteAppend": "Acrescentar",
     "aiOverwriteCancel": "Cancelar",
-    "aiDontShowAgain": "Não mostrar novamente"
+    "aiDontShowAgain": "Não mostrar novamente",
+    "internalTitle": "Notas internas",
+    "publicTitle": "Notas para o cliente"
   },
   "invoice": {
     "title": "Resumo da fatura",
@@ -248,7 +256,10 @@
     "failedDelete": "Não foi possível eliminar o pagamento",
     "deleteTitle": "Eliminar pagamento",
     "deleteDescription": "Isto remove o registo deste pagamento.",
-    "failedPdf": "Não foi possível gerar o PDF da fatura"
+    "failedPdf": "Não foi possível gerar o PDF da fatura",
+    "received": "Pagamentos recebidos",
+    "noneYet": "Ainda nada pago.",
+    "balanceDue": "Valor em dívida"
   },
   "attachments": {
     "deleteTitle": "Eliminar anexo",
@@ -357,6 +368,7 @@
     "date": "Data",
     "startTime": "Hora de início",
     "endTime": "Hora de fim",
+    "durationPresets": "Duração",
     "technician": "Técnico",
     "selectTechnician": "Selecionar técnico...",
     "searchOrCreate": "Pesquisar ou criar técnico...",
@@ -394,7 +406,17 @@
     "pickNextAvailable": "Escolher o próximo horário livre",
     "findingSlot": "A procurar um horário…",
     "slotPicked": "Marcado no próximo horário livre",
-    "noSlotFound": "Nenhum horário livre encontrado nas próximas semanas"
+    "noSlotFound": "Nenhum horário livre encontrado nas próximas semanas",
+    "assignedTitle": "Técnico atribuído",
+    "boardLink": "Quadro de trabalho",
+    "someoneElse": "Outra pessoa",
+    "loadOf": "{booked} / {capacity} h",
+    "loadFree": "{hours} h livres",
+    "loadFull": "Sem horas livres",
+    "loadOn": "Horas marcadas a {date}",
+    "promisedAt": "Prometido ao cliente",
+    "promisedPlaceholder": "Ainda não prometido",
+    "promisedClear": "Limpar"
   },
   "notifications": {
     "title": "Notificações ao cliente",
@@ -447,7 +469,60 @@
     "placeholder": "Faz um barulho atrás quando travo",
     "remove": "Remover esta queixa",
     "notAnswered": "Ainda nada encontrado sobre isto",
-    "answered": "{count} observação(ões) sobre isto"
+    "answered": "{count} observação(ões) sobre isto",
+    "storyTitle": "Descrição do trabalho",
+    "storyHint": "Condição, causa, correção, confirmação. Clique num passo para o abrir; uma linha por cada coisa que o cliente referiu ou que observou.",
+    "steps": {
+      "condition": "Condição",
+      "cause": "Causa",
+      "correction": "Correção",
+      "confirm": "Confirmação"
+    },
+    "conditionPlaceholder": "O que o cliente referiu ou o que observou",
+    "causePlaceholder": "O que os testes mostraram ser a causa de origem",
+    "correctionPlaceholder": "O que foi feito para o resolver",
+    "confirmPlaceholder": "Como a reparação foi verificada: ensaio em estrada, nova leitura de diagnóstico, sintoma desaparecido",
+    "markConfirmed": "Confirmada",
+    "confirmedBy": "Confirmada por {name}, {date}",
+    "confirmedAt": "Confirmada a {date}",
+    "confirmedOnSave": "Fica com o seu nome quando guardar",
+    "confirmedCount": "{confirmed} de {total} confirmadas",
+    "findingsCount": "{count, plural, one {# observação} other {# observações}}",
+    "addFinding": "Adicionar observação",
+    "unconfirmedTitle": "Concluir com queixas por confirmar?",
+    "unconfirmedBody": "{count, plural, one {Há uma queixa que ainda não foi confirmada como resolvida:} other {Há # queixas que ainda não foram confirmadas como resolvidas:}}",
+    "unconfirmedProceed": "Concluir na mesma",
+    "addMedia": "Adicionar multimédia",
+    "mediaUploading": "A carregar…",
+    "mediaAdded": "{count, plural, one {# ficheiro adicionado} other {# ficheiros adicionados}}",
+    "mediaFailed": "Não foi possível carregar {name}",
+    "mediaTypes": "Escolha uma fotografia (JPG, PNG, WebP) ou um vídeo.",
+    "dictate": {
+      "start": "Ditar",
+      "listening": "A ouvir",
+      "startHint": "Fale e o texto aparece aqui. Nada é gravado.",
+      "stopHint": "Parar de ouvir",
+      "notAllowed": "O navegador não teve autorização para usar o microfone. Autorize-o para este site e tente novamente.",
+      "noMicrophone": "Não foi encontrado nenhum microfone.",
+      "failed": "O ditado parou. Tente novamente.",
+      "startHintServer": "Fale e o texto vai aparecendo enquanto fala. O áudio é enviado para o seu modelo de voz para ser transcrito e não é guardado.",
+      "transcribing": "A concluir…",
+      "language": "Idioma falado",
+      "auto": "Automático",
+      "nothingHeard": "Não se ouviu nada.",
+      "fellBack": "Por agora, a usar o ditado do próprio navegador.",
+      "autoLong": "Automático (detetar o idioma)",
+      "engine": "Transcrito por",
+      "engineAi": "Modelo de voz (mais preciso)",
+      "engineBrowser": "Navegador (escreve enquanto fala)",
+      "liveFellBack": "A escrever em blocos de alguns segundos em vez de palavra a palavra."
+    },
+    "stepEmpty": "Ainda por preencher",
+    "removeTitle": "Remover esta queixa?",
+    "removeBody": "A condição, a causa, a correção e a confirmação são removidas com ela quando guardar a ordem de serviço.",
+    "removeAttached": "As observações e os ficheiros multimédia ficam no trabalho, mas deixam de estar associados a uma queixa.",
+    "removeConfirm": "Remover queixa",
+    "saveFirst": "Guarde primeiro a ordem de serviço"
   },
   "videoCall": {
     "title": "Videochamada",
@@ -493,5 +568,127 @@
       "joinLine": "Entre na videochamada aqui: {url}",
       "emailSubject": "Videochamada sobre a sua viatura {vehicle}"
     }
+  },
+  "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",
+    "vehicleChangesOnSave": "A ordem de serviço passa para esta viatura quando guardar.",
+    "phone": "Telefone",
+    "email": "E-mail",
+    "notSet": "Não definido",
+    "noCustomer": "Nenhum cliente nesta ordem de serviço.",
+    "invoiceTitle": "Fatura e pagamento",
+    "filesTitle": "Ficheiros e multimédia",
+    "activity": {
+      "title": "Atividade",
+      "messageSent": "Mensagem enviada ao cliente",
+      "linkShared": "Link da fatura partilhado",
+      "linkViewed": "{count, plural, one {O cliente abriu o link} other {O cliente abriu o link # vezes}}",
+      "paymentTaken": "Pagamento de {amount} registado"
+    },
+    "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}",
+    "openedBy": "Aberta a {date} por {name}",
+    "editTitle": "Editar título",
+    "titleField": "Título",
+    "media": {
+      "tabs": {
+        "photos": "Fotografias",
+        "diagnostics": "Diagnóstico"
+      },
+      "summary": "{count, plural, one {# ficheiro} other {# ficheiros}} · o cliente só vê o que estiver marcado",
+      "customerCanSee": "Visível para o cliente",
+      "showToCustomer": "Mostrar ao cliente",
+      "hideFromCustomer": "Ocultar do cliente",
+      "add": {
+        "image": "Adicionar fotografias",
+        "document": "Adicionar documentos",
+        "diagnostic": "Adicionar relatório de diagnóstico",
+        "video": "Adicionar vídeo"
+      },
+      "dropHint": "Largue ficheiros aqui ou clique para procurar",
+      "added": "{count, plural, one {# ficheiro adicionado} other {# ficheiros adicionados}}",
+      "failed": "Não foi possível carregar {name}",
+      "wrongType": "Tipo de ficheiro errado para este separador: {names}",
+      "limit": "Limite atingido ({count}/{max})"
+    },
+    "moneyBar": {
+      "total": "Total",
+      "paid": "Pago",
+      "balance": "Valor em dívida",
+      "settled": "Pago na totalidade",
+      "takePayment": "Receber pagamento",
+      "preview": "Pré-visualizar fatura",
+      "previewShort": "Pré-visualizar",
+      "send": "Enviar ao cliente",
+      "sendShort": "Enviar"
+    },
+    "vehicleInfo": {
+      "more": "Mais informações",
+      "less": "Menos informações",
+      "copy": "Copiar {label}",
+      "copied": "Copiado",
+      "lastInspection": "Última inspeção periódica",
+      "purchaseDate": "Data de compra",
+      "purchasePrice": "Preço de compra",
+      "overdue": "em atraso",
+      "customerSince": "Cliente desde"
+    }
+  },
+  "photoHandoff": {
+    "button": "Adicionar pelo telemóvel",
+    "dialogTitle": "Adicionar fotografias e documentos a partir de um telemóvel",
+    "dialogBody": "Tire ou escolha fotografias, ou partilhe um PDF, em qualquer telemóvel. Tudo vai diretamente para esta ordem de trabalho.",
+    "expires": "O código é válido até às {time}.",
+    "waiting": "À espera de fotografias e documentos…",
+    "received": "{count, plural, one {# ficheiro recebido} other {# ficheiros recebidos}}",
+    "copyLink": "Copiar link",
+    "linkCopied": "Link copiado",
+    "failed": "Não foi possível criar o código. Tente novamente.",
+    "done": "Concluído",
+    "pageTitle": "Adicionar fotografias e documentos",
+    "forJob": "Ordem de serviço {number}",
+    "takePhoto": "Tirar fotografia",
+    "choosePhotos": "Escolher fotografias",
+    "hint": "Tudo vai diretamente para a ordem de trabalho. Tire as fotografias de que precisar.",
+    "uploading": "A carregar…",
+    "added": "Adicionada",
+    "notAdded": "Não adicionada",
+    "retry": "Tentar novamente",
+    "addedCount": "{count, plural, one {# ficheiro adicionado} other {# ficheiros adicionados}}",
+    "expiredTitle": "Este código expirou",
+    "invalidTitle": "Este link não funciona",
+    "errors": {
+      "expired": "Peça um novo código ao balcão. Cada código é válido durante 30 minutos.",
+      "invalid": "Leia novamente o código na ordem de serviço.",
+      "limit": "Esta ordem de trabalho já tem todas as fotografias que comporta.",
+      "type": "Aqui só é possível adicionar fotografias e documentos PDF.",
+      "tooLarge": "Esse ficheiro é demasiado grande.",
+      "failed": "A fotografia não foi adicionada. Verifique a ligação e tente novamente.",
+      "codeLimit": "Este código já adicionou todas as fotografias que um código permite. Leia um novo código no balcão."
+    },
+    "concernHeading": "As fotografias ficam associadas a esta queixa",
+    "howTitle": "Como funciona",
+    "step1": "Aponte a câmara do telemóvel para o código e abra a ligação.",
+    "step2": "Tire fotografias, ou escolha fotografias ou documentos PDF que já estão no telemóvel.",
+    "step3": "Aparecem nesta ordem de trabalho à medida que chegam.",
+    "noSignIn": "Não é preciso iniciar sessão no telemóvel.",
+    "customerToo": "A equipa pode utilizá-lo, e o cliente também: deixe-o ler o código ou envie-lhe a ligação.",
+    "scope": "A ligação só adiciona fotografias e documentos a esta ordem de trabalho e deixa de funcionar ao fim de 30 minutos.",
+    "scanHint": "Leia com a câmara do telemóvel",
+    "chooseDocument": "Carregar um documento",
+    "privacy": "Só a oficina vê o que adiciona aqui."
   }
 }

+ 23 - 1
messages/pt-PT/settings.json

@@ -1229,7 +1229,29 @@
     "dateLabel": "Data",
     "dateTimeLabel": "Data e hora",
     "saveSettings": "Guardar definições",
-    "saved": "Definições guardadas"
+    "saved": "Definições guardadas",
+    "fontLabel": "Tipo de letra",
+    "fontHint": "Escolha os tipos de letra usados na aplicação. A escolha fica guardada neste dispositivo.",
+    "fontSampleHeading": "Ordem de serviço",
+    "fontSampleText": "Substituir bobina de ignição e velas",
+    "fonts": {
+      "default": {
+        "name": "Predefinido",
+        "description": "Geist, limpo e neutro."
+      },
+      "workshop": {
+        "name": "Oficina",
+        "description": "IBM Plex com títulos em maiúsculas condensadas, como uma folha de obra."
+      },
+      "precise": {
+        "name": "Preciso",
+        "description": "Inter e JetBrains Mono. Feito para ecrãs cheios de tabelas e números."
+      },
+      "legible": {
+        "name": "Legível",
+        "description": "Atkinson Hyperlegible. 0 e O, 1 e l nunca se confundem; fácil de ler do outro lado da oficina."
+      }
+    }
   },
   "data": {
     "title": "Gestão de dados",

+ 8 - 2
messages/pt-PT/workBoard.json

@@ -78,7 +78,10 @@
     "addTechnician": "Adicionar Técnico",
     "standaloneOnly": "Um nome no quadro para alguém que não inicia sessão, como um aprendiz ou um subcontratado. Para um mecânico que usa a aplicação, adicione-o na página Equipa.",
     "linkedTo": "Inicia sessão como {name}",
-    "linkedManagedOnTeam": "Gerido na página Equipa, juntamente com o acesso à aplicação."
+    "linkedManagedOnTeam": "Gerido na página Equipa, juntamente com o acesso à aplicação.",
+    "skills": "Competências",
+    "skillsPlaceholder": "Diagnóstico, certificado em veículos elétricos",
+    "skillsHint": "Aparece por baixo do nome onde o trabalho é atribuído. Opcional."
   },
   "jobDetail": {
     "viewDetails": "Ver Detalhes",
@@ -154,6 +157,9 @@
     "workBay": "Posto de trabalho",
     "status": "Estado",
     "noTime": "Ainda sem hora definida",
-    "none": "Ainda ninguém"
+    "none": "Ainda ninguém",
+    "promised": "Prometido",
+    "promiseLate": "Atrasado",
+    "promiseOverdue": "Prometido ao cliente para um momento que já passou"
   }
 }

+ 7 - 1
messages/pt-PT/workOrders.json

@@ -31,7 +31,9 @@
       "status": "Estado",
       "tech": "Técnico",
       "date": "Data",
-      "total": "Total"
+      "total": "Total",
+      "promised": "Prometido",
+      "promisedOverdue": "Prometido {date}, atrasado"
     },
     "emailSubject": "Ponto de situação da sua viatura: {status}",
     "changeStatus": "Alterar estado",
@@ -39,6 +41,10 @@
       "open": "Abrir ordem de serviço",
       "openVehicle": "Abrir viatura",
       "openCustomer": "Abrir cliente"
+    },
+    "dueToday": "Para hoje",
+    "columns": {
+      "label": "Colunas"
     }
   },
   "vehiclePicker": {

+ 21 - 2
messages/ru/integrations.json

@@ -60,7 +60,8 @@
       "safety": "Отзывные кампании и жалобы владельцев"
     },
     "ai": {
-      "chat": "Тексты и сводки"
+      "chat": "Тексты и сводки",
+      "transcribe": "Диктовка"
     },
     "payments": {
       "checkout": "Оплата по ссылке на счёт"
@@ -156,7 +157,8 @@
       "syncNowHelp": "«Синхронизировать сейчас» запускает проход немедленно, не дожидаясь ежечасного. Он проверяет по 25 автомобилей и продолжает сам, пока не обработает все ожидающие."
     },
     "supersedes": "Подключение {next} отключит {current}. Одновременно работает только один, поэтому {current} перестанет использоваться, как только заработает {next}.",
-    "batchRetried": "{count, plural, =0 {Нечего повторять} one {# задача снова в очереди} few {# задачи снова в очереди} many {# задач снова в очереди} other {# задачи снова в очереди}}"
+    "batchRetried": "{count, plural, =0 {Нечего повторять} one {# задача снова в очереди} few {# задачи снова в очереди} many {# задач снова в очереди} other {# задачи снова в очереди}}",
+    "advanced": "Дополнительно"
   },
   "activity": {
     "title": "Активность",
@@ -578,6 +580,23 @@
         "refreshFleet": "Поддерживать актуальные данные об отзывах и жалобах для каждого автомобиля этой мастерской",
         "refreshFleetHelp": "Раз в день модели, не обновлявшиеся за последнюю неделю, запрашиваются заново, чтобы страница автомобиля никогда не ждала, а новая отзывная кампания появлялась в течение недели. Отправляются только марка, модель и год."
       }
+    },
+    "speech-to-text": {
+      "description": "Диктовка в заказ-наряде, которую записывает речевая модель, а не браузер: гораздо точнее, на любом языке и со словами мастерской. Работает отдельно от вашего провайдера ИИ, так что его можно оставить как есть. Использует ваш собственный ключ OpenAI или ваш собственный сервер с API распознавания речи в стиле OpenAI. Запись отправляется на расшифровку и не сохраняется.",
+      "fields": {
+        "apiKey": "API-ключ",
+        "apiKeyHelp": "Ключ OpenAI с platform.openai.com из раздела API keys; OpenAI берёт плату за использование поминутно. Для собственного сервера нужен, только если сервер его требует.",
+        "baseUrl": "Адрес сервера",
+        "baseUrlHelp": "Оставьте пустым, чтобы использовать OpenAI. Для собственного сервера (локальный Whisper, LocalAI, Groq) укажите, где начинается его API в стиле OpenAI, включая путь, например /v1. Torqvoice добавляет к нему /audio/transcriptions."
+      },
+      "settings": {
+        "model": "Модель",
+        "modelHelp": "Какую речевую модель запрашивать. gpt-4o-mini-transcribe точная и недорогая; gpt-4o-transcribe крупнее. Собственный сервер обычно отвечает на whisper-1.",
+        "mode": "Кто выбирает, как записывается диктовка",
+        "modeHelp": "Браузер пишет по ходу речи, но хуже, особенно на небольших языках. Речевая модель пишет, когда вы замолчите.",
+        "modeAi": "Всегда речевая модель",
+        "modeChoice": "Каждый выбирает сам: речевая модель или браузер"
+      }
     }
   },
   "jobKinds": {

+ 202 - 5
messages/ru/service.json

@@ -21,7 +21,8 @@
       "title": "Заказу нужно название, прежде чем его можно сохранить.",
       "negative": "Количество, часы, цены и затраты не могут быть отрицательными.",
       "partName": "У каждой детали с ценой должно быть название.",
-      "laborDescription": "У каждой строки работ с часами или ставкой должно быть описание."
+      "laborDescription": "У каждой строки работ с часами или ставкой должно быть описание.",
+      "concernCondition": "Если у пункта указаны причина, устранение или проверка, заполните и симптом."
     }
   },
   "header": {
@@ -164,6 +165,11 @@
     "deleteRow": "Удалить строку",
     "descriptionMissingHint": "Строки без описания не сохраняются."
   },
+  "liveLabor": {
+    "title": "{count, plural, one {Механик добавил # строку работ в этот заказ} few {Механик добавил # строки работ в этот заказ} many {Механик добавил # строк работ в этот заказ} other {Механик добавил # строк работ в этот заказ}}",
+    "body": "Пока вы редактируете, она не попадает в список. При следующем сохранении она сохранится в любом случае.",
+    "show": "Показать"
+  },
   "totals": {
     "title": "Итоги",
     "parts": "Запчасти",
@@ -198,7 +204,9 @@
     "aiOverwriteReplace": "Заменить",
     "aiOverwriteAppend": "Добавить",
     "aiOverwriteCancel": "Отмена",
-    "aiDontShowAgain": "Больше не показывать"
+    "aiDontShowAgain": "Больше не показывать",
+    "internalTitle": "Внутренние заметки",
+    "publicTitle": "Заметки для клиента"
   },
   "invoice": {
     "title": "Итоги по счёту",
@@ -248,7 +256,10 @@
     "failedDelete": "Не удалось удалить платёж",
     "deleteTitle": "Удалить платёж",
     "deleteDescription": "Эта запись платежа будет удалена.",
-    "failedPdf": "Не удалось сгенерировать PDF-счёт"
+    "failedPdf": "Не удалось сгенерировать PDF-счёт",
+    "received": "Полученные платежи",
+    "noneYet": "Оплат пока нет.",
+    "balanceDue": "К оплате"
   },
   "attachments": {
     "deleteTitle": "Удалить вложение",
@@ -357,6 +368,7 @@
     "date": "Дата",
     "startTime": "Время начала",
     "endTime": "Время окончания",
+    "durationPresets": "Длительность",
     "technician": "Механик",
     "selectTechnician": "Выберите механика...",
     "searchOrCreate": "Поиск или создание механика...",
@@ -394,7 +406,17 @@
     "pickNextAvailable": "Выбрать ближайшее свободное время",
     "findingSlot": "Поиск времени…",
     "slotPicked": "Назначено на ближайшее свободное время",
-    "noSlotFound": "В ближайшие недели свободного времени не найдено"
+    "noSlotFound": "В ближайшие недели свободного времени не найдено",
+    "assignedTitle": "Назначенный механик",
+    "boardLink": "Доска работ",
+    "someoneElse": "Кто-то другой",
+    "loadOf": "{booked} / {capacity} ч",
+    "loadFree": "Свободно {hours} ч",
+    "loadFull": "Всё занято",
+    "loadOn": "Занятые часы на {date}",
+    "promisedAt": "Обещано клиенту",
+    "promisedPlaceholder": "Срок ещё не обещан",
+    "promisedClear": "Очистить"
   },
   "notifications": {
     "title": "Уведомления клиента",
@@ -447,7 +469,60 @@
     "placeholder": "При торможении сзади слышен шум",
     "remove": "Удалить этот пункт",
     "notAnswered": "По этому пункту пока ничего не найдено",
-    "answered": "Найдено по этому пункту: {count}"
+    "answered": "Найдено по этому пункту: {count}",
+    "storyTitle": "Описание работ",
+    "storyHint": "Симптом, причина, устранение, проверка. Нажмите на шаг, чтобы открыть его; по одной строке на каждый пункт, названный клиентом или замеченный вами.",
+    "steps": {
+      "condition": "Симптом",
+      "cause": "Причина",
+      "correction": "Устранение",
+      "confirm": "Проверка"
+    },
+    "conditionPlaceholder": "Что сообщил клиент или что заметили вы",
+    "causePlaceholder": "Что показала диагностика: в чём первопричина",
+    "correctionPlaceholder": "Что сделано для устранения",
+    "confirmPlaceholder": "Как проверен ремонт: тест-драйв, повторное сканирование, симптом исчез",
+    "markConfirmed": "Проверено",
+    "confirmedBy": "Проверено: {name}, {date}",
+    "confirmedAt": "Проверено {date}",
+    "confirmedOnSave": "При сохранении будет отмечено вашим именем",
+    "confirmedCount": "Проверено {confirmed} из {total}",
+    "findingsCount": "{count, plural, one {# наблюдение} few {# наблюдения} many {# наблюдений} other {# наблюдения}}",
+    "addFinding": "Добавить наблюдение",
+    "unconfirmedTitle": "Завершить с непроверенными пунктами?",
+    "unconfirmedBody": "{count, plural, =1 {Один пункт не проверен после ремонта:} one {# пункт не проверен после ремонта:} few {# пункта не проверены после ремонта:} many {# пунктов не проверены после ремонта:} other {# пункта не проверены после ремонта:}}",
+    "unconfirmedProceed": "Всё равно завершить",
+    "addMedia": "Добавить медиа",
+    "mediaUploading": "Загрузка…",
+    "mediaAdded": "{count, plural, one {Добавлен # файл} few {Добавлено # файла} many {Добавлено # файлов} other {Добавлено # файла}}",
+    "mediaFailed": "Не удалось загрузить {name}",
+    "mediaTypes": "Выберите фото (JPG, PNG, WebP) или видео.",
+    "dictate": {
+      "start": "Диктовать",
+      "listening": "Слушаю",
+      "startHint": "Говорите, и текст появится здесь. Ничего не записывается.",
+      "stopHint": "Перестать слушать",
+      "notAllowed": "Браузеру не разрешён доступ к микрофону. Разрешите его для этого сайта и попробуйте снова.",
+      "noMicrophone": "Микрофон не найден.",
+      "failed": "Диктовка остановлена. Попробуйте снова.",
+      "startHintServer": "Говорите, и текст пишется по ходу речи. Звук уходит в вашу речевую модель на расшифровку и не сохраняется.",
+      "transcribing": "Завершение…",
+      "language": "Язык речи",
+      "auto": "Авто",
+      "nothingHeard": "Ничего не было слышно.",
+      "fellBack": "Пока используется встроенная диктовка браузера.",
+      "autoLong": "Авто (определить язык)",
+      "engine": "Кто записывает",
+      "engineAi": "Речевая модель (точнее)",
+      "engineBrowser": "Браузер (пишет по ходу речи)",
+      "liveFellBack": "Текст появляется частями раз в несколько секунд, а не по словам."
+    },
+    "stepEmpty": "Ещё не заполнено",
+    "removeTitle": "Удалить этот пункт?",
+    "removeBody": "Его симптом, причина, устранение и проверка удалятся вместе с ним при сохранении заказ-наряда.",
+    "removeAttached": "Его наблюдения и медиа останутся в заказ-наряде, но уже не будут привязаны к пункту.",
+    "removeConfirm": "Удалить пункт",
+    "saveFirst": "Сначала сохраните заказ-наряд"
   },
   "videoCall": {
     "title": "Видеозвонок",
@@ -493,5 +568,127 @@
       "joinLine": "Подключиться к видеозвонку: {url}",
       "emailSubject": "Видеозвонок по поводу вашего {vehicle}"
     }
+  },
+  "modern": {
+    "statusLabel": "Статус заказ-наряда",
+    "back": "Назад",
+    "backToClassic": "Вернуться к классическому виду",
+    "openCustomer": "Открыть клиента",
+    "serviceHistory": "История обслуживания",
+    "changeVehicle": "Сменить транспортное средство",
+    "vehicleChangesOnSave": "При сохранении заказ-наряд перейдёт на это транспортное средство.",
+    "phone": "Телефон",
+    "email": "Эл. почта",
+    "notSet": "Не указано",
+    "noCustomer": "В этом заказ-наряде нет клиента.",
+    "invoiceTitle": "Счёт и оплата",
+    "filesTitle": "Файлы и медиа",
+    "activity": {
+      "title": "Активность",
+      "messageSent": "Клиенту отправлено сообщение",
+      "linkShared": "Отправлена ссылка на счёт",
+      "linkViewed": "{count, plural, =1 {Клиент открыл ссылку} one {Клиент открыл ссылку # раз} few {Клиент открыл ссылку # раза} many {Клиент открыл ссылку # раз} other {Клиент открыл ссылку # раза}}",
+      "paymentTaken": "Записан платёж {amount}"
+    },
+    "invite": {
+      "title": "Страница заказ-наряда обновлена.",
+      "body": "Более понятная компоновка с теми же полями. Вернуться можно в любой момент.",
+      "try": "Попробовать",
+      "dismiss": "Не сейчас",
+      "menu": "Попробовать новый вид"
+    },
+    "promised": "Обещано {date}",
+    "promisedOverdue": "Обещано {date}, просрочено",
+    "opened": "Открыт {date}",
+    "openedBy": "Открыт {date}, автор: {name}",
+    "editTitle": "Изменить название",
+    "titleField": "Название",
+    "media": {
+      "tabs": {
+        "photos": "Фото",
+        "diagnostics": "Диагностика"
+      },
+      "summary": "{count, plural, one {# файл} few {# файла} many {# файлов} other {# файла}} · клиент видит только отмеченные",
+      "customerCanSee": "Видно клиенту",
+      "showToCustomer": "Показать клиенту",
+      "hideFromCustomer": "Скрыть от клиента",
+      "add": {
+        "image": "Добавить фото",
+        "document": "Добавить документы",
+        "diagnostic": "Добавить диагностический отчёт",
+        "video": "Добавить видео"
+      },
+      "dropHint": "Перетащите файлы или нажмите, чтобы выбрать",
+      "added": "{count, plural, one {Добавлен # файл} few {Добавлено # файла} many {Добавлено # файлов} other {Добавлено # файла}}",
+      "failed": "Не удалось загрузить {name}",
+      "wrongType": "Эти файлы не подходят для этой вкладки: {names}",
+      "limit": "Достигнут лимит ({count}/{max})"
+    },
+    "moneyBar": {
+      "total": "Итого",
+      "paid": "Оплачено",
+      "balance": "К оплате",
+      "settled": "Оплачено полностью",
+      "takePayment": "Принять оплату",
+      "preview": "Просмотр счёта",
+      "previewShort": "Просмотр",
+      "send": "Отправить клиенту",
+      "sendShort": "Отправить"
+    },
+    "vehicleInfo": {
+      "more": "Подробнее",
+      "less": "Свернуть",
+      "copy": "Копировать: {label}",
+      "copied": "Скопировано",
+      "lastInspection": "Последний техосмотр",
+      "purchaseDate": "Дата покупки",
+      "purchasePrice": "Цена покупки",
+      "overdue": "просрочен",
+      "customerSince": "Клиент с"
+    }
+  },
+  "photoHandoff": {
+    "button": "Добавить с телефона",
+    "dialogTitle": "Добавить фото и документы с телефона",
+    "dialogBody": "Снимайте или выбирайте фото или отправьте PDF с любого телефона. Всё сразу попадает в этот заказ-наряд.",
+    "expires": "Код действует до {time}.",
+    "waiting": "Ожидание фото и документов…",
+    "received": "{count, plural, one {Получен # файл} few {Получено # файла} many {Получено # файлов} other {Получено # файла}}",
+    "copyLink": "Копировать ссылку",
+    "linkCopied": "Ссылка скопирована",
+    "failed": "Не удалось создать код. Попробуйте ещё раз.",
+    "done": "Готово",
+    "pageTitle": "Добавить фото и документы",
+    "forJob": "Заказ-наряд {number}",
+    "takePhoto": "Сделать фото",
+    "choosePhotos": "Выбрать фото",
+    "hint": "Всё сразу попадает в заказ-наряд. Снимайте столько фото, сколько нужно.",
+    "uploading": "Загрузка…",
+    "added": "Добавлено",
+    "notAdded": "Не добавлено",
+    "retry": "Повторить",
+    "addedCount": "{count, plural, one {Добавлен # файл} few {Добавлено # файла} many {Добавлено # файлов} other {Добавлено # файла}}",
+    "expiredTitle": "Срок действия кода истёк",
+    "invalidTitle": "Эта ссылка не работает",
+    "errors": {
+      "expired": "Попросите новый код на стойке. Каждый код действует 30 минут.",
+      "invalid": "Отсканируйте код в заказ-наряде ещё раз.",
+      "limit": "В этом заказ-наряде уже столько фото, сколько он может вместить.",
+      "type": "Здесь можно добавлять только фото и документы PDF.",
+      "tooLarge": "Файл слишком большой.",
+      "failed": "Фото не добавлено. Проверьте подключение и попробуйте ещё раз.",
+      "codeLimit": "С этим кодом уже добавлено столько фото, сколько допускает один код. Отсканируйте новый код на стойке."
+    },
+    "concernHeading": "Фото будут привязаны к этому пункту",
+    "howTitle": "Как это работает",
+    "step1": "Наведите камеру телефона на код и откройте ссылку.",
+    "step2": "Сделайте фото или выберите фото или документы PDF, которые уже есть на телефоне.",
+    "step3": "Они появляются в этом заказ-наряде по мере поступления.",
+    "noSignIn": "Входить в систему на телефоне не нужно.",
+    "customerToo": "Пользоваться могут сотрудники, и клиент тоже: дайте ему отсканировать код или отправьте ссылку.",
+    "scope": "Ссылка только добавляет фото и документы в этот заказ-наряд и перестаёт работать через 30 минут.",
+    "scanHint": "Отсканируйте камерой телефона",
+    "chooseDocument": "Загрузить документ",
+    "privacy": "То, что вы добавляете здесь, видит только сервис."
   }
 }

+ 23 - 1
messages/ru/settings.json

@@ -1229,7 +1229,29 @@
     "dateLabel": "Дата",
     "dateTimeLabel": "Дата и время",
     "saveSettings": "Сохранить настройки",
-    "saved": "Настройки сохранены"
+    "saved": "Настройки сохранены",
+    "fontLabel": "Шрифт",
+    "fontHint": "Выберите шрифты приложения. Выбор сохраняется на этом устройстве.",
+    "fontSampleHeading": "Заказ-наряд",
+    "fontSampleText": "Заменить катушку зажигания и свечи",
+    "fonts": {
+      "default": {
+        "name": "По умолчанию",
+        "description": "Geist, чистый и нейтральный."
+      },
+      "workshop": {
+        "name": "Мастерская",
+        "description": "IBM Plex с узкими заглавными заголовками, как в рабочей карточке."
+      },
+      "precise": {
+        "name": "Точный",
+        "description": "Inter и JetBrains Mono. Для экранов, полных таблиц и цифр."
+      },
+      "legible": {
+        "name": "Разборчивый",
+        "description": "Atkinson Hyperlegible. 0 и O, 1 и l никогда не похожи друг на друга; легко читается с другого конца мастерской."
+      }
+    }
   },
   "data": {
     "title": "Управление данными",

+ 8 - 2
messages/ru/workBoard.json

@@ -78,7 +78,10 @@
     "addTechnician": "Добавить механика",
     "standaloneOnly": "Имя на доске для того, кто не входит в систему: ученика или подрядчика. Механика с приложением добавьте на странице «Команда».",
     "linkedTo": "Входит как {name}",
-    "linkedManagedOnTeam": "Управляется на странице «Команда» вместе с доступом к приложению."
+    "linkedManagedOnTeam": "Управляется на странице «Команда» вместе с доступом к приложению.",
+    "skills": "Навыки",
+    "skillsPlaceholder": "Диагностика, допуск к электромобилям",
+    "skillsHint": "Показывается под именем там, где распределяется работа. Необязательно."
   },
   "jobDetail": {
     "viewDetails": "Просмотреть детали",
@@ -154,6 +157,9 @@
     "workBay": "Пост",
     "status": "Статус",
     "noTime": "Время не назначено",
-    "none": "Пока никто"
+    "none": "Пока никто",
+    "promised": "Обещано",
+    "promiseLate": "Просрочено",
+    "promiseOverdue": "Обещано клиенту на уже прошедшее время"
   }
 }

+ 7 - 1
messages/ru/workOrders.json

@@ -31,7 +31,9 @@
       "status": "Статус",
       "tech": "Механик",
       "date": "Дата",
-      "total": "Итого"
+      "total": "Итого",
+      "promised": "Обещано",
+      "promisedOverdue": "Обещано {date}, просрочено"
     },
     "emailSubject": "Обновление статуса транспортного средства: {status}",
     "changeStatus": "Изменить статус",
@@ -39,6 +41,10 @@
       "open": "Открыть заказ-наряд",
       "openVehicle": "Открыть автомобиль",
       "openCustomer": "Открыть клиента"
+    },
+    "dueToday": "Срок сегодня",
+    "columns": {
+      "label": "Столбцы"
     }
   },
   "vehiclePicker": {

+ 21 - 2
messages/tr/integrations.json

@@ -60,7 +60,8 @@
       "safety": "Geri çağırmalar ve sahip şikâyetleri"
     },
     "ai": {
-      "chat": "Metinler ve özetler"
+      "chat": "Metinler ve özetler",
+      "transcribe": "Dikte"
     },
     "payments": {
       "checkout": "Fatura bağlantısından ödeme"
@@ -156,7 +157,8 @@
       "syncNowHelp": "Şimdi eşitle, saatlik taramayı beklemek yerine hemen bir tarama başlatır. Her seferinde 25 aracı sorgular ve sırası gelen tüm araçlar bitene kadar kendi kendine devam eder."
     },
     "supersedes": "{next} bağlandığında {current} bağlantısı kesilir. Aynı anda yalnızca biri geçerli olabilir; {next} devreye girdiği anda {current} kullanılmayı bırakır.",
-    "batchRetried": "{count, plural, =0 {Yeniden denenecek bir şey yok} one {# iş yeniden kuyruğa alındı} other {# iş yeniden kuyruğa alındı}}"
+    "batchRetried": "{count, plural, =0 {Yeniden denenecek bir şey yok} one {# iş yeniden kuyruğa alındı} other {# iş yeniden kuyruğa alındı}}",
+    "advanced": "Gelişmiş"
   },
   "activity": {
     "title": "Aktivite",
@@ -578,6 +580,23 @@
         "refreshFleet": "Bu atölyedeki her araç için geri çağırma ve şikâyet verilerini güncel tut",
         "refreshFleetHelp": "Günde bir kez, son bir haftada yenilenmemiş modeller yeniden çekilir; böylece araç sayfası hiç beklemez ve yeni bir geri çağırma bir hafta içinde görünür. Yalnızca marka, model ve yıl gönderilir."
       }
+    },
+    "speech-to-text": {
+      "description": "İş emrindeki dikte, tarayıcı yerine bir konuşma modeli tarafından yazıya dökülür: çok daha doğrudur, her dilde çalışır ve atölye terimlerini bilir. Yapay zekâ sağlayıcınızdan ayrıdır, o yüzden onu olduğu gibi bırakabilirsiniz. Kendi OpenAI anahtarınızı ya da OpenAI'nin transkripsiyon API'siyle çalışan kendi sunucunuzu kullanır. Kayıt yazıya dökülmek üzere gönderilir ve saklanmaz.",
+      "fields": {
+        "apiKey": "API anahtarı",
+        "apiKeyHelp": "platform.openai.com adresinde API keys bölümünden alınan bir OpenAI anahtarı; OpenAI kullanımı dakika başına faturalandırır. Kendi sunucunuz için yalnızca sunucu bir anahtar isterse.",
+        "baseUrl": "Sunucu adresi",
+        "baseUrlHelp": "OpenAI kullanmak için boş bırakın. Kendi sunucunuz için (yerel bir Whisper, LocalAI, Groq) OpenAI tarzı API'sinin başladığı adres, yol dahil, örneğin /v1. Torqvoice bunun sonuna /audio/transcriptions ekler."
+      },
+      "settings": {
+        "model": "Model",
+        "modelHelp": "İstenecek konuşma modeli. gpt-4o-mini-transcribe doğru ve ucuzdur; gpt-4o-transcribe daha büyük olanıdır. Kendi sunucunuz genellikle whisper-1 adına yanıt verir.",
+        "mode": "Diktenin nasıl yazıya döküleceğini kim seçer",
+        "modeHelp": "Tarayıcı siz konuşurken yazar ama daha zayıftır, özellikle az konuşulan dillerde. Konuşma modeli siz durduğunuzda yazar.",
+        "modeAi": "Her zaman konuşma modeli",
+        "modeChoice": "Herkes kendisi seçer: konuşma modeli veya tarayıcı"
+      }
     }
   },
   "jobKinds": {

+ 202 - 5
messages/tr/service.json

@@ -21,7 +21,8 @@
       "title": "İş kaydedilmeden önce bir başlık gerekiyor.",
       "negative": "Miktarlar, saatler, fiyatlar ve maliyetler negatif olamaz.",
       "partName": "Fiyatı olan her parçanın bir adı olmalı.",
-      "laborDescription": "Saati veya ücreti olan her işçilik satırının bir açıklaması olmalı."
+      "laborDescription": "Saati veya ücreti olan her işçilik satırının bir açıklaması olmalı.",
+      "concernCondition": "Nedeni, onarımı veya doğrulaması olan bir maddenin belirtisi doldurulmalı."
     }
   },
   "header": {
@@ -164,6 +165,11 @@
     "deleteRow": "Satırı sil",
     "descriptionMissingHint": "Açıklaması olmayan satırlar kaydedilmez."
   },
+  "liveLabor": {
+    "title": "{count, plural, one {Bir teknisyen bu işe işçilik ekledi} other {Bir teknisyen bu işe # işçilik satırı ekledi}}",
+    "body": "Siz düzenlerken listeye eklenmez. Bir sonraki kaydetmede yine de kaydedilir.",
+    "show": "Göster"
+  },
   "totals": {
     "title": "Toplamlar",
     "parts": "Parçalar",
@@ -198,7 +204,9 @@
     "aiOverwriteReplace": "Değiştir",
     "aiOverwriteAppend": "Ekle",
     "aiOverwriteCancel": "İptal",
-    "aiDontShowAgain": "Bir daha gösterme"
+    "aiDontShowAgain": "Bir daha gösterme",
+    "internalTitle": "İç notlar",
+    "publicTitle": "Müşteri notları"
   },
   "invoice": {
     "title": "Fatura Özeti",
@@ -248,7 +256,10 @@
     "failedDelete": "Ödeme silinemedi",
     "deleteTitle": "Ödemeyi Sil",
     "deleteDescription": "Bu, bu ödeme kaydını kaldıracaktır.",
-    "failedPdf": "PDF fatura oluşturulamadı"
+    "failedPdf": "PDF fatura oluşturulamadı",
+    "received": "Alınan ödemeler",
+    "noneYet": "Henüz ödeme yapılmadı.",
+    "balanceDue": "Kalan bakiye"
   },
   "attachments": {
     "deleteTitle": "Eki Sil",
@@ -357,6 +368,7 @@
     "date": "Tarih",
     "startTime": "Başlangıç Saati",
     "endTime": "Bitiş Saati",
+    "durationPresets": "Süre",
     "technician": "Teknisyen",
     "selectTechnician": "Teknisyen seçin...",
     "searchOrCreate": "Teknisyen ara veya oluştur...",
@@ -394,7 +406,17 @@
     "pickNextAvailable": "Sıradaki boş saati seç",
     "findingSlot": "Saat aranıyor…",
     "slotPicked": "Sıradaki boş saate alındı",
-    "noSlotFound": "Önümüzdeki haftalarda boş saat bulunamadı"
+    "noSlotFound": "Önümüzdeki haftalarda boş saat bulunamadı",
+    "assignedTitle": "Atanan teknisyen",
+    "boardLink": "İş panosu",
+    "someoneElse": "Başka biri",
+    "loadOf": "{booked} / {capacity} sa",
+    "loadFree": "{hours} sa boş",
+    "loadFull": "Tamamen dolu",
+    "loadOn": "{date} tarihindeki dolu saatler",
+    "promisedAt": "Müşteriye söz verilen zaman",
+    "promisedPlaceholder": "Henüz söz verilmedi",
+    "promisedClear": "Temizle"
   },
   "notifications": {
     "title": "Müşteri bildirimleri",
@@ -447,7 +469,60 @@
     "placeholder": "Fren yapınca arkadan ses geliyor",
     "remove": "Bu maddeyi kaldır",
     "notAnswered": "Bu konuda henüz bir şey bulunmadı",
-    "answered": "Bu konuda {count} tespit"
+    "answered": "Bu konuda {count} tespit",
+    "storyTitle": "İş tanımı",
+    "storyHint": "Belirti, neden, onarım, doğrulama. Açmak için bir adıma tıklayın; müşterinin bahsettiği veya sizin gözlemlediğiniz her şey için bir satır.",
+    "steps": {
+      "condition": "Belirti",
+      "cause": "Neden",
+      "correction": "Onarım",
+      "confirm": "Doğrulama"
+    },
+    "conditionPlaceholder": "Müşterinin bildirdiği veya sizin gözlemlediğiniz",
+    "causePlaceholder": "Testlerin ortaya çıkardığı asıl neden",
+    "correctionPlaceholder": "Gidermek için ne yapıldı",
+    "confirmPlaceholder": "Onarım nasıl kontrol edildi: yol testi, yeniden tarama, belirti geçti",
+    "markConfirmed": "Doğrulandı",
+    "confirmedBy": "{name} tarafından doğrulandı, {date}",
+    "confirmedAt": "{date} tarihinde doğrulandı",
+    "confirmedOnSave": "Kaydettiğinizde adınızla işaretlenir",
+    "confirmedCount": "{total} maddeden {confirmed} tanesi doğrulandı",
+    "findingsCount": "{count, plural, one {# gözlem} other {# gözlem}}",
+    "addFinding": "Gözlem ekle",
+    "unconfirmedTitle": "Doğrulanmamış maddelerle tamamlansın mı?",
+    "unconfirmedBody": "{count, plural, one {Bir maddenin giderildiği doğrulanmadı:} other {# maddenin giderildiği doğrulanmadı:}}",
+    "unconfirmedProceed": "Yine de tamamla",
+    "addMedia": "Medya ekle",
+    "mediaUploading": "Yükleniyor…",
+    "mediaAdded": "{count, plural, one {# dosya eklendi} other {# dosya eklendi}}",
+    "mediaFailed": "{name} yüklenemedi",
+    "mediaTypes": "Bir fotoğraf (JPG, PNG, WebP) veya video seçin.",
+    "dictate": {
+      "start": "Dikte et",
+      "listening": "Dinleniyor",
+      "startHint": "Konuşun, burada yazılır. Hiçbir şey kaydedilmez.",
+      "stopHint": "Dinlemeyi durdur",
+      "notAllowed": "Tarayıcının mikrofonu kullanmasına izin verilmedi. Bu site için izin verin ve tekrar deneyin.",
+      "noMicrophone": "Mikrofon bulunamadı.",
+      "failed": "Dikte durdu. Tekrar deneyin.",
+      "startHintServer": "Konuşun, siz konuştukça yazılır. Ses, yazıya dökülmek üzere konuşma modelinize gider ve saklanmaz.",
+      "transcribing": "Bitiriliyor…",
+      "language": "Konuşulan dil",
+      "auto": "Otomatik",
+      "nothingHeard": "Hiçbir şey duyulmadı.",
+      "fellBack": "Şimdilik tarayıcının kendi diktesi kullanılıyor.",
+      "autoLong": "Otomatik (dili algıla)",
+      "engine": "Yazıya döken",
+      "engineAi": "Konuşma modeli (daha doğru)",
+      "engineBrowser": "Tarayıcı (siz konuşurken yazar)",
+      "liveFellBack": "Kelime kelime yerine birkaç saniyelik parçalar halinde yazılıyor."
+    },
+    "stepEmpty": "Henüz yazılmadı",
+    "removeTitle": "Bu madde kaldırılsın mı?",
+    "removeBody": "İş emrini kaydettiğinizde belirtisi, nedeni, onarımı ve doğrulaması da onunla birlikte kaldırılır.",
+    "removeAttached": "Gözlemleri ve medyası işte kalır, ancak artık bir maddeye bağlı olmaz.",
+    "removeConfirm": "Maddeyi kaldır",
+    "saveFirst": "Önce iş emrini kaydedin"
   },
   "videoCall": {
     "title": "Görüntülü görüşme",
@@ -493,5 +568,127 @@
       "joinLine": "Görüntülü görüşmeye buradan katılın: {url}",
       "emailSubject": "{vehicle} aracınızla ilgili görüntülü görüşme"
     }
+  },
+  "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",
+    "vehicleChangesOnSave": "Kaydettiğinizde iş emri bu araca taşınır.",
+    "phone": "Telefon",
+    "email": "E-posta",
+    "notSet": "Belirtilmedi",
+    "noCustomer": "Bu iş emrinde müşteri yok.",
+    "invoiceTitle": "Fatura ve ödeme",
+    "filesTitle": "Dosyalar ve medya",
+    "activity": {
+      "title": "Etkinlik",
+      "messageSent": "Müşteriye mesaj gönderildi",
+      "linkShared": "Fatura bağlantısı paylaşıldı",
+      "linkViewed": "{count, plural, one {Müşteri bağlantıyı açtı} other {Müşteri bağlantıyı # kez açtı}}",
+      "paymentTaken": "{amount} tutarında ödeme kaydedildi"
+    },
+    "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ı",
+    "openedBy": "{date} tarihinde {name} tarafından açıldı",
+    "editTitle": "Başlığı düzenle",
+    "titleField": "Başlık",
+    "media": {
+      "tabs": {
+        "photos": "Fotoğraflar",
+        "diagnostics": "Tanı"
+      },
+      "summary": "{count, plural, one {# dosya} other {# dosya}} · yalnızca işaretlenenler müşteriyle paylaşılır",
+      "customerCanSee": "Müşteri görebilir",
+      "showToCustomer": "Müşteriye göster",
+      "hideFromCustomer": "Müşteriden gizle",
+      "add": {
+        "image": "Fotoğraf ekle",
+        "document": "Belge ekle",
+        "diagnostic": "Tanı raporu ekle",
+        "video": "Video ekle"
+      },
+      "dropHint": "Dosyaları bırakın veya göz atmak için tıklayın",
+      "added": "{count, plural, one {# dosya eklendi} other {# dosya eklendi}}",
+      "failed": "{name} yüklenemedi",
+      "wrongType": "Bu sekmeye uygun dosya türü değil: {names}",
+      "limit": "Sınıra ulaşıldı ({count}/{max})"
+    },
+    "moneyBar": {
+      "total": "Toplam",
+      "paid": "Ödenen",
+      "balance": "Kalan bakiye",
+      "settled": "Tamamı ödendi",
+      "takePayment": "Ödeme al",
+      "preview": "Faturayı önizle",
+      "previewShort": "Önizle",
+      "send": "Müşteriye gönder",
+      "sendShort": "Gönder"
+    },
+    "vehicleInfo": {
+      "more": "Daha fazla bilgi",
+      "less": "Daha az bilgi",
+      "copy": "{label} kopyala",
+      "copied": "Kopyalandı",
+      "lastInspection": "Son periyodik muayene",
+      "purchaseDate": "Satın alma tarihi",
+      "purchasePrice": "Satın alma fiyatı",
+      "overdue": "gecikmiş",
+      "customerSince": "Müşteri olma tarihi"
+    }
+  },
+  "photoHandoff": {
+    "button": "Telefondan ekle",
+    "dialogTitle": "Telefondan fotoğraf ve belge ekleyin",
+    "dialogBody": "Herhangi bir telefonla fotoğraf çekin, seçin veya bir PDF paylaşın. Her şey doğrudan bu iş emrine eklenir.",
+    "expires": "Kod {time} saatine kadar geçerli.",
+    "waiting": "Fotoğraf ve belgeler bekleniyor…",
+    "received": "{count, plural, one {# dosya alındı} other {# dosya alındı}}",
+    "copyLink": "Bağlantıyı kopyala",
+    "linkCopied": "Bağlantı kopyalandı",
+    "failed": "Kod oluşturulamadı. Tekrar deneyin.",
+    "done": "Tamam",
+    "pageTitle": "Fotoğraf ve belge ekleyin",
+    "forJob": "İş emri {number}",
+    "takePhoto": "Fotoğraf çek",
+    "choosePhotos": "Fotoğraf seç",
+    "hint": "Her şey doğrudan iş emrine eklenir. İhtiyacınız kadar fotoğraf çekin.",
+    "uploading": "Yükleniyor…",
+    "added": "Eklendi",
+    "notAdded": "Eklenmedi",
+    "retry": "Tekrar dene",
+    "addedCount": "{count, plural, one {# dosya eklendi} other {# dosya eklendi}}",
+    "expiredTitle": "Bu kodun süresi doldu",
+    "invalidTitle": "Bu bağlantı çalışmıyor",
+    "errors": {
+      "expired": "Danışmadan yeni bir kod isteyin. Her kod 30 dakika geçerlidir.",
+      "invalid": "İş emrindeki kodu yeniden tarayın.",
+      "limit": "Bu iş emri alabileceği kadar fotoğrafa zaten sahip.",
+      "type": "Buraya yalnızca fotoğraf ve PDF belgeleri eklenebilir.",
+      "tooLarge": "Bu dosya çok büyük.",
+      "failed": "Fotoğraf eklenmedi. Bağlantıyı kontrol edip tekrar deneyin.",
+      "codeLimit": "Bu kodla bir kodun izin verdiği kadar fotoğraf eklendi. Danışmada yeni bir kodu tarayın."
+    },
+    "concernHeading": "Fotoğraflar bu maddeye eklenir",
+    "howTitle": "Nasıl çalışır",
+    "step1": "Telefon kamerasını koda tutun ve bağlantıyı açın.",
+    "step2": "Fotoğraf çekin veya telefonda zaten olan fotoğrafları ya da PDF belgelerini seçin.",
+    "step3": "Geldikçe bu iş emrinde görünürler.",
+    "noSignIn": "Telefonda giriş yapmak gerekmez.",
+    "customerToo": "Çalışanlar kullanabilir, müşteri de: kodu okutmasına izin verin veya bağlantıyı gönderin.",
+    "scope": "Bağlantı yalnızca bu iş emrine fotoğraf ve belge ekler ve 30 dakika sonra çalışmaz.",
+    "scanHint": "Telefon kamerasıyla okutun",
+    "chooseDocument": "Belge yükle",
+    "privacy": "Buraya eklediklerinizi yalnızca servis görür."
   }
 }

+ 23 - 1
messages/tr/settings.json

@@ -1229,7 +1229,29 @@
     "dateLabel": "Tarih",
     "dateTimeLabel": "Tarih & Saat",
     "saveSettings": "Ayarları Kaydet",
-    "saved": "Ayarlar kaydedildi"
+    "saved": "Ayarlar kaydedildi",
+    "fontLabel": "Yazı tipi",
+    "fontHint": "Uygulamada kullanılacak yazı tiplerini seçin. Seçim bu cihazda kaydedilir.",
+    "fontSampleHeading": "İş emri",
+    "fontSampleText": "Ateşleme bobinini ve bujileri değiştir",
+    "fonts": {
+      "default": {
+        "name": "Varsayılan",
+        "description": "Geist, sade ve nötr."
+      },
+      "workshop": {
+        "name": "Atölye",
+        "description": "Dar büyük harfli başlıklarla IBM Plex, bir iş kartı gibi."
+      },
+      "precise": {
+        "name": "Hassas",
+        "description": "Inter ve JetBrains Mono. Tablo ve rakamlarla dolu ekranlar için yapıldı."
+      },
+      "legible": {
+        "name": "Okunaklı",
+        "description": "Atkinson Hyperlegible. 0 ile O, 1 ile l asla birbirine benzemez; atölyenin öbür ucundan bile kolayca okunur."
+      }
+    }
   },
   "data": {
     "title": "Veri Yönetimi",

+ 8 - 2
messages/tr/workBoard.json

@@ -78,7 +78,10 @@
     "addTechnician": "Teknisyen Ekle",
     "standaloneOnly": "Panoda giriş yapmayan biri için bir isim, örneğin çırak ya da taşeron. Uygulamayı kullanan bir tamirciyi Ekip sayfasından ekleyin.",
     "linkedTo": "{name} olarak giriş yapar",
-    "linkedManagedOnTeam": "Uygulama erişimiyle birlikte Ekip sayfasından yönetilir."
+    "linkedManagedOnTeam": "Uygulama erişimiyle birlikte Ekip sayfasından yönetilir.",
+    "skills": "Uzmanlıklar",
+    "skillsPlaceholder": "Arıza tespiti, elektrikli araç sertifikalı",
+    "skillsHint": "İşlerin dağıtıldığı yerde adın altında gösterilir. İsteğe bağlı."
   },
   "jobDetail": {
     "viewDetails": "Ayrıntıları Görüntüle",
@@ -154,6 +157,9 @@
     "workBay": "Çalışma alanı",
     "status": "Durum",
     "noTime": "Henüz saat yok",
-    "none": "Henüz kimse"
+    "none": "Henüz kimse",
+    "promised": "Söz verilen",
+    "promiseLate": "Gecikti",
+    "promiseOverdue": "Müşteriye geçmiş bir zaman için söz verildi"
   }
 }

+ 7 - 1
messages/tr/workOrders.json

@@ -31,7 +31,9 @@
       "status": "Durum",
       "tech": "Teknisyen",
       "date": "Tarih",
-      "total": "Toplam"
+      "total": "Toplam",
+      "promised": "Söz verilen",
+      "promisedOverdue": "{date} için söz verildi, gecikti"
     },
     "emailSubject": "Araç Durum Güncellemesi: {status}",
     "changeStatus": "Durumu değiştir",
@@ -39,6 +41,10 @@
       "open": "İş emrini aç",
       "openVehicle": "Aracı aç",
       "openCustomer": "Müşteriyi aç"
+    },
+    "dueToday": "Bugün teslim",
+    "columns": {
+      "label": "Sütunlar"
     }
   },
   "vehiclePicker": {

+ 28 - 0
prisma/migrations/20260919142636_work_order_overhaul/migration.sql

@@ -0,0 +1,28 @@
+-- AlterTable
+ALTER TABLE "service_attachments" ADD COLUMN     "concernId" TEXT;
+
+-- AlterTable
+ALTER TABLE "service_concerns" ADD COLUMN     "cause" TEXT,
+ADD COLUMN     "confirmation" TEXT,
+ADD COLUMN     "confirmedAt" TIMESTAMP(3),
+ADD COLUMN     "confirmedById" TEXT,
+ADD COLUMN     "correction" TEXT;
+
+-- AlterTable
+ALTER TABLE "service_records" ADD COLUMN     "createdById" TEXT,
+ADD COLUMN     "promisedAt" TIMESTAMP(3);
+
+-- AlterTable
+ALTER TABLE "technicians" ADD COLUMN     "skills" TEXT;
+
+-- CreateIndex
+CREATE INDEX "service_attachments_concernId_idx" ON "service_attachments"("concernId");
+
+-- AddForeignKey
+ALTER TABLE "service_records" ADD CONSTRAINT "service_records_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "service_concerns" ADD CONSTRAINT "service_concerns_confirmedById_fkey" FOREIGN KEY ("confirmedById") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "service_attachments" ADD CONSTRAINT "service_attachments_concernId_fkey" FOREIGN KEY ("concernId") REFERENCES "service_concerns"("id") ON DELETE SET NULL ON UPDATE CASCADE;

+ 2 - 0
prisma/schema/auth.prisma

@@ -44,6 +44,8 @@ model User {
   organizationMembers    OrganizationMember[]
   dashboardWidgets       DashboardWidget[]
   auditLogs              AuditLog[]
+  openedServiceRecords   ServiceRecord[]        @relation("ServiceRecordCreatedBy")
+  confirmedConcerns      ServiceConcern[]       @relation("ConcernConfirmedBy")
   technicians            Technician[]
   stockMovements         StockMovement[]
   tireWarehouses         TireWarehouse[]

+ 34 - 1
prisma/schema/service-records.prisma

@@ -9,6 +9,9 @@ model ServiceRecord {
   serviceDate     DateTime  @default(now())
   startDateTime   DateTime?
   endDateTime     DateTime?
+  /// When the customer was told the vehicle would be ready. Not the booking:
+  /// `endDateTime` is when the work is planned to finish, this is the promise.
+  promisedAt      DateTime?
   shopName        String?
   techName        String?
   parts           String?
@@ -53,6 +56,12 @@ model ServiceRecord {
   createdAt    DateTime  @default(now())
   updatedAt    DateTime  @updatedAt
 
+  /// Who opened the job, when a person did. Null for work the system made
+  /// (a customer's online booking, a recurring invoice, an import) and for
+  /// everything older than this column.
+  createdById String?
+  createdBy   User?   @relation("ServiceRecordCreatedBy", fields: [createdById], references: [id], onDelete: SetNull)
+
   /// First time this invoice reached the customer, by email or share link.
   /// Distinct from sharedAt, which tracks only the link and is cleared when
   /// the link is revoked; sending is not undone by withdrawing the link.
@@ -180,12 +189,28 @@ model ServiceConcern {
   createdAt   DateTime @default(now())
   updatedAt   DateTime @updatedAt
 
+  /// The rest of the story this concern starts: condition, cause, correction,
+  /// confirmation. `description` is the condition. The cause is the sentence
+  /// the customer is told; the structured findings hang off `findings` below.
+  cause        String?
+  /// What was done about it.
+  correction   String?
+  /// How the fix was checked: the test drive, the re-scan, the symptom gone.
+  confirmation String?
+  /// Set when somebody ticks the concern as confirmed, cleared when unticked.
+  /// Stamped by the server from the session, never taken from the client.
+  confirmedAt   DateTime?
+  confirmedById String?
+  confirmedBy   User?     @relation("ConcernConfirmedBy", fields: [confirmedById], references: [id], onDelete: SetNull)
+
   serviceRecordId String
   serviceRecord   ServiceRecord @relation(fields: [serviceRecordId], references: [id], onDelete: Cascade)
 
   /// What the shop found when it went looking. Empty means nobody has answered
   /// this one yet, which is worth being able to ask about before closing a job.
-  findings VehicleFinding[]
+  findings    VehicleFinding[]
+  /// Photos and video filed under this concern.
+  attachments ServiceAttachment[]
 
   @@index([serviceRecordId])
   @@map("service_concerns")
@@ -229,7 +254,15 @@ model ServiceAttachment {
   serviceRecordId String
   serviceRecord   ServiceRecord @relation(fields: [serviceRecordId], references: [id], onDelete: Cascade)
 
+  /// The concern this photo or video is evidence for, when it was added from
+  /// that concern's row. It is still one of the job's files, listed with the
+  /// rest. SetNull, because a picture of the car outlives the sentence it
+  /// was filed under.
+  concernId String?
+  concern   ServiceConcern? @relation(fields: [concernId], references: [id], onDelete: SetNull)
+
   @@index([serviceRecordId])
+  @@index([concernId])
   @@map("service_attachments")
 }
 

+ 3 - 0
prisma/schema/technicians.prisma

@@ -5,6 +5,9 @@ model Technician {
   isActive      Boolean @default(true)
   sortOrder     Int     @default(0)
   dailyCapacity Int     @default(480)
+  /// What this person is good at, in the workshop's own words ("Diagnostics,
+  /// EV certified"). Shown beside their name where work is handed out.
+  skills        String?
 
   userId String?
   user   User?   @relation(fields: [userId], references: [id], onDelete: SetNull)

+ 1 - 0
public/images/integrations/speech-to-text.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48" role="img" aria-label="Speech to text"><title>Speech to text</title><rect width="48" height="48" rx="11" fill="#1f2937"/><g fill="none" stroke="#fff" stroke-width="2.2" stroke-linejoin="round" stroke-linecap="round"><rect x="19.5" y="10" width="9" height="16" rx="4.5"/><path d="M14 23a10 10 0 0 0 20 0"/><path d="M24 33v5M19 38h10"/></g></svg>

+ 258 - 0
src/__tests__/api/photo-handoff.test.ts

@@ -0,0 +1,258 @@
+/**
+ * @vitest-environment node
+ *
+ * "Add from phone": the signed link a work order's QR code carries, and the
+ * public route a phone posts photos to with it. Nobody is signed in on that
+ * route, so what it lets through and how much it may write are the point.
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+vi.mock('@/lib/db', () => ({
+  db: {
+    serviceRecord: { findFirst: vi.fn() },
+    serviceConcern: { findFirst: vi.fn() },
+    serviceAttachment: { count: vi.fn(), create: vi.fn() },
+  },
+}))
+vi.mock('@/lib/rate-limit', () => ({ rateLimit: vi.fn(() => null) }))
+vi.mock('@/lib/audit', () => ({ logAudit: vi.fn() }))
+vi.mock('@/lib/features', () => ({ getFeatures: vi.fn() }))
+vi.mock('@/lib/image-upload.server', () => ({ compressPhoto: vi.fn() }))
+vi.mock('@/lib/files/manager', () => ({ discardUnsavedUpload: vi.fn(async () => undefined) }))
+vi.mock('@/lib/upload-root', () => ({ uploadsRoot: () => '/tmp/uploads' }))
+vi.mock('node:fs/promises', () => {
+  const fs = { mkdir: vi.fn(), writeFile: vi.fn() }
+  return { ...fs, default: fs }
+})
+
+import { writeFile } from 'node:fs/promises'
+import { db } from '@/lib/db'
+import { getFeatures } from '@/lib/features'
+import { compressPhoto } from '@/lib/image-upload.server'
+import { discardUnsavedUpload } from '@/lib/files/manager'
+import {
+  createPhotoHandoffToken,
+  PHOTO_HANDOFF_TTL_SECONDS,
+  verifyPhotoHandoffToken,
+} from '@/lib/photo-handoff'
+import { POST } from '@/app/api/public/photo-handoff/[token]/route'
+
+const HANDOFF = {
+  organizationId: 'org-1',
+  serviceRecordId: 'job-1',
+  concernId: 'concern-1',
+  userId: 'user-1',
+}
+
+function post(token: string, file?: File) {
+  const body = new FormData()
+  if (file) body.append('file', file)
+  return POST(
+    new Request(`http://app.test/api/public/photo-handoff/${token}`, { method: 'POST', body }),
+    {
+      params: Promise.resolve({ token }),
+    }
+  )
+}
+
+const photo = () =>
+  new File([new Uint8Array([0xff, 0xd8, 0xff, 0xe0])], 'IMG_0001.HEIC', { type: 'image/heic' })
+
+beforeEach(() => {
+  process.env.BETTER_AUTH_SECRET = 'test-secret-for-photo-handoff'
+  vi.mocked(db.serviceRecord.findFirst)
+    .mockReset()
+    .mockResolvedValue({ id: 'job-1', invoiceNumber: 'WO-7' } as never)
+  vi.mocked(db.serviceConcern.findFirst)
+    .mockReset()
+    .mockResolvedValue({ id: 'concern-1' } as never)
+  vi.mocked(db.serviceAttachment.count).mockReset().mockResolvedValue(0)
+  vi.mocked(db.serviceAttachment.create)
+    .mockReset()
+    .mockResolvedValue({ id: 'att-1' } as never)
+  vi.mocked(getFeatures)
+    .mockReset()
+    .mockResolvedValue({ maxImagesPerService: 999999 } as never)
+  vi.mocked(compressPhoto)
+    .mockReset()
+    .mockResolvedValue({ data: Buffer.from('jpeg'), width: 1200, height: 900 })
+  vi.mocked(writeFile).mockReset()
+})
+
+const pdf = (bytes = '%PDF-1.7\n%ok') =>
+  new File([bytes], 'old-invoice.pdf', { type: 'application/pdf' })
+
+describe('the signed link', () => {
+  it('reads back what it was made with', () => {
+    const { token } = createPhotoHandoffToken(HANDOFF)
+    const check = verifyPhotoHandoffToken(token)
+    expect(check.ok && check.handoff).toMatchObject(HANDOFF)
+  })
+
+  it('lapses after half an hour, and says so rather than calling it broken', () => {
+    const now = Date.UTC(2026, 8, 19, 12)
+    const { token } = createPhotoHandoffToken(HANDOFF, now)
+    expect(verifyPhotoHandoffToken(token, now + (PHOTO_HANDOFF_TTL_SECONDS - 1) * 1000).ok).toBe(
+      true
+    )
+    expect(verifyPhotoHandoffToken(token, now + PHOTO_HANDOFF_TTL_SECONDS * 1000)).toEqual({
+      ok: false,
+      reason: 'expired',
+    })
+  })
+
+  it('refuses a link that was changed, pointed at another job, or signed with another secret', () => {
+    const { token } = createPhotoHandoffToken(HANDOFF)
+    const [prefix, body, signature] = token.split('.')
+    const otherJob = Buffer.from(
+      JSON.stringify({ ...JSON.parse(Buffer.from(body, 'base64url').toString()), r: 'job-2' })
+    ).toString('base64url')
+    expect(verifyPhotoHandoffToken(`${prefix}.${otherJob}.${signature}`).ok).toBe(false)
+    expect(verifyPhotoHandoffToken(`${prefix}.${body}.${signature.slice(0, -2)}xx`).ok).toBe(false)
+    expect(verifyPhotoHandoffToken(`wa1.${body}.${signature}`).ok).toBe(false)
+    expect(verifyPhotoHandoffToken('nonsense').ok).toBe(false)
+
+    process.env.BETTER_AUTH_SECRET = 'a-different-secret'
+    expect(verifyPhotoHandoffToken(token)).toEqual({ ok: false, reason: 'invalid' })
+  })
+})
+
+describe('the phone upload route', () => {
+  it('stores the photo compressed, as a JPEG, under the concern the code names', async () => {
+    const { token } = createPhotoHandoffToken(HANDOFF)
+    const res = await post(token, photo())
+
+    expect(res.status).toBe(201)
+    expect(compressPhoto).toHaveBeenCalledOnce()
+    expect(vi.mocked(writeFile).mock.calls[0][0]).toMatch(
+      /^\/tmp\/uploads\/org-1\/services\/[0-9a-f-]+\.jpg$/
+    )
+    expect(vi.mocked(db.serviceAttachment.create).mock.calls[0][0].data).toMatchObject({
+      serviceRecordId: 'job-1',
+      concernId: 'concern-1',
+      category: 'image',
+      fileType: 'image/jpeg',
+      fileName: 'IMG_0001.jpg',
+      fileSize: 4,
+    })
+  })
+
+  it('files the photo on the job when its concern has since been removed', async () => {
+    vi.mocked(db.serviceConcern.findFirst).mockResolvedValue(null)
+    const { token } = createPhotoHandoffToken(HANDOFF)
+    expect((await post(token, photo())).status).toBe(201)
+    expect(vi.mocked(db.serviceAttachment.create).mock.calls[0][0].data.concernId).toBeNull()
+  })
+
+  it('tells an expired code from a broken one', async () => {
+    const { token } = createPhotoHandoffToken(
+      HANDOFF,
+      Date.now() - PHOTO_HANDOFF_TTL_SECONDS * 1000 - 1
+    )
+    const expired = await post(token, photo())
+    expect(expired.status).toBe(410)
+    expect(await expired.json()).toEqual({ code: 'expired' })
+
+    const broken = await post('ph1.e30.nope', photo())
+    expect(broken.status).toBe(404)
+    expect(db.serviceAttachment.create).not.toHaveBeenCalled()
+  })
+
+  it('refuses a code for a job that is not in the workshop it names', async () => {
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(null)
+    const { token } = createPhotoHandoffToken(HANDOFF)
+    expect((await post(token, photo())).status).toBe(404)
+  })
+
+  it('refuses what is not a photo, by type and by content, before writing anything', async () => {
+    const { token } = createPhotoHandoffToken(HANDOFF)
+    const pdf = new File(['%PDF'], 'report.pdf', { type: 'application/pdf' })
+    expect(await (await post(token, pdf)).json()).toEqual({ code: 'type' })
+
+    vi.mocked(compressPhoto).mockRejectedValue(new Error('Not an image we can read'))
+    expect(await (await post(token, photo())).json()).toEqual({ code: 'type' })
+    expect(writeFile).not.toHaveBeenCalled()
+  })
+
+  it('refuses an upload too large to be a phone photo without decoding it', async () => {
+    const { token } = createPhotoHandoffToken(HANDOFF)
+    const huge = new File([new Uint8Array(15 * 1024 * 1024 + 1)], 'big.jpg', { type: 'image/jpeg' })
+    expect(await (await post(token, huge)).json()).toEqual({ code: 'tooLarge' })
+    expect(compressPhoto).not.toHaveBeenCalled()
+  })
+
+  it('keeps a PDF as it is, and off the invoice until the workshop has looked at it', async () => {
+    vi.mocked(getFeatures).mockResolvedValue({
+      maxImagesPerService: 999999,
+      maxDocumentsPerService: 999999,
+    } as never)
+    const { token } = createPhotoHandoffToken(HANDOFF)
+    const res = await post(token, pdf())
+
+    expect(res.status).toBe(201)
+    expect(compressPhoto).not.toHaveBeenCalled()
+    expect(vi.mocked(writeFile).mock.calls[0][0]).toMatch(/\.pdf$/)
+    expect(vi.mocked(db.serviceAttachment.create).mock.calls[0][0].data).toMatchObject({
+      category: 'document',
+      fileType: 'application/pdf',
+      fileName: 'old-invoice.pdf',
+      includeInInvoice: false,
+    })
+  })
+
+  it('refuses a file that only calls itself a PDF, and a PDF too large to take', async () => {
+    vi.mocked(getFeatures).mockResolvedValue({ maxDocumentsPerService: 999999 } as never)
+    const { token } = createPhotoHandoffToken(HANDOFF)
+    expect(await (await post(token, pdf('<html>not a pdf</html>'))).json()).toEqual({
+      code: 'type',
+    })
+    const big = new File([`%PDF-${'x'.repeat(10 * 1024 * 1024)}`], 'big.pdf', {
+      type: 'application/pdf',
+    })
+    expect(await (await post(token, big)).json()).toEqual({ code: 'tooLarge' })
+    expect(writeFile).not.toHaveBeenCalled()
+  })
+
+  it('stops at twenty documents on a job', async () => {
+    vi.mocked(getFeatures).mockResolvedValue({ maxDocumentsPerService: 999999 } as never)
+    vi.mocked(db.serviceAttachment.count).mockResolvedValueOnce(20).mockResolvedValueOnce(0)
+    const { token } = createPhotoHandoffToken(HANDOFF)
+    expect(await (await post(token, pdf())).json()).toEqual({ code: 'limit' })
+  })
+
+  it('removes the file again when its row cannot be written', async () => {
+    vi.mocked(db.serviceAttachment.create).mockRejectedValue(new Error('database gone'))
+    const { token } = createPhotoHandoffToken(HANDOFF)
+    await expect(post(token, photo())).rejects.toThrow('database gone')
+    const written = String(vi.mocked(writeFile).mock.calls[0][0])
+    expect(discardUnsavedUpload).toHaveBeenCalledWith('org-1', 'services', written.split('/').pop())
+  })
+
+  it('stops at a hundred photos on a job, even on a plan without a cap', async () => {
+    vi.mocked(db.serviceAttachment.count).mockResolvedValueOnce(100).mockResolvedValueOnce(0)
+    const { token } = createPhotoHandoffToken(HANDOFF)
+    const res = await post(token, photo())
+    expect(res.status).toBe(409)
+    expect(await res.json()).toEqual({ code: 'limit' })
+    expect(compressPhoto).not.toHaveBeenCalled()
+  })
+
+  it('stops at the plan cap when it is lower', async () => {
+    vi.mocked(getFeatures).mockResolvedValue({ maxImagesPerService: 10 } as never)
+    vi.mocked(db.serviceAttachment.count).mockResolvedValueOnce(10).mockResolvedValueOnce(0)
+    const { token } = createPhotoHandoffToken(HANDOFF)
+    expect(await (await post(token, photo())).json()).toEqual({ code: 'limit' })
+  })
+
+  it('stops one code at thirty photos, counted from when it was shown', async () => {
+    vi.mocked(db.serviceAttachment.count).mockResolvedValueOnce(30).mockResolvedValueOnce(30)
+    const now = Date.now()
+    const { token } = createPhotoHandoffToken(HANDOFF, now)
+    expect(await (await post(token, photo())).json()).toEqual({ code: 'codeLimit' })
+
+    const sinceShown = vi.mocked(db.serviceAttachment.count).mock.calls[1][0]?.where?.createdAt as {
+      gte: Date
+    }
+    expect(Math.abs(sinceShown.gte.getTime() - now)).toBeLessThan(1000)
+  })
+})

+ 133 - 0
src/__tests__/api/tech-labor-live.test.ts

@@ -0,0 +1,133 @@
+/**
+ * @vitest-environment node
+ *
+ * Telling the desk that a technician billed time.
+ *
+ * The app posts the line, the job's totals are recalculated, and a desk with
+ * that work order open has to learn about it without reloading. That last
+ * step is one event on the work board channel, the same channel the clock
+ * uses, carrying ids only: whoever listens reads the job back themselves.
+ *
+ * A line written with nobody told is the case this guards. It looks correct
+ * everywhere except on the screen of the person writing the invoice, who
+ * saves the list they loaded and deletes the technician's work with it.
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const state = {
+  labor: { id: 'lab-1', description: 'Timing belt', hours: 1.5, rate: 900, total: 1350 },
+  job: {
+    id: 'rec-1',
+    title: 'Service',
+    taxRate: 25,
+    taxInclusive: false,
+    taxComponents: null,
+    discountType: null,
+    discountValue: 0,
+  } as Record<string, unknown> | null,
+  transactionThrows: false,
+}
+
+vi.mock('@/lib/db', () => {
+  const tx = {
+    serviceLabor: {
+      create: vi.fn(async () => state.labor),
+      aggregate: vi.fn(async () => ({ _sum: { total: 1350 } })),
+    },
+    servicePart: { aggregate: vi.fn(async () => ({ _sum: { total: 0 } })) },
+    serviceRecord: { update: vi.fn(async () => ({})) },
+  }
+  return {
+    db: {
+      serviceRecord: { findFirst: vi.fn(async () => state.job) },
+      appSetting: { findFirst: vi.fn(async () => ({ value: '900' })) },
+      $transaction: vi.fn(async (run: (t: typeof tx) => Promise<unknown>) => {
+        if (state.transactionThrows) throw new Error('rolled back')
+        return run(tx)
+      }),
+    },
+  }
+})
+
+vi.mock('@/lib/document-lock.server', () => ({
+  assertInvoiceEditable: vi.fn(async () => undefined),
+}))
+
+vi.mock('@/lib/with-api-auth', async () => {
+  const actual = await vi.importActual<typeof import('@/lib/with-api-auth')>('@/lib/with-api-auth')
+  return {
+    ...actual,
+    withApiAuth: vi.fn(
+      async (
+        _request: Request,
+        handler: (ctx: {
+          organizationId: string
+          technicianIds: string[]
+          isAdmin: boolean
+        }) => Promise<Response>
+      ) => handler({ organizationId: 'org-1', technicianIds: ['tech-1'], isAdmin: false })
+    ),
+  }
+})
+
+import { notificationBus } from '@/lib/notification-bus'
+import { POST } from '@/app/api/v1/tech/jobs/[id]/labor/route'
+
+function post(body: unknown) {
+  return POST(
+    new Request('https://app.test/api/v1/tech/jobs/rec-1/labor', {
+      method: 'POST',
+      body: JSON.stringify(body),
+    }),
+    { params: Promise.resolve({ id: 'rec-1' }) }
+  )
+}
+
+let heard: unknown[]
+beforeEach(() => {
+  state.job = {
+    id: 'rec-1',
+    title: 'Service',
+    taxRate: 25,
+    taxInclusive: false,
+    taxComponents: null,
+    discountType: null,
+    discountValue: 0,
+  }
+  state.transactionThrows = false
+  heard = []
+  notificationBus.removeAllListeners('workboard')
+  notificationBus.on('workboard', (event: unknown) => heard.push(event))
+})
+
+describe('a technician adding a line of work', () => {
+  it('tells the workshop which job gained it, and nothing more', async () => {
+    const response = await post({ description: 'Timing belt', hours: 1.5 })
+
+    expect(response.status).toBe(201)
+    expect(heard).toEqual([
+      {
+        type: 'job_labor_added',
+        organizationId: 'org-1',
+        serviceRecordId: 'rec-1',
+        laborId: 'lab-1',
+      },
+    ])
+  })
+
+  it('says nothing when the job is not this technician’s', async () => {
+    state.job = null
+
+    const response = await post({ hours: 1 })
+
+    expect(response.status).toBe(404)
+    expect(heard).toEqual([])
+  })
+
+  it('says nothing when the write rolled back', async () => {
+    state.transactionThrows = true
+
+    await expect(post({ hours: 1 })).rejects.toThrow('rolled back')
+    expect(heard).toEqual([])
+  })
+})

+ 60 - 0
src/__tests__/features/ai/transcription.test.ts

@@ -0,0 +1,60 @@
+import { describe, expect, it } from 'vitest'
+import {
+  canTranscribe,
+  liveTranscriptionSession,
+  transcriptionLanguage,
+  transcriptionModel,
+  transcriptionPrompt,
+} from '@/features/ai/Lib/transcription'
+
+describe('speech to text through the AI connection', () => {
+  it('is offered for vendors that have a transcription endpoint, and no others', () => {
+    expect(canTranscribe('openai')).toBe(true)
+    expect(canTranscribe('openai-compatible')).toBe(true)
+    expect(canTranscribe('anthropic')).toBe(false)
+    expect(canTranscribe(null)).toBe(false)
+  })
+
+  it('asks a compatible server for the name those servers answer to', () => {
+    expect(transcriptionModel('openai')).toBe('gpt-4o-mini-transcribe')
+    expect(transcriptionModel('openai-compatible')).toBe('whisper-1')
+  })
+
+  it('passes a two-letter language and leaves anything else to the model', () => {
+    expect(transcriptionLanguage('de')).toBe('de')
+    expect(transcriptionLanguage('pt-BR')).toBe('pt')
+    // The app's Bokmål is the speech models' Norwegian.
+    expect(transcriptionLanguage('nb')).toBe('no')
+    expect(transcriptionLanguage('auto')).toBeUndefined()
+    expect(transcriptionLanguage('')).toBeUndefined()
+    expect(transcriptionLanguage(null)).toBeUndefined()
+  })
+
+  it('names the vehicle in the hint when there is one', () => {
+    expect(transcriptionPrompt({ year: 2021, make: 'Škoda', model: 'Octavia' })).toContain(
+      'The vehicle is a 2021 Škoda Octavia.'
+    )
+    expect(transcriptionPrompt({})).not.toContain('The vehicle is')
+  })
+
+  it('ends the hint with what was said just before, so the model reads on from it', () => {
+    expect(transcriptionPrompt({}, ' and then it started to ')).toMatch(/and then it started to$/)
+  })
+
+  it('opens a live session that only transcribes, and leaves the language out for auto', () => {
+    const auto = liveTranscriptionSession({ make: 'BMW', model: '5-Serie' }, undefined)
+    expect(auto.type).toBe('transcription')
+    // The live model has no turn detection and refuses a session that asks for it.
+    expect(auto.audio.input.turn_detection).toBeNull()
+    expect(auto.audio.input.transcription).toEqual({
+      model: 'gpt-live-transcribe',
+      keywords: expect.arrayContaining(['BMW', '5-Serie', 'brake pads']),
+    })
+    const norwegian = liveTranscriptionSession({}, 'no', false)
+    expect(norwegian.audio.input.transcription).toEqual({
+      model: 'gpt-live-transcribe',
+      language: 'no',
+    })
+    expect(norwegian.audio.input).not.toHaveProperty('noise_reduction')
+  })
+})

+ 138 - 0
src/__tests__/features/backup/restore-overrides.test.ts

@@ -0,0 +1,138 @@
+/**
+ * @vitest-environment node
+ *
+ * What a restore writes has to be a column the model actually has.
+ *
+ * `restoreRows` takes an override object stamped onto every row: the
+ * workshop, the importing user, the parent id. Prisma refuses an unknown
+ * field outright, and a restore is one transaction, so a single wrong key
+ * does not lose one table, it fails the whole import and rolls the workshop
+ * back to nothing.
+ *
+ * Two of these were sitting in the route unnoticed, on vehicle findings and
+ * recurring invoices, because neither was ever written into a backup: the
+ * export did not carry them, so the code that reads them back never ran with
+ * anything in it. Both are carried now, which is exactly when a wrong key
+ * would have started failing real restores.
+ *
+ * This reads the route rather than running it: the mistake is visible in the
+ * source, and a test that needed a database would not be run often enough to
+ * catch it.
+ */
+import fs from 'node:fs'
+import path from 'node:path'
+import { describe, expect, it } from 'vitest'
+import { readPrismaSchema } from '@/__tests__/stubs/prisma-schema'
+
+const ROOT = process.cwd()
+const source = fs.readFileSync(
+  path.join(ROOT, 'src/app/api/protected/backup/import/route.ts'),
+  'utf-8'
+)
+const schema = readPrismaSchema(ROOT)
+
+/** The arguments of one call, split at top level so nested objects survive. */
+function argumentsOf(call: string): string[] {
+  const args: string[] = []
+  let depth = 0
+  let current = ''
+  let quote = ''
+  for (const char of call) {
+    if (quote) {
+      current += char
+      if (char === quote) quote = ''
+      continue
+    }
+    if (char === "'" || char === '"' || char === '`') {
+      quote = char
+      current += char
+      continue
+    }
+    if ('([{'.includes(char)) depth++
+    if (')]}'.includes(char)) {
+      depth--
+      // The closing bracket of the call itself.
+      if (depth < 0) break
+    }
+    if (char === ',' && depth === 0) {
+      args.push(current.trim())
+      current = ''
+      continue
+    }
+    current += char
+  }
+  if (current.trim()) args.push(current.trim())
+  return args
+}
+
+/** Every `restoreRows(...)` in the route, as model plus the keys it stamps on. */
+function restoreCalls(): { model: string; keys: string[]; where: number }[] {
+  const calls: { model: string; keys: string[]; where: number }[] = []
+  // `await`, so the function's own definition is not read as a call.
+  const marker = 'await restoreRows('
+  for (let at = source.indexOf(marker); at !== -1; at = source.indexOf(marker, at + 1)) {
+    const args = argumentsOf(source.slice(at + marker.length))
+    const accessor = /tx\.(\w+)\.\w+/.exec(args[1] ?? '')
+    expect(accessor, `restoreRows at ${at} writes through no tx accessor`).toBeTruthy()
+    const model = (accessor as RegExpExecArray)[1]
+    // The override is the fourth argument; the fifth names file columns and
+    // is not written to the row.
+    const override = args[3] ?? ''
+    const keys = [...override.matchAll(/(?:^|[{,])\s*(\w+)\s*[:,}]/g)].map((m) => m[1])
+    calls.push({ model, keys, where: source.slice(0, at).split('\n').length })
+  }
+  return calls
+}
+
+/** The scalar columns of a model, as the schema declares them. */
+function columnsOf(model: string): Set<string> {
+  const pascal = model.charAt(0).toUpperCase() + model.slice(1)
+  const found = new RegExp(`^model ${pascal} \\{([\\s\\S]*?)^\\}`, 'm').exec(schema)
+  expect(found, `no model ${pascal} in the schema`).toBeTruthy()
+  const columns = new Set<string>()
+  for (const line of (found as RegExpExecArray)[1].split('\n')) {
+    const field = /^\s{2}(\w+)\s+\w+/.exec(line)
+    if (field) columns.add(field[1])
+  }
+  return columns
+}
+
+describe('what a restore stamps on every row', () => {
+  const calls = restoreCalls()
+
+  it('finds the restores in the route', () => {
+    // Guards the guard: a parser that silently matched nothing would make
+    // every assertion below pass.
+    expect(calls.length).toBeGreaterThan(10)
+    expect(calls.map((call) => call.model)).toContain('statusReport')
+  })
+
+  it('is a column the model has', () => {
+    const wrong = calls.flatMap(({ model, keys, where }) => {
+      const columns = columnsOf(model)
+      return keys
+        .filter((key) => !columns.has(key))
+        .map((key) => `${model}.${key} (route line ${where})`)
+    })
+
+    expect(
+      wrong,
+      `Prisma refuses an unknown field, and takes the whole restore with it: ${wrong.join(', ')}`
+    ).toEqual([])
+  })
+
+  it('stamps the workshop only where the row carries one', () => {
+    // The reverse mistake: a row that has an organizationId and is restored
+    // without one belongs to no workshop, and a query scoped by workshop
+    // never finds it again.
+    const missing = calls
+      .filter(({ model }) => columnsOf(model).has('organizationId'))
+      .filter(({ keys }) => !keys.includes('organizationId'))
+      .map(({ model, where }) => `${model} (route line ${where})`)
+
+    expect(
+      missing,
+      `Restored without a workshop, so nothing will find them: ${missing.join(', ')}`
+    ).toEqual([])
+  })
+})

+ 21 - 1
src/__tests__/features/inspection-reminders/booking-actions.test.ts

@@ -1,10 +1,30 @@
 import { beforeEach, describe, expect, it, vi } from 'vitest'
 
 const inspectionReminderSend = { findUnique: vi.fn(), update: vi.fn() }
-const serviceRecord = { create: vi.fn(), findUnique: vi.fn(), deleteMany: vi.fn() }
+const serviceRecord = {
+  create: vi.fn(),
+  findUnique: vi.fn(),
+  findFirst: vi.fn(),
+  deleteMany: vi.fn(),
+}
 const serviceRequest = { create: vi.fn(), findUnique: vi.fn(), updateMany: vi.fn() }
 const scheduledMessage = { create: vi.fn() }
 const appSetting = { findUnique: vi.fn() }
+// These tests are about workshop scoping, not files: the file manager and the
+// files a delete collects have tests of their own (src/__tests__/lib/files).
+vi.mock('@/lib/files/collect', () => ({
+  serviceRecordFileUrls: vi.fn(async () => []),
+  inspectionFileUrls: vi.fn(async () => []),
+  vehicleFileUrls: vi.fn(async () => []),
+  quoteFileUrls: vi.fn(async () => []),
+  tireSetFileUrls: vi.fn(async () => []),
+  inventoryPartFileUrls: vi.fn(async () => []),
+}))
+vi.mock('@/lib/files/manager', () => ({
+  releaseFiles: vi.fn(async () => ({ removed: [], kept: [], skipped: [] })),
+  parseStoredFileUrl: vi.fn(() => null),
+}))
+
 vi.mock('@/lib/db', () => ({
   db: { inspectionReminderSend, serviceRecord, serviceRequest, scheduledMessage, appSetting },
 }))

+ 3 - 1
src/__tests__/features/integrations/ai.test.ts

@@ -222,7 +222,9 @@ describe('plan gates', () => {
       .filter((m) => connectorAllowed(m, free))
       .map((m) => m.id)
       .sort()
-    expect(reachable).toEqual(['anthropic', 'openai', 'openai-compatible'])
+    // Speech to text is an AI connector too: dictation is part of what the AI
+    // plan feature pays for, so it is reachable wherever the chat vendors are.
+    expect(reachable).toEqual(['anthropic', 'openai', 'openai-compatible', 'speech-to-text'])
 
     const pro = PLAN_FEATURES.pro
     expect(connectorAllowed(getManifest('openai')!, pro)).toBe(true)

+ 23 - 0
src/__tests__/features/integrations/speech.test.ts

@@ -0,0 +1,23 @@
+import { describe, expect, it, vi } from 'vitest'
+
+vi.mock('@/lib/db', () => ({ db: {} }))
+vi.mock('@/lib/cloud-instance', () => ({ isCloudInstance: () => true }))
+
+import { dictationModeOf } from '@/features/integrations/Lib/speech'
+import { getManifest } from '@/integrations/registry'
+
+describe('speech to text connection', () => {
+  it('is always the speech model unless the workshop lets people choose', () => {
+    expect(dictationModeOf({ mode: 'choice' })).toBe('choice')
+    expect(dictationModeOf({ mode: 'ai' })).toBe('ai')
+    expect(dictationModeOf({})).toBe('ai')
+    expect(dictationModeOf(null)).toBe('ai')
+  })
+
+  it('does not offer a server address on the cloud instance', () => {
+    const manifest = getManifest('speech-to-text')
+    expect(manifest?.auth.type).toBe('api-key')
+    const keys = manifest?.auth.type === 'api-key' ? manifest.auth.fields.map((f) => f.key) : []
+    expect(keys).toEqual(['apiKey'])
+  })
+})

+ 15 - 0
src/__tests__/features/onboarding/sample-data.test.ts

@@ -16,6 +16,21 @@ vi.mock('@/lib/cached-session', () => ({
 
 vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
 
+// These tests are about workshop scoping, not files: the file manager and the
+// files a delete collects have tests of their own (src/__tests__/lib/files).
+vi.mock('@/lib/files/collect', () => ({
+  serviceRecordFileUrls: vi.fn(async () => []),
+  inspectionFileUrls: vi.fn(async () => []),
+  vehicleFileUrls: vi.fn(async () => []),
+  quoteFileUrls: vi.fn(async () => []),
+  tireSetFileUrls: vi.fn(async () => []),
+  inventoryPartFileUrls: vi.fn(async () => []),
+}))
+vi.mock('@/lib/files/manager', () => ({
+  releaseFiles: vi.fn(async () => ({ removed: [], kept: [], skipped: [] })),
+  parseStoredFileUrl: vi.fn(() => null),
+}))
+
 vi.mock('@/lib/db', () => ({
   db: {
     user: { findUnique: vi.fn() },

+ 13 - 2
src/__tests__/features/tire-hotel/backup-coverage.test.ts

@@ -62,8 +62,19 @@ describe('export', () => {
     expect(exportSource).toMatch(/tireWarehouse[\s\S]{0,200}locations:\s*true/)
   })
 
-  it.each(['measurements', 'movements', 'treatments'])('pulls %s with the set', (relation) => {
-    expect(exportSource).toMatch(new RegExp(`db\\.tireSet[\\s\\S]{0,400}${relation}`))
+  it.each([
+    'measurements',
+    'movements',
+    'treatments',
+    'images',
+  ])('pulls %s with the set', (relation) => {
+    // `images` are the condition photos of a reading, nested one deeper.
+    expect(exportSource).toMatch(new RegExp(`db\\.tireSet[\\s\\S]{0,800}${relation}`))
+  })
+
+  it('restores a reading’s condition photos, which hang off the reading', () => {
+    expect(importSource).toMatch(/tire condition photos/)
+    expect(importSource).toMatch(/tireMeasurementId: m\.id as string/)
   })
 })
 

+ 121 - 0
src/__tests__/features/vehicles/concern-story.test.ts

@@ -0,0 +1,121 @@
+/**
+ * The rules behind a concern's four steps: what is written on a save, who
+ * gets the credit for confirming, and how the editor keeps hold of a concern
+ * across the save that gives it an id.
+ */
+
+import { describe, expect, it } from 'vitest'
+import {
+  concernHasStory,
+  concernSteps,
+  concernStoryData,
+  reconcileConcernRows,
+  type ConcernRowLike,
+} from '@/features/vehicles/Lib/concernStory'
+import { findServiceFormProblem } from '@/features/vehicles/Lib/validateServiceForm'
+
+const NOW = new Date('2026-09-21T12:10:00Z')
+
+describe('concernStoryData', () => {
+  it('stamps who confirmed and when, the first time the tick arrives', () => {
+    expect(concernStoryData({ confirmed: true }, null, 'user-1', NOW)).toEqual({
+      confirmedAt: NOW,
+      confirmedById: 'user-1',
+    })
+  })
+
+  it('keeps the first stamp when somebody else saves the job later', () => {
+    const existing = { confirmedAt: new Date('2026-09-20T09:00:00Z'), confirmedById: 'user-1' }
+    expect(concernStoryData({ confirmed: true }, existing, 'user-2', NOW)).toEqual({})
+  })
+
+  it('clears the stamp when the tick is taken away', () => {
+    const existing = { confirmedAt: new Date('2026-09-20T09:00:00Z'), confirmedById: 'user-1' }
+    expect(concernStoryData({ confirmed: false }, existing, 'user-2', NOW)).toEqual({
+      confirmedAt: null,
+      confirmedById: null,
+    })
+  })
+
+  it('leaves the confirmation alone for a client that does not send it', () => {
+    expect(concernStoryData({ cause: 'Coil cracked' }, null, 'user-1', NOW)).toEqual({
+      cause: 'Coil cracked',
+    })
+  })
+
+  it('stores an emptied field as nothing rather than as spaces', () => {
+    expect(
+      concernStoryData({ cause: '  ', correction: '', confirmation: null }, null, 'u', NOW)
+    ).toEqual({ cause: null, correction: null, confirmation: null })
+  })
+})
+
+describe('concernSteps', () => {
+  it('says which of the four steps are done', () => {
+    expect(
+      concernSteps({ description: 'Engine light on', cause: 'P0303', correction: ' ' })
+    ).toEqual({ condition: true, cause: true, correction: false, confirm: false })
+  })
+})
+
+describe('a story with no condition', () => {
+  it('is refused rather than dropped with its text', () => {
+    expect(concernHasStory({ correction: 'Replaced coil' })).toBe(true)
+    expect(
+      findServiceFormProblem({
+        title: 'Job',
+        partItems: [],
+        laborItems: [],
+        concerns: [{ description: ' ', correction: 'Replaced coil' }],
+      })
+    ).toBe('concernCondition')
+  })
+
+  it('lets an untouched blank row through, as the editor always keeps one ready', () => {
+    expect(
+      findServiceFormProblem({
+        title: 'Job',
+        partItems: [],
+        laborItems: [],
+        concerns: [{ description: '' }],
+      })
+    ).toBeNull()
+  })
+})
+
+describe('reconcileConcernRows', () => {
+  const server: ConcernRowLike[] = [
+    {
+      id: 'c1',
+      description: 'Pulls right when braking',
+      sortOrder: 0,
+      confirmed: true,
+      confirmedAt: '2026-09-21T12:10:00.000Z',
+      confirmedByName: 'Jonas Berg',
+    },
+  ]
+
+  it('takes the saved rows as they are when nothing is unsaved', () => {
+    const typed: ConcernRowLike[] = [{ description: 'Pulls right when braking', sortOrder: 0 }]
+    expect(reconcileConcernRows(typed, server, false)).toBe(server)
+  })
+
+  it('gives a row typed this session the id it was saved under, keeping later edits', () => {
+    const typed: ConcernRowLike[] = [
+      { description: 'Pulls right when braking', sortOrder: 0, cause: 'typed since the save' },
+    ]
+    const [row] = reconcileConcernRows(typed, server, true)
+    expect(row.id).toBe('c1')
+    expect(row.cause).toBe('typed since the save')
+    expect(row.confirmedByName).toBe('Jonas Berg')
+  })
+
+  it('does not hand one saved id to two rows that say the same thing', () => {
+    const typed: ConcernRowLike[] = [
+      { description: 'Pulls right when braking', sortOrder: 0 },
+      { description: 'Pulls right when braking', sortOrder: 1 },
+    ]
+    const rows = reconcileConcernRows(typed, server, true)
+    expect(rows.map((r) => r.id)).toEqual(['c1', undefined])
+  })
+})

+ 24 - 0
src/__tests__/features/vehicles/header-title.test.ts

@@ -0,0 +1,24 @@
+import { describe, expect, it, vi } from 'vitest'
+
+vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }))
+
+import { shownTitle } from '@/features/vehicles/Components/service-page/modern/ModernHero'
+
+describe('the title in the work order header', () => {
+  it('is shown whole up to fifty characters', () => {
+    const fifty = 'x'.repeat(50)
+    expect(shownTitle(fifty)).toBe(fifty)
+    expect(shownTitle('2026-1067 - BS48364')).toBe('2026-1067 - BS48364')
+  })
+
+  it('is cut at fifty with an ellipsis beyond that', () => {
+    const long = 'Defender 110 - 30K Service + Off-Road Inspection and brake overhaul'
+    expect(Array.from(shownTitle(long)).length).toBeLessThanOrEqual(51)
+    expect(shownTitle(long).endsWith('…')).toBe(true)
+    expect(long.startsWith(shownTitle(long).slice(0, -1))).toBe(true)
+  })
+
+  it('counts characters, not bytes, so an emoji or an accent is one', () => {
+    expect(shownTitle('å'.repeat(50))).toBe('å'.repeat(50))
+  })
+})

+ 150 - 0
src/__tests__/features/vehicles/live-labor.test.tsx

@@ -0,0 +1,150 @@
+/**
+ * A line of work added while the desk has the work order open.
+ *
+ * The technician app bills time straight onto the job, and the page hears
+ * about it on the work board channel and reads the job again. What it may do
+ * with the new line depends on whether someone is typing:
+ *
+ * - nothing being edited: the line is simply there, as after a reload;
+ * - mid-edit: the list is left exactly as typed and the line waits, because
+ *   this form saves labour by replacing every line. Taking the offer puts it
+ *   in the list; ignoring it still saves it, since a line missing from the
+ *   payload is a line the save deletes. That is how a technician's work was
+ *   lost before: the desk saved a list read before the line existed.
+ */
+import { act, renderHook } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { addedLaborLines } from '@/features/vehicles/Lib/laborLines'
+import { useServiceFormState } from '@/features/vehicles/Components/service-page/useServiceFormState'
+import type { ServiceLaborInput } from '@/features/vehicles/Schema/serviceSchema'
+
+vi.mock('@/hooks/use-deferred-commit', () => ({
+  useDeferredCommit: () => ({ schedule: vi.fn(), cancel: vi.fn() }),
+}))
+
+const line = (description: string, hours = 1, rate = 100): ServiceLaborInput => ({
+  description,
+  hours,
+  rate,
+  total: hours * rate,
+  pricingType: 'hourly',
+})
+
+const DESK = line('Diagnosis', 2)
+const FROM_THE_BAY = line('Replaced the timing belt', 1.5)
+
+/** The page's props, as ServiceRecordPage builds them, with these labour lines. */
+function props(laborItems: ServiceLaborInput[]) {
+  return {
+    vehicleId: 'veh-1',
+    defaultTaxRate: 0,
+    currentUserName: 'Desk',
+    record: { id: 'rec-1', attachments: [], payments: [] } as never,
+    initialData: {
+      id: 'rec-1',
+      concerns: [],
+      partItems: [],
+      laborItems,
+      taxRate: 0,
+      taxInclusive: false,
+      discountValue: 0,
+    } as never,
+  }
+}
+
+function openPage(laborItems: ServiceLaborInput[] = [DESK]) {
+  return renderHook((p: ReturnType<typeof props>) => useServiceFormState(p), {
+    initialProps: props(laborItems),
+  })
+}
+
+beforeEach(() => {
+  vi.useRealTimers()
+})
+
+describe('labour added from the technician app', () => {
+  it('appears in the list when nothing is being edited', () => {
+    const { result, rerender } = openPage()
+
+    // What a refresh brings back after the app added its line.
+    rerender(props([DESK, FROM_THE_BAY]))
+
+    expect(result.current.laborItems).toEqual([DESK, FROM_THE_BAY])
+    expect(result.current.laborAddedElsewhere).toEqual([])
+    expect(result.current.laborSubtotal).toBe(DESK.total + FROM_THE_BAY.total)
+  })
+
+  it('waits while the desk is typing, and leaves what was typed alone', () => {
+    const { result, rerender } = openPage()
+
+    act(() => result.current.dirtySetLaborItems([line('Diagnosis, gearbox', 3)]))
+    rerender(props([DESK, FROM_THE_BAY]))
+
+    expect(result.current.laborItems).toEqual([line('Diagnosis, gearbox', 3)])
+    expect(result.current.laborAddedElsewhere).toEqual([FROM_THE_BAY])
+  })
+
+  it('is saved even when the offer is never taken', () => {
+    const { result, rerender } = openPage()
+
+    act(() => result.current.dirtySetLaborItems([line('Diagnosis, gearbox', 3)]))
+    rerender(props([DESK, FROM_THE_BAY]))
+
+    expect(result.current.laborItemsForSave).toEqual([line('Diagnosis, gearbox', 3), FROM_THE_BAY])
+  })
+
+  it('goes into the list when the offer is taken, and is offered only once', () => {
+    const { result, rerender } = openPage()
+
+    act(() => result.current.dirtySetLaborItems([DESK, line('Road test')]))
+    rerender(props([DESK, FROM_THE_BAY]))
+    act(() => result.current.applyLaborAddedElsewhere())
+
+    expect(result.current.laborItems).toEqual([DESK, line('Road test'), FROM_THE_BAY])
+    expect(result.current.laborAddedElsewhere).toEqual([])
+    // Still dirty, so the line rides along with the edit being made.
+    expect(result.current.hasUnsavedChanges).toBe(true)
+    expect(result.current.laborItemsForSave).toEqual([DESK, line('Road test'), FROM_THE_BAY])
+  })
+
+  it('collects a second line that arrives while the first is still waiting', () => {
+    const { result, rerender } = openPage()
+    const second = line('Brake fluid', 0.5)
+
+    act(() => result.current.dirtySetLaborItems([line('Diagnosis, gearbox', 3)]))
+    rerender(props([DESK, FROM_THE_BAY]))
+    rerender(props([DESK, FROM_THE_BAY, second]))
+
+    expect(result.current.laborAddedElsewhere).toEqual([FROM_THE_BAY, second])
+  })
+
+  it('a refresh that brings nothing new offers nothing', () => {
+    const { result, rerender } = openPage()
+
+    act(() => result.current.dirtySetLaborItems([line('Diagnosis, gearbox', 3)]))
+    rerender(props([DESK]))
+
+    expect(result.current.laborAddedElsewhere).toEqual([])
+    expect(result.current.laborItems).toEqual([line('Diagnosis, gearbox', 3)])
+  })
+})
+
+describe('addedLaborLines', () => {
+  it('reads a job by what its lines say, since a saved line carries no id', () => {
+    expect(addedLaborLines([DESK], [DESK, FROM_THE_BAY])).toEqual([FROM_THE_BAY])
+    expect(addedLaborLines([DESK], [DESK])).toEqual([])
+    // A line deleted elsewhere is not something to add.
+    expect(addedLaborLines([DESK, FROM_THE_BAY], [DESK])).toEqual([])
+  })
+
+  it('counts repeats, so a second identical line reads as one added', () => {
+    expect(addedLaborLines([DESK], [DESK, DESK])).toEqual([DESK])
+    expect(addedLaborLines([DESK, DESK], [DESK, DESK])).toEqual([])
+  })
+
+  it('tells lines apart by every field that prints', () => {
+    expect(addedLaborLines([line('Service', 1, 100)], [line('Service', 1, 120)])).toEqual([
+      line('Service', 1, 120),
+    ])
+  })
+})

+ 77 - 0
src/__tests__/features/vehicles/promise-overdue.test.tsx

@@ -0,0 +1,77 @@
+/**
+ * A promise to the customer, and when it counts as broken.
+ *
+ * `promisedAt` is what somebody said on the phone: when the vehicle would be
+ * ready. It is not the booking, and until now it was visible only on the
+ * job's own page, so a missed promise was invisible to anyone not already
+ * looking at that job. It is now a sortable column on the work order list and
+ * a marker on the board card, which is three places reading one rule. They
+ * have to agree, or a job is late on the board and fine in the list.
+ */
+import { render, screen } from '@testing-library/react'
+import { NextIntlClientProvider } from 'next-intl'
+import { describe, expect, it } from 'vitest'
+import { isPromiseOverdue } from '@/features/vehicles/Lib/promise'
+import { PromiseOverdueMark } from '@/features/workboard/Components/PromiseOverdueMark'
+import messages from '../../../../messages/en/workBoard.json'
+
+const NOW = Date.UTC(2026, 8, 20, 12, 0)
+const EARLIER = new Date(NOW - 60 * 60 * 1000).toISOString()
+const LATER = new Date(NOW + 60 * 60 * 1000).toISOString()
+
+describe('isPromiseOverdue', () => {
+  it('is broken once the promised moment has passed', () => {
+    expect(isPromiseOverdue(EARLIER, 'in-progress', NOW)).toBe(true)
+    expect(isPromiseOverdue(LATER, 'in-progress', NOW)).toBe(false)
+  })
+
+  it('is never broken by a job that is finished', () => {
+    // The car was handed over; how long ago it was promised is history.
+    expect(isPromiseOverdue(EARLIER, 'completed', NOW)).toBe(false)
+  })
+
+  it('is never broken by a job nobody promised', () => {
+    expect(isPromiseOverdue(null, 'in-progress', NOW)).toBe(false)
+    expect(isPromiseOverdue(undefined, 'pending', NOW)).toBe(false)
+  })
+
+  it('takes a Date as readily as a stored string', () => {
+    expect(isPromiseOverdue(new Date(NOW - 1), 'pending', NOW)).toBe(true)
+    expect(isPromiseOverdue(new Date(NOW + 1), 'pending', NOW)).toBe(false)
+  })
+
+  it('treats an unreadable value as no promise rather than as late', () => {
+    expect(isPromiseOverdue('not a date', 'pending', NOW)).toBe(false)
+  })
+
+  it('is not broken at the exact moment it was promised for', () => {
+    expect(isPromiseOverdue(new Date(NOW).toISOString(), 'pending', NOW)).toBe(false)
+  })
+})
+
+function mark(job: { promisedAt: string | null; status: string }) {
+  return render(
+    <NextIntlClientProvider locale="en" messages={messages} timeZone="UTC">
+      <PromiseOverdueMark job={job} />
+    </NextIntlClientProvider>
+  )
+}
+
+describe('the marker on a board card', () => {
+  it('appears only for a promise already broken', () => {
+    mark({ promisedAt: new Date(Date.now() - 60_000).toISOString(), status: 'in-progress' })
+    expect(screen.getByTestId('promise-overdue')).toBeTruthy()
+  })
+
+  it('stays away from a promise still ahead, which is every ordinary job', () => {
+    mark({ promisedAt: new Date(Date.now() + 60 * 60_000).toISOString(), status: 'in-progress' })
+    expect(screen.queryByTestId('promise-overdue')).toBeNull()
+  })
+
+  it('stays away from a finished job and from one never promised', () => {
+    mark({ promisedAt: new Date(Date.now() - 60_000).toISOString(), status: 'completed' })
+    expect(screen.queryByTestId('promise-overdue')).toBeNull()
+    mark({ promisedAt: null, status: 'in-progress' })
+    expect(screen.queryByTestId('promise-overdue')).toBeNull()
+  })
+})

+ 1 - 3
src/__tests__/features/vehicles/work-order-title.test.ts

@@ -134,9 +134,7 @@ describe('resolveWorkOrderTitle', () => {
   })
 
   it('handles a number where a string was expected', () => {
-    expect(resolveWorkOrderTitle('{year} {make}', { year: 2019, make: 'Volvo' })).toBe(
-      '2019 Volvo'
-    )
+    expect(resolveWorkOrderTitle('{year} {make}', { year: 2019, make: 'Volvo' })).toBe('2019 Volvo')
   })
 
   it('leaves an unclosed brace alone as text', () => {

+ 133 - 0
src/__tests__/features/vehicles/work-orders-promised-sort.test.ts

@@ -0,0 +1,133 @@
+/**
+ * @vitest-environment node
+ *
+ * Sorting the work order list by what the customer was told.
+ *
+ * "What are we late on" is the question the promise exists to answer, and on
+ * a list of twenty rows a page it can only be answered by ordering the whole
+ * table in the database. Two things have to hold: the column sorts by
+ * `promisedAt`, and a job nobody promised sorts to the end rather than
+ * crowding the top as an empty value.
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const findMany = vi.hoisted(() =>
+  vi.fn(
+    async (_args: { orderBy: unknown; where: Record<string, unknown> }): Promise<unknown[]> => []
+  )
+)
+vi.mock('@/lib/db', () => ({
+  db: {
+    serviceRecord: {
+      findMany,
+      count: vi.fn(async () => 0),
+      groupBy: vi.fn(async () => []),
+    },
+  },
+}))
+vi.mock('@/lib/workshop-timezone', () => ({
+  workshopTimeZone: vi.fn(async () => 'Pacific/Auckland'),
+}))
+vi.mock('@/lib/with-auth', () => ({
+  withAuth: async (run: (ctx: { organizationId: string }) => Promise<unknown>) => ({
+    success: true,
+    data: await run({ organizationId: 'org-1' }),
+  }),
+}))
+
+import { getWorkOrders } from '@/features/vehicles/Actions/serviceActions'
+
+/** The where the list's query was built with. */
+async function whereFor(params: Parameters<typeof getWorkOrders>[0]) {
+  findMany.mockClear()
+  await getWorkOrders(params)
+  return findMany.mock.calls[0]?.[0].where
+}
+
+/** The orderBy the list's query was built with. */
+async function orderByFor(sortBy: string, sortOrder: 'asc' | 'desc' = 'desc') {
+  return (await whereForOrder({ sortBy, sortOrder })) as unknown
+}
+
+async function whereForOrder(params: Parameters<typeof getWorkOrders>[0]) {
+  findMany.mockClear()
+  await getWorkOrders(params)
+  return findMany.mock.calls[0]?.[0].orderBy
+}
+
+beforeEach(() => {
+  findMany.mockClear()
+})
+
+describe('the promised column', () => {
+  it('sorts by the promise, soonest first when ascending', async () => {
+    expect(await orderByFor('promisedAt', 'asc')).toEqual({
+      promisedAt: { sort: 'asc', nulls: 'last' },
+    })
+  })
+
+  it('keeps jobs with no promise at the end, whichever way it is sorted', async () => {
+    // Ascending they would otherwise lead the list, and the answer to "what is
+    // promised soonest" would be a page of jobs nobody promised anything.
+    for (const direction of ['asc', 'desc'] as const) {
+      expect(await orderByFor('promisedAt', direction)).toMatchObject({
+        promisedAt: { nulls: 'last' },
+      })
+    }
+  })
+
+  it('leaves the other columns as they were', async () => {
+    expect(await orderByFor('totalAmount', 'desc')).toEqual({ totalAmount: 'desc' })
+    // The default is still the date the job is presented with.
+    expect(await orderByFor('serviceDate', 'desc')).toEqual([
+      { startDateTime: { sort: 'desc', nulls: 'last' } },
+      { serviceDate: 'desc' },
+    ])
+  })
+})
+
+/**
+ * The "Due today" filter: what the workshop owes a customer by tonight.
+ *
+ * Late jobs belong to the same answer, since they were due before today and
+ * are still owed, and a finished job is owed to nobody. "Today" has to be the
+ * workshop's own day: read in the server's zone, a shop far enough east loses
+ * its evening from the list, which is exactly the hours it would be working
+ * through the promises.
+ */
+describe('the due today filter', () => {
+  it('asks for promises made for today or earlier, on jobs not finished', async () => {
+    const where = (await whereFor({ due: 'today' })) as {
+      promisedAt: { not: null; lte: Date }
+      AND: { status: { not: string } }[]
+    }
+
+    expect(where.promisedAt.not).toBeNull()
+    expect(where.AND).toEqual([{ status: { not: 'completed' } }])
+    // End of today in Auckland, which is not end of today in UTC.
+    const end = where.promisedAt.lte
+    expect(end.getTime()).toBeGreaterThan(Date.now() - 24 * 60 * 60 * 1000)
+    const inZone = new Intl.DateTimeFormat('en-GB', {
+      timeZone: 'Pacific/Auckland',
+      hour: '2-digit',
+      minute: '2-digit',
+      hour12: false,
+    }).format(end)
+    expect(inZone).toBe('23:59')
+  })
+
+  it('leaves the list alone when it is not asked for', async () => {
+    const where = (await whereFor({})) as Record<string, unknown>
+    expect(where.promisedAt).toBeUndefined()
+    expect(where.AND).toBeUndefined()
+  })
+
+  it('still honours the status tab it is combined with', async () => {
+    const where = (await whereFor({ due: 'today', status: 'waiting-parts' })) as Record<
+      string,
+      unknown
+    >
+    expect(where.status).toBe('waiting-parts')
+    expect(where.AND).toEqual([{ status: { not: 'completed' } }])
+  })
+})

+ 49 - 0
src/__tests__/features/workboard/availability.test.ts

@@ -13,6 +13,7 @@ import {
   findConflicts,
   nextAvailableSlot,
   overlaps,
+  technicianMinutesInDay,
   withinWorkingHours,
   type Booking,
 } from '@/features/workboard/Lib/availability'
@@ -259,3 +260,51 @@ describe('finding the next free slot', () => {
     ).toBeNull()
   })
 })
+
+describe('technicianMinutesInDay', () => {
+  const dayStart = new Date('2026-09-22T00:00:00+02:00')
+  const dayEnd = new Date('2026-09-23T00:00:00+02:00')
+  const job = (technicianId: string | null, start: string, end: string) => ({
+    id: `${technicianId}-${start}`,
+    kind: 'serviceRecord' as const,
+    label: '',
+    start: new Date(start),
+    end: new Date(end),
+    technicianId,
+    workBayId: null,
+  })
+
+  it('adds up what each technician is booked for inside the day', () => {
+    const minutes = technicianMinutesInDay(
+      [
+        job('anna', '2026-09-22T08:00:00+02:00', '2026-09-22T10:30:00+02:00'),
+        job('anna', '2026-09-22T12:00:00+02:00', '2026-09-22T15:00:00+02:00'),
+        job('ben', '2026-09-22T09:00:00+02:00', '2026-09-22T10:00:00+02:00'),
+      ],
+      dayStart,
+      dayEnd
+    )
+    expect(minutes).toEqual({ anna: 330, ben: 60 })
+  })
+
+  it('counts only the part of a job that falls inside the day', () => {
+    const minutes = technicianMinutesInDay(
+      [job('anna', '2026-09-21T22:00:00+02:00', '2026-09-22T02:00:00+02:00')],
+      dayStart,
+      dayEnd
+    )
+    expect(minutes).toEqual({ anna: 120 })
+  })
+
+  it('leaves out other days and work nobody is assigned to', () => {
+    const minutes = technicianMinutesInDay(
+      [
+        job('anna', '2026-09-23T08:00:00+02:00', '2026-09-23T09:00:00+02:00'),
+        job(null, '2026-09-22T08:00:00+02:00', '2026-09-22T09:00:00+02:00'),
+      ],
+      dayStart,
+      dayEnd
+    )
+    expect(minutes).toEqual({})
+  })
+})

+ 1 - 0
src/__tests__/features/workboard/datetime.test.ts

@@ -24,6 +24,7 @@ function makeJob(startDateTime: string | null, endDateTime: string | null): Work
     status: 'pending',
     startDateTime,
     endDateTime,
+    promisedAt: null,
     vehicle: null,
   }
 }

+ 72 - 0
src/__tests__/features/workboard/promised-on-cards.test.ts

@@ -0,0 +1,72 @@
+/**
+ * @vitest-environment node
+ *
+ * The promise reaching a board card.
+ *
+ * A card is built by one mapper from one select, so that the four queries
+ * returning board jobs cannot drift apart (see the comment on
+ * SERVICE_JOB_SELECT). A field the mapper hands out but the select never
+ * reads is undefined at runtime while the types look right, so both are
+ * checked here, together.
+ */
+import { describe, expect, it } from 'vitest'
+import {
+  inspectionToJob,
+  SERVICE_JOB_SELECT,
+  serviceRecordToJob,
+} from '@/features/workboard/Actions/boardActions/mappers'
+
+const PROMISED = new Date('2026-09-20T09:10:00.000Z')
+
+const serviceRecord = {
+  id: 'sr-1',
+  title: 'Brakes',
+  status: 'in-progress',
+  startDateTime: new Date('2026-09-20T08:00:00.000Z'),
+  endDateTime: new Date('2026-09-20T09:00:00.000Z'),
+  promisedAt: PROMISED,
+  technicianId: 'tech-1',
+  workBayId: null,
+  sortOrder: 0,
+  vehicle: null,
+  customer: { name: 'A customer' },
+}
+
+describe('a service record on the board', () => {
+  it('carries the promise, as the card needs it', () => {
+    expect(serviceRecordToJob(serviceRecord).promisedAt).toBe(PROMISED.toISOString())
+  })
+
+  it('carries none when none was made', () => {
+    expect(serviceRecordToJob({ ...serviceRecord, promisedAt: null }).promisedAt).toBeNull()
+  })
+
+  it('is read from the database, not only typed', () => {
+    expect(SERVICE_JOB_SELECT).toMatchObject({ promisedAt: true })
+  })
+})
+
+describe('an inspection on the board', () => {
+  it('has no promise at all: it is booked, never promised', () => {
+    const job = inspectionToJob({
+      id: 'insp-1',
+      status: 'pending',
+      startDateTime: null,
+      endDateTime: null,
+      technicianId: null,
+      workBayId: null,
+      sortOrder: 0,
+      vehicle: {
+        id: 'v-1',
+        make: 'Volvo',
+        model: 'V70',
+        year: 2015,
+        licensePlate: 'AB12345',
+        customer: null,
+      },
+      template: { name: 'Annual check' },
+    })
+
+    expect(job.promisedAt).toBeNull()
+  })
+})

+ 1 - 0
src/__tests__/features/workboard/week-layout.test.ts

@@ -41,6 +41,7 @@ function job(
     status: 'pending',
     startDateTime: start,
     endDateTime: end,
+    promisedAt: null,
     vehicle: null,
     ...extra,
   }

+ 1 - 1
src/__tests__/features/workorders/concerns-section.test.tsx

@@ -98,7 +98,7 @@ describe('the customer concerns field', () => {
         answeredCounts={{ c1: 2 }}
       />
     )
-    expect(screen.getByText('2 finding(s) against this')).toBeInTheDocument()
+    expect(screen.getByText('2 observations against this')).toBeInTheDocument()
     expect(screen.getByText('Nothing found against this yet')).toBeInTheDocument()
   })
 

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff