Просмотр исходного кода

Name new work orders from a title template in the workshop settings (#382)

Bernt Christian Egeland 2 недель назад
Родитель
Сommit
564eedc2c0

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

@@ -0,0 +1,158 @@
+import { expect, type Page, test } from '@playwright/test'
+import {
+  forgetWorkshopSetting,
+  ownerOrganizationId,
+  serviceRecordNames,
+  vehicleFacts,
+  workshopSetting,
+} from '../../support/db'
+import { settle } from '../../support/hydration'
+import { saveWorkOrder, seededVehicleUrl } from '../../support/work-order'
+
+/**
+ * A new work order opens with a title the workshop chose.
+ *
+ * Every job used to start as "New Service Record" and wait for somebody to
+ * type over it. A workshop asked for the title to fill itself in from the
+ * job's number, the plate or the customer, so Settings → Workshop now holds a
+ * template of tags, resolved on the server the moment the draft is made and
+ * its number is known. The unit tests in
+ * `src/__tests__/features/vehicles/work-order-title.test.ts` pin the
+ * resolution rules; this file proves the chain from the settings page to the
+ * title field of a job opened from a vehicle.
+ *
+ * The seeded workshop has never set a template, so the default applies until
+ * this file saves one, and the setting is removed again at the end.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+const KEY = 'workshop.workOrderTitleTemplate'
+
+let organizationId = ''
+let vehicleUrl = ''
+let vehicleId = ''
+let facts: Awaited<ReturnType<typeof vehicleFacts>>
+
+/** Types a template into Settings → Workshop and saves it. */
+async function saveTemplate(page: Page, template: string): Promise<void> {
+  await page.goto('/settings/workshop')
+  await settle(page)
+  const field = page.locator('#workOrderTitleTemplate')
+  await expect(field).toBeVisible()
+  await field.fill(template)
+  await page.getByRole('button', { name: 'Save Workshop Settings', exact: true }).click()
+  await expect(page.getByText('Workshop settings saved', { exact: true })).toBeVisible()
+  await expect.poll(() => workshopSetting(organizationId, KEY)).toBe(template)
+}
+
+/** When the last job was opened: `/service/new` reuses an untouched draft younger than five seconds. */
+let lastOpenedAt = 0
+
+/** Opens a fresh job on the seeded vehicle and returns what it was called and numbered. */
+async function openNewJob(page: Page) {
+  const wait = lastOpenedAt + 5_500 - Date.now()
+  if (wait > 0) await page.waitForTimeout(wait)
+  lastOpenedAt = Date.now()
+  await page.goto(`${vehicleUrl}/service/new`)
+  await page.waitForURL(/\/vehicles\/[^/]+\/service\/[^/]+$/)
+  await settle(page)
+  const id = page.url().split('/').pop() ?? ''
+  const row = await serviceRecordNames(id)
+  return { id, ...row, field: page.locator('input[name="title"]') }
+}
+
+test.beforeAll(async ({ browser }) => {
+  organizationId = await ownerOrganizationId()
+  await forgetWorkshopSetting(organizationId, KEY)
+  const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
+  vehicleUrl = await seededVehicleUrl(page)
+  vehicleId = vehicleUrl.split('/').pop() ?? ''
+  facts = await vehicleFacts(vehicleId)
+  expect(facts.licensePlate, 'the seeded vehicle has a plate to print').toBeTruthy()
+  expect(facts.customerName, 'and an owner').toBeTruthy()
+  await page.close()
+})
+
+test.afterAll(async () => {
+  await forgetWorkshopSetting(organizationId, KEY)
+})
+
+test.describe('the default, before the workshop has chosen anything', () => {
+  test('names a new job after its number and the plate', async ({ page }) => {
+    const job = await openNewJob(page)
+    expect(job.invoiceNumber, 'the job was numbered').toBeTruthy()
+    expect(job.title).toBe(`${job.invoiceNumber} - ${facts.licensePlate}`)
+    // And that is what the editor shows, ready to be typed over.
+    await expect(job.field).toHaveValue(job.title)
+  })
+
+  test('is what the settings page offers to edit', async ({ page }) => {
+    await page.goto('/settings/workshop')
+    await settle(page)
+    await expect(page.locator('#workOrderTitleTemplate')).toHaveValue(
+      '{order_number} - {license_plate}'
+    )
+    await expect(page.getByTestId('work-order-title-preview')).toHaveText(
+      'Example: 2026-1042 - AB 12345'
+    )
+  })
+})
+
+test.describe('a template the workshop wrote', () => {
+  test('is previewed as it is typed, tag by tag', async ({ page }) => {
+    await page.goto('/settings/workshop')
+    await settle(page)
+    const field = page.locator('#workOrderTitleTemplate')
+    await field.fill('')
+    await page.getByTestId('work-order-title-tag-customer_name').click()
+    await page.getByTestId('work-order-title-tag-vehicle').click()
+    // Two tags added back to back are kept apart by a space.
+    await expect(field).toHaveValue('{customer_name} {vehicle}')
+    await expect(page.getByTestId('work-order-title-preview')).toHaveText(
+      'Example: Jane Cooper 2021 Toyota Camry'
+    )
+
+    // A tag nobody knows is pointed out, and left out of the example.
+    await field.fill('{order_number} - {nope}')
+    await expect(page.getByTestId('work-order-title-unknown')).toHaveText(
+      'Unknown tags are left out: {nope}'
+    )
+    await expect(page.getByTestId('work-order-title-preview')).toHaveText('Example: 2026-1042')
+  })
+
+  test('names every new job with the customer, the car and the number', async ({ page }) => {
+    await saveTemplate(page, '{customer_name} · {license_plate} · WO#{order_number}')
+    const job = await openNewJob(page)
+    expect(job.title).toBe(
+      `${facts.customerName} · ${facts.licensePlate} · WO#${job.invoiceNumber}`
+    )
+    await expect(job.field).toHaveValue(job.title)
+  })
+
+  test('leaves out a tag nobody knows, with its separator', async ({ page }) => {
+    await saveTemplate(page, '{order_number} - {nope} - {license_plate}')
+    const job = await openNewJob(page)
+    expect(job.title).toBe(`${job.invoiceNumber} - ${facts.licensePlate}`)
+  })
+
+  test('prints the car as year, make and model', async ({ page }) => {
+    await saveTemplate(page, '{vehicle} ({order_number})')
+    const job = await openNewJob(page)
+    expect(job.title).toBe(`${facts.year} ${facts.make} ${facts.model} (${job.invoiceNumber})`)
+  })
+
+  test('saved empty brings back the plain name', async ({ page }) => {
+    await saveTemplate(page, '')
+    const job = await openNewJob(page)
+    expect(job.title).toBe('New Service Record')
+  })
+
+  test('can still be typed over on the job', async ({ page }) => {
+    await saveTemplate(page, '{order_number} - {license_plate}')
+    const job = await openNewJob(page)
+    await job.field.fill('Brake pads, front')
+    await saveWorkOrder(page)
+    expect((await serviceRecordNames(job.id)).title).toBe('Brake pads, front')
+  })
+})

+ 57 - 0
e2e/support/db.ts

@@ -882,3 +882,60 @@ export async function deleteInboundSms(organizationId: string, body: string): Pr
     )
     )
   )
   )
 }
 }
+
+// ─── Work order titles ───────────────────────────────────────────────────────
+
+/** What a job is called and numbered, straight from its row. */
+export async function serviceRecordNames(
+  serviceRecordId: string
+): Promise<{ title: string; invoiceNumber: string | null }> {
+  return withDb(async (db) => {
+    const result = await db.query<{ title: string; invoiceNumber: string | null }>(
+      `select title, "invoiceNumber" from service_records where id = $1`,
+      [serviceRecordId]
+    )
+    const row = result.rows[0]
+    if (!row) throw new Error(`no work order ${serviceRecordId}`)
+    return row
+  })
+}
+
+/** The words a title template can print about one vehicle and its owner. */
+export async function vehicleFacts(vehicleId: string): Promise<{
+  licensePlate: string | null
+  make: string
+  model: string
+  year: number
+  vin: string | null
+  customerName: string | null
+}> {
+  return withDb(async (db) => {
+    const result = await db.query<{
+      licensePlate: string | null
+      make: string
+      model: string
+      year: number
+      vin: string | null
+      customerName: string | null
+    }>(
+      `select v."licensePlate", v.make, v.model, v.year, v.vin, c.name as "customerName"
+         from vehicles v
+         left join customers c on c.id = v."customerId"
+        where v.id = $1`,
+      [vehicleId]
+    )
+    const row = result.rows[0]
+    if (!row) throw new Error(`no vehicle ${vehicleId}`)
+    return row
+  })
+}
+
+/** Removes a workshop setting so the app falls back to its default for it. */
+export async function forgetWorkshopSetting(organizationId: string, key: string): Promise<void> {
+  await withDb((db) =>
+    db.query(`delete from app_settings where "organizationId" = $1 and key = $2`, [
+      organizationId,
+      key,
+    ])
+  )
+}

+ 19 - 0
messages/de/settings.json

@@ -1097,6 +1097,25 @@
     "weekStartDay": "Wochenstart",
     "weekStartDay": "Wochenstart",
     "workDayStart": "Arbeitsbeginn",
     "workDayStart": "Arbeitsbeginn",
     "workDayEnd": "Arbeitsende",
     "workDayEnd": "Arbeitsende",
+    "titleTemplateTitle": "Titel des Arbeitsauftrags",
+    "titleTemplateDescription": "Wie ein neuer Arbeitsauftrag heißt, bevor jemand einen Titel eingibt. Setzen Sie ihn aus den Tags unten zusammen; jeder Auftrag öffnet mit ausgefüllten Tags, und der Titel lässt sich am Auftrag weiterhin ändern.",
+    "titleTemplate": "Titelvorlage",
+    "titleTemplatePlaceholder": "Leer lassen, damit jeder Auftrag als \"New Service Record\" beginnt",
+    "titleTemplateHint": "Klicken Sie auf ein Tag, um es einzufügen. Ein Tag ohne Inhalt, etwa ein Fahrzeug ohne Kennzeichen, entfällt samt Trennzeichen.",
+    "titleTemplatePreview": "Beispiel: {title}",
+    "titleTemplateUnknown": "Unbekannte Tags entfallen: {tags}",
+    "titleTemplateTags": {
+      "order_number": "Auftragsnummer",
+      "license_plate": "Kennzeichen",
+      "customer_name": "Kundenname",
+      "vehicle": "Fahrzeug (Jahr, Marke, Modell)",
+      "make": "Marke",
+      "model": "Modell",
+      "year": "Baujahr",
+      "vin": "FIN",
+      "technician": "Techniker",
+      "date": "Datum"
+    },
     "weekDays": {
     "weekDays": {
       "0": "Sonntag",
       "0": "Sonntag",
       "1": "Montag",
       "1": "Montag",

+ 19 - 0
messages/en/settings.json

@@ -1097,6 +1097,25 @@
     "weekStartDay": "Week Start Day",
     "weekStartDay": "Week Start Day",
     "workDayStart": "Work Day Start",
     "workDayStart": "Work Day Start",
     "workDayEnd": "Work Day End",
     "workDayEnd": "Work Day End",
+    "titleTemplateTitle": "Work Order Title",
+    "titleTemplateDescription": "What a new work order is called before anybody types a title. Build it from the tags below; every job opens with them filled in, and the title can still be changed on the job.",
+    "titleTemplate": "Title template",
+    "titleTemplatePlaceholder": "Leave empty to start every job as \"New Service Record\"",
+    "titleTemplateHint": "Click a tag to add it. A tag with nothing behind it, such as a car without a plate, is left out together with its separator.",
+    "titleTemplatePreview": "Example: {title}",
+    "titleTemplateUnknown": "Unknown tags are left out: {tags}",
+    "titleTemplateTags": {
+      "order_number": "Order number",
+      "license_plate": "License plate",
+      "customer_name": "Customer name",
+      "vehicle": "Vehicle (year, make, model)",
+      "make": "Make",
+      "model": "Model",
+      "year": "Year",
+      "vin": "VIN",
+      "technician": "Technician",
+      "date": "Date"
+    },
     "weekDays": {
     "weekDays": {
       "0": "Sunday",
       "0": "Sunday",
       "1": "Monday",
       "1": "Monday",

+ 19 - 0
messages/es/settings.json

@@ -1097,6 +1097,25 @@
     "weekStartDay": "Inicio de semana",
     "weekStartDay": "Inicio de semana",
     "workDayStart": "Inicio de jornada",
     "workDayStart": "Inicio de jornada",
     "workDayEnd": "Fin de jornada",
     "workDayEnd": "Fin de jornada",
+    "titleTemplateTitle": "Título de la orden de trabajo",
+    "titleTemplateDescription": "Cómo se llama una orden de trabajo nueva antes de que alguien escriba un título. Constrúyalo con las etiquetas de abajo; cada trabajo se abre con ellas rellenadas y el título se puede cambiar en el trabajo.",
+    "titleTemplate": "Plantilla del título",
+    "titleTemplatePlaceholder": "Déjelo vacío para que cada trabajo empiece como \"New Service Record\"",
+    "titleTemplateHint": "Haga clic en una etiqueta para añadirla. Una etiqueta sin contenido, como un vehículo sin matrícula, se omite junto con su separador.",
+    "titleTemplatePreview": "Ejemplo: {title}",
+    "titleTemplateUnknown": "Las etiquetas desconocidas se omiten: {tags}",
+    "titleTemplateTags": {
+      "order_number": "Número de orden",
+      "license_plate": "Matrícula",
+      "customer_name": "Nombre del cliente",
+      "vehicle": "Vehículo (año, marca, modelo)",
+      "make": "Marca",
+      "model": "Modelo",
+      "year": "Año",
+      "vin": "VIN",
+      "technician": "Técnico",
+      "date": "Fecha"
+    },
     "weekDays": {
     "weekDays": {
       "0": "Domingo",
       "0": "Domingo",
       "1": "Lunes",
       "1": "Lunes",

+ 19 - 0
messages/fr/settings.json

@@ -1097,6 +1097,25 @@
     "weekStartDay": "Début de semaine",
     "weekStartDay": "Début de semaine",
     "workDayStart": "Début de journee",
     "workDayStart": "Début de journee",
     "workDayEnd": "Fin de journee",
     "workDayEnd": "Fin de journee",
+    "titleTemplateTitle": "Titre de l'ordre de travail",
+    "titleTemplateDescription": "Le nom d'un nouvel ordre de travail avant que quelqu'un saisisse un titre. Composez-le avec les balises ci-dessous ; chaque travail s'ouvre avec les balises remplies et le titre reste modifiable sur le travail.",
+    "titleTemplate": "Modèle de titre",
+    "titleTemplatePlaceholder": "Laissez vide pour que chaque travail commence comme « New Service Record »",
+    "titleTemplateHint": "Cliquez sur une balise pour l'ajouter. Une balise sans contenu, comme un véhicule sans plaque, est omise avec son séparateur.",
+    "titleTemplatePreview": "Exemple : {title}",
+    "titleTemplateUnknown": "Les balises inconnues sont omises : {tags}",
+    "titleTemplateTags": {
+      "order_number": "Numéro d'ordre",
+      "license_plate": "Plaque d'immatriculation",
+      "customer_name": "Nom du client",
+      "vehicle": "Véhicule (année, marque, modèle)",
+      "make": "Marque",
+      "model": "Modèle",
+      "year": "Année",
+      "vin": "VIN",
+      "technician": "Technicien",
+      "date": "Date"
+    },
     "weekDays": {
     "weekDays": {
       "0": "Dimanche",
       "0": "Dimanche",
       "1": "Lundi",
       "1": "Lundi",

+ 19 - 0
messages/it/settings.json

@@ -1097,6 +1097,25 @@
     "weekStartDay": "Inizio settimana",
     "weekStartDay": "Inizio settimana",
     "workDayStart": "Inizio giornata",
     "workDayStart": "Inizio giornata",
     "workDayEnd": "Fine giornata",
     "workDayEnd": "Fine giornata",
+    "titleTemplateTitle": "Titolo dell'ordine di lavoro",
+    "titleTemplateDescription": "Come si chiama un nuovo ordine di lavoro prima che qualcuno scriva un titolo. Componilo con i tag qui sotto; ogni lavoro si apre con i tag compilati e il titolo resta modificabile sul lavoro.",
+    "titleTemplate": "Modello del titolo",
+    "titleTemplatePlaceholder": "Lascia vuoto per far iniziare ogni lavoro come \"New Service Record\"",
+    "titleTemplateHint": "Fai clic su un tag per aggiungerlo. Un tag senza contenuto, come un veicolo senza targa, viene omesso insieme al suo separatore.",
+    "titleTemplatePreview": "Esempio: {title}",
+    "titleTemplateUnknown": "I tag sconosciuti vengono omessi: {tags}",
+    "titleTemplateTags": {
+      "order_number": "Numero ordine",
+      "license_plate": "Targa",
+      "customer_name": "Nome cliente",
+      "vehicle": "Veicolo (anno, marca, modello)",
+      "make": "Marca",
+      "model": "Modello",
+      "year": "Anno",
+      "vin": "Telaio (VIN)",
+      "technician": "Tecnico",
+      "date": "Data"
+    },
     "weekDays": {
     "weekDays": {
       "0": "Domenica",
       "0": "Domenica",
       "1": "Lunedi",
       "1": "Lunedi",

+ 19 - 0
messages/lt/settings.json

@@ -1097,6 +1097,25 @@
     "weekStartDay": "Savaitės pradžios diena",
     "weekStartDay": "Savaitės pradžios diena",
     "workDayStart": "Darbo dienos pradžia",
     "workDayStart": "Darbo dienos pradžia",
     "workDayEnd": "Darbo dienos pabaiga",
     "workDayEnd": "Darbo dienos pabaiga",
+    "titleTemplateTitle": "Darbo užsakymo pavadinimas",
+    "titleTemplateDescription": "Kaip vadinamas naujas darbo užsakymas, kol niekas neįrašė pavadinimo. Sudarykite jį iš žymų žemiau; kiekvienas darbas atsidaro su užpildytomis žymomis, o pavadinimą vis dar galima keisti pačiame darbe.",
+    "titleTemplate": "Pavadinimo šablonas",
+    "titleTemplatePlaceholder": "Palikite tuščią, kad kiekvienas darbas prasidėtų kaip „New Service Record“",
+    "titleTemplateHint": "Spustelėkite žymą, kad ją pridėtumėte. Žyma be turinio, pavyzdžiui automobilis be numerio, praleidžiama kartu su skirtuku.",
+    "titleTemplatePreview": "Pavyzdys: {title}",
+    "titleTemplateUnknown": "Nežinomos žymos praleidžiamos: {tags}",
+    "titleTemplateTags": {
+      "order_number": "Užsakymo numeris",
+      "license_plate": "Valstybinis numeris",
+      "customer_name": "Kliento vardas",
+      "vehicle": "Transporto priemonė (metai, markė, modelis)",
+      "make": "Markė",
+      "model": "Modelis",
+      "year": "Metai",
+      "vin": "VIN",
+      "technician": "Technikas",
+      "date": "Data"
+    },
     "weekDays": {
     "weekDays": {
       "0": "Sekmadienis",
       "0": "Sekmadienis",
       "1": "Pirmadienis",
       "1": "Pirmadienis",

+ 19 - 0
messages/nb/settings.json

@@ -1097,6 +1097,25 @@
     "weekStartDay": "Ukens startdag",
     "weekStartDay": "Ukens startdag",
     "workDayStart": "Arbeidsdagen starter",
     "workDayStart": "Arbeidsdagen starter",
     "workDayEnd": "Arbeidsdagen slutter",
     "workDayEnd": "Arbeidsdagen slutter",
+    "titleTemplateTitle": "Tittel på arbeidsordre",
+    "titleTemplateDescription": "Hva en ny arbeidsordre heter før noen skriver inn en tittel. Sett den sammen av taggene under; hver jobb åpnes med taggene fylt ut, og tittelen kan fortsatt endres på jobben.",
+    "titleTemplate": "Tittelmal",
+    "titleTemplatePlaceholder": "La stå tom for at hver jobb skal starte som «New Service Record»",
+    "titleTemplateHint": "Klikk på en tagg for å legge den til. En tagg uten innhold, som et kjøretøy uten skilt, utelates sammen med skilletegnet.",
+    "titleTemplatePreview": "Eksempel: {title}",
+    "titleTemplateUnknown": "Ukjente tagger utelates: {tags}",
+    "titleTemplateTags": {
+      "order_number": "Ordrenummer",
+      "license_plate": "Registreringsnummer",
+      "customer_name": "Kundenavn",
+      "vehicle": "Kjøretøy (år, merke, modell)",
+      "make": "Merke",
+      "model": "Modell",
+      "year": "Årsmodell",
+      "vin": "Understellsnummer",
+      "technician": "Tekniker",
+      "date": "Dato"
+    },
     "weekDays": {
     "weekDays": {
       "0": "Søndag",
       "0": "Søndag",
       "1": "Mandag",
       "1": "Mandag",

+ 19 - 0
messages/nl/settings.json

@@ -1097,6 +1097,25 @@
     "weekStartDay": "Weekstart",
     "weekStartDay": "Weekstart",
     "workDayStart": "Werkdag begin",
     "workDayStart": "Werkdag begin",
     "workDayEnd": "Werkdag einde",
     "workDayEnd": "Werkdag einde",
+    "titleTemplateTitle": "Titel van de werkorder",
+    "titleTemplateDescription": "Hoe een nieuwe werkorder heet voordat iemand een titel typt. Stel hem samen uit de tags hieronder; elke klus opent met de tags ingevuld en de titel blijft op de klus te wijzigen.",
+    "titleTemplate": "Titelsjabloon",
+    "titleTemplatePlaceholder": "Laat leeg om elke klus te laten beginnen als \"New Service Record\"",
+    "titleTemplateHint": "Klik op een tag om die toe te voegen. Een tag zonder inhoud, zoals een voertuig zonder kenteken, wordt samen met het scheidingsteken weggelaten.",
+    "titleTemplatePreview": "Voorbeeld: {title}",
+    "titleTemplateUnknown": "Onbekende tags worden weggelaten: {tags}",
+    "titleTemplateTags": {
+      "order_number": "Ordernummer",
+      "license_plate": "Kenteken",
+      "customer_name": "Klantnaam",
+      "vehicle": "Voertuig (jaar, merk, model)",
+      "make": "Merk",
+      "model": "Model",
+      "year": "Bouwjaar",
+      "vin": "VIN",
+      "technician": "Monteur",
+      "date": "Datum"
+    },
     "weekDays": {
     "weekDays": {
       "0": "Zondag",
       "0": "Zondag",
       "1": "Maandag",
       "1": "Maandag",

+ 19 - 0
messages/pl/settings.json

@@ -1097,6 +1097,25 @@
     "weekStartDay": "Poczatek tygodnia",
     "weekStartDay": "Poczatek tygodnia",
     "workDayStart": "Poczatek dnia pracy",
     "workDayStart": "Poczatek dnia pracy",
     "workDayEnd": "Koniec dnia pracy",
     "workDayEnd": "Koniec dnia pracy",
+    "titleTemplateTitle": "Tytuł zlecenia",
+    "titleTemplateDescription": "Jak nazywa się nowe zlecenie, zanim ktoś wpisze tytuł. Zbuduj go z poniższych tagów; każde zlecenie otwiera się z wypełnionymi tagami, a tytuł nadal można zmienić w zleceniu.",
+    "titleTemplate": "Szablon tytułu",
+    "titleTemplatePlaceholder": "Pozostaw puste, aby każde zlecenie zaczynało się jako „New Service Record”",
+    "titleTemplateHint": "Kliknij tag, aby go dodać. Tag bez treści, na przykład pojazd bez tablicy, jest pomijany razem ze separatorem.",
+    "titleTemplatePreview": "Przykład: {title}",
+    "titleTemplateUnknown": "Nieznane tagi są pomijane: {tags}",
+    "titleTemplateTags": {
+      "order_number": "Numer zlecenia",
+      "license_plate": "Numer rejestracyjny",
+      "customer_name": "Nazwa klienta",
+      "vehicle": "Pojazd (rok, marka, model)",
+      "make": "Marka",
+      "model": "Model",
+      "year": "Rok",
+      "vin": "VIN",
+      "technician": "Technik",
+      "date": "Data"
+    },
     "weekDays": {
     "weekDays": {
       "0": "Niedziela",
       "0": "Niedziela",
       "1": "Poniedzialek",
       "1": "Poniedzialek",

+ 19 - 0
messages/pt-BR/settings.json

@@ -1097,6 +1097,25 @@
     "weekStartDay": "Início da semana",
     "weekStartDay": "Início da semana",
     "workDayStart": "Início do expediente",
     "workDayStart": "Início do expediente",
     "workDayEnd": "Fim do expediente",
     "workDayEnd": "Fim do expediente",
+    "titleTemplateTitle": "Título da ordem de serviço",
+    "titleTemplateDescription": "Como uma nova ordem de serviço se chama antes que alguém digite um título. Monte-o com as tags abaixo; cada serviço abre com as tags preenchidas e o título ainda pode ser alterado no serviço.",
+    "titleTemplate": "Modelo do título",
+    "titleTemplatePlaceholder": "Deixe vazio para que cada serviço comece como \"New Service Record\"",
+    "titleTemplateHint": "Clique em uma tag para adicioná-la. Uma tag sem conteúdo, como um veículo sem placa, é omitida junto com o separador.",
+    "titleTemplatePreview": "Exemplo: {title}",
+    "titleTemplateUnknown": "Tags desconhecidas são omitidas: {tags}",
+    "titleTemplateTags": {
+      "order_number": "Número da ordem",
+      "license_plate": "Placa",
+      "customer_name": "Nome do cliente",
+      "vehicle": "Veículo (ano, marca, modelo)",
+      "make": "Marca",
+      "model": "Modelo",
+      "year": "Ano",
+      "vin": "Chassi (VIN)",
+      "technician": "Técnico",
+      "date": "Data"
+    },
     "weekDays": {
     "weekDays": {
       "0": "Domingo",
       "0": "Domingo",
       "1": "Segunda-feira",
       "1": "Segunda-feira",

+ 19 - 0
messages/ru/settings.json

@@ -1097,6 +1097,25 @@
     "weekStartDay": "Первый день недели",
     "weekStartDay": "Первый день недели",
     "workDayStart": "Начало рабочего дня",
     "workDayStart": "Начало рабочего дня",
     "workDayEnd": "Конец рабочего дня",
     "workDayEnd": "Конец рабочего дня",
+    "titleTemplateTitle": "Название заказ-наряда",
+    "titleTemplateDescription": "Как называется новый заказ-наряд, пока никто не ввёл название. Соберите его из тегов ниже; каждый заказ открывается с заполненными тегами, а название по-прежнему можно изменить в самом заказе.",
+    "titleTemplate": "Шаблон названия",
+    "titleTemplatePlaceholder": "Оставьте пустым, чтобы каждый заказ начинался как \"New Service Record\"",
+    "titleTemplateHint": "Нажмите на тег, чтобы добавить его. Тег без содержимого, например автомобиль без номера, опускается вместе с разделителем.",
+    "titleTemplatePreview": "Пример: {title}",
+    "titleTemplateUnknown": "Неизвестные теги опускаются: {tags}",
+    "titleTemplateTags": {
+      "order_number": "Номер заказа",
+      "license_plate": "Госномер",
+      "customer_name": "Имя клиента",
+      "vehicle": "Автомобиль (год, марка, модель)",
+      "make": "Марка",
+      "model": "Модель",
+      "year": "Год",
+      "vin": "VIN",
+      "technician": "Техник",
+      "date": "Дата"
+    },
     "weekDays": {
     "weekDays": {
       "0": "Воскресенье",
       "0": "Воскресенье",
       "1": "Понедельник",
       "1": "Понедельник",

+ 19 - 0
messages/tr/settings.json

@@ -1097,6 +1097,25 @@
     "weekStartDay": "Hafta Baslangici",
     "weekStartDay": "Hafta Baslangici",
     "workDayStart": "Is Gunu Baslangici",
     "workDayStart": "Is Gunu Baslangici",
     "workDayEnd": "Is Gunu Bitisi",
     "workDayEnd": "Is Gunu Bitisi",
+    "titleTemplateTitle": "İş emri başlığı",
+    "titleTemplateDescription": "Biri başlık yazmadan önce yeni bir iş emrinin adı. Aşağıdaki etiketlerden oluşturun; her iş etiketler doldurulmuş olarak açılır ve başlık iş üzerinde yine değiştirilebilir.",
+    "titleTemplate": "Başlık şablonu",
+    "titleTemplatePlaceholder": "Her işin \"New Service Record\" olarak başlaması için boş bırakın",
+    "titleTemplateHint": "Eklemek için bir etikete tıklayın. İçeriği olmayan bir etiket, örneğin plakasız bir araç, ayırıcısıyla birlikte dışarıda bırakılır.",
+    "titleTemplatePreview": "Örnek: {title}",
+    "titleTemplateUnknown": "Bilinmeyen etiketler dışarıda bırakılır: {tags}",
+    "titleTemplateTags": {
+      "order_number": "Sipariş numarası",
+      "license_plate": "Plaka",
+      "customer_name": "Müşteri adı",
+      "vehicle": "Araç (yıl, marka, model)",
+      "make": "Marka",
+      "model": "Model",
+      "year": "Yıl",
+      "vin": "Şasi (VIN)",
+      "technician": "Teknisyen",
+      "date": "Tarih"
+    },
     "weekDays": {
     "weekDays": {
       "0": "Pazar",
       "0": "Pazar",
       "1": "Pazartesi",
       "1": "Pazartesi",

+ 235 - 0
src/__tests__/features/vehicles/work-order-title.test.ts

@@ -0,0 +1,235 @@
+import { describe, expect, it } from 'vitest'
+import {
+  DEFAULT_WORK_ORDER_TITLE_TEMPLATE,
+  FALLBACK_WORK_ORDER_TITLE,
+  MAX_WORK_ORDER_TITLE_LENGTH,
+  MAX_WORK_ORDER_TITLE_TEMPLATE_LENGTH,
+  resolveWorkOrderTitle,
+  SAMPLE_WORK_ORDER_TITLE_VALUES,
+  tokensIn,
+  unknownTokensIn,
+  WORK_ORDER_TITLE_TOKENS,
+  workOrderTitleTemplateFrom,
+  workOrderTitleValues,
+} from '@/features/vehicles/Lib/workOrderTitle'
+
+/**
+ * The title a new work order opens with, from the workshop's template.
+ *
+ * The rules a workshop relies on without reading them: every tag fills in,
+ * a tag with nothing behind it takes its separator with it, a typo never
+ * produces an empty or broken title, and what the workshop typed between the
+ * tags is left exactly as typed.
+ */
+
+const values = {
+  order_number: '2026-1042',
+  license_plate: 'AB 12345',
+  customer_name: 'Jane Cooper',
+  vehicle: '2021 Toyota Camry',
+  make: 'Toyota',
+  model: 'Camry',
+  year: 2021,
+  vin: '4T1BF1FK5CU123456',
+  technician: 'Sam Lee',
+  date: '2026-09-11',
+}
+
+describe('resolveWorkOrderTitle', () => {
+  it('fills every tag it knows', () => {
+    for (const token of WORK_ORDER_TITLE_TOKENS) {
+      expect(resolveWorkOrderTitle(`{${token}}`, values), token).toBe(String(values[token]))
+    }
+  })
+
+  it('names the default the way the request asked: number, then plate', () => {
+    expect(resolveWorkOrderTitle(DEFAULT_WORK_ORDER_TITLE_TEMPLATE, values)).toBe(
+      '2026-1042 - AB 12345'
+    )
+  })
+
+  it('keeps the text between tags exactly as written', () => {
+    expect(resolveWorkOrderTitle('WO#{order_number} ({license_plate})', values)).toBe(
+      'WO#2026-1042 (AB 12345)'
+    )
+    expect(resolveWorkOrderTitle('{customer_name}: {vehicle} / {date}', values)).toBe(
+      'Jane Cooper: 2021 Toyota Camry / 2026-09-11'
+    )
+  })
+
+  it('does not touch punctuation inside a value', () => {
+    // The order number's own dash is not a separator to be tidied.
+    expect(resolveWorkOrderTitle('{order_number}', values)).toBe('2026-1042')
+    expect(resolveWorkOrderTitle('{order_number}/{year}', values)).toBe('2026-1042/2021')
+  })
+
+  it('drops an empty tag together with the separator beside it', () => {
+    const noPlate = { ...values, license_plate: null }
+    expect(resolveWorkOrderTitle('{order_number} - {license_plate}', noPlate)).toBe('2026-1042')
+    expect(resolveWorkOrderTitle('{license_plate} - {order_number}', noPlate)).toBe('2026-1042')
+    expect(
+      resolveWorkOrderTitle('{order_number} - {license_plate} - {customer_name}', noPlate)
+    ).toBe('2026-1042 - Jane Cooper')
+    // Whitespace-only counts as empty too.
+    expect(resolveWorkOrderTitle('{order_number} · {vin}', { ...values, vin: '   ' })).toBe(
+      '2026-1042'
+    )
+  })
+
+  it('drops an empty tag inside brackets with the brackets', () => {
+    expect(
+      resolveWorkOrderTitle('{order_number} ({license_plate})', { ...values, license_plate: '' })
+    ).toBe('2026-1042')
+    expect(
+      resolveWorkOrderTitle('{order_number} [{customer_name}]', {
+        ...values,
+        customer_name: null,
+      })
+    ).toBe('2026-1042')
+  })
+
+  it('drops a tag nobody knows the same way', () => {
+    expect(resolveWorkOrderTitle('{order_number} - {nope} - {license_plate}', values)).toBe(
+      '2026-1042 - AB 12345'
+    )
+    expect(resolveWorkOrderTitle('{unknown}', values)).toBe(FALLBACK_WORK_ORDER_TITLE)
+  })
+
+  it('reads the spellings people reach for as the tag they mean', () => {
+    expect(resolveWorkOrderTitle('{order_id}', values)).toBe('2026-1042')
+    expect(resolveWorkOrderTitle('{invoice_number}', values)).toBe('2026-1042')
+    expect(resolveWorkOrderTitle('{plate}', values)).toBe('AB 12345')
+    expect(resolveWorkOrderTitle('{customer}', values)).toBe('Jane Cooper')
+    expect(resolveWorkOrderTitle('{ Order_Number }', values)).toBe('2026-1042')
+  })
+
+  it('falls back to the plain name when nothing is left', () => {
+    expect(resolveWorkOrderTitle('', values)).toBe(FALLBACK_WORK_ORDER_TITLE)
+    expect(resolveWorkOrderTitle('   ', values)).toBe(FALLBACK_WORK_ORDER_TITLE)
+    expect(resolveWorkOrderTitle(null, values)).toBe(FALLBACK_WORK_ORDER_TITLE)
+    expect(resolveWorkOrderTitle(undefined, values)).toBe(FALLBACK_WORK_ORDER_TITLE)
+    expect(resolveWorkOrderTitle('{license_plate} - {vin}', {})).toBe(FALLBACK_WORK_ORDER_TITLE)
+    expect(resolveWorkOrderTitle(' - ', values)).toBe('-')
+  })
+
+  it('takes a fallback of its own when asked', () => {
+    expect(resolveWorkOrderTitle('{vin}', {}, 'Parts Sale')).toBe('Parts Sale')
+  })
+
+  it('keeps a template with no tags as it is', () => {
+    expect(resolveWorkOrderTitle('Service visit', values)).toBe('Service visit')
+  })
+
+  it('collapses runs of whitespace', () => {
+    expect(resolveWorkOrderTitle('  {order_number}    {license_plate}  ', values)).toBe(
+      '2026-1042 AB 12345'
+    )
+  })
+
+  it('never exceeds what the title field holds', () => {
+    const long = { ...values, customer_name: 'x'.repeat(300) }
+    const title = resolveWorkOrderTitle('{order_number} - {customer_name}', long)
+    expect(title.length).toBe(MAX_WORK_ORDER_TITLE_LENGTH)
+    expect(title.startsWith('2026-1042 - x')).toBe(true)
+  })
+
+  it('handles a number where a string was expected', () => {
+    expect(resolveWorkOrderTitle('{year} {make}', { year: 2019, make: 'Volvo' })).toBe(
+      '2019 Volvo'
+    )
+  })
+
+  it('leaves an unclosed brace alone as text', () => {
+    expect(resolveWorkOrderTitle('{order_number - {license_plate}', values)).toBe(
+      '{order_number - AB 12345'
+    )
+  })
+})
+
+describe('tokensIn and unknownTokensIn', () => {
+  it('lists every tag with the token it resolves to', () => {
+    expect(tokensIn('{order_number} {plate} {nope}')).toEqual([
+      { tag: 'order_number', token: 'order_number' },
+      { tag: 'plate', token: 'license_plate' },
+      { tag: 'nope', token: null },
+    ])
+  })
+
+  it('names the unknown ones once each, in braces, for the settings page', () => {
+    expect(unknownTokensIn('{order_number} {nope} {Nope} {other}')).toEqual([
+      '{nope}',
+      '{Nope}',
+      '{other}',
+    ])
+    expect(unknownTokensIn(DEFAULT_WORK_ORDER_TITLE_TEMPLATE)).toEqual([])
+  })
+})
+
+describe('workOrderTitleValues', () => {
+  it('builds the vehicle name from year, make and model', () => {
+    const built = workOrderTitleValues({
+      orderNumber: '2026-7',
+      vehicle: { licensePlate: 'ZZ 1', make: 'Ford', model: 'Transit', year: 2018, vin: null },
+      customerName: 'Acme',
+      technicianName: 'Sam',
+      date: '2026-09-11',
+    })
+    expect(built).toEqual({
+      order_number: '2026-7',
+      license_plate: 'ZZ 1',
+      customer_name: 'Acme',
+      vehicle: '2018 Ford Transit',
+      make: 'Ford',
+      model: 'Transit',
+      year: 2018,
+      vin: null,
+      technician: 'Sam',
+      date: '2026-09-11',
+    })
+  })
+
+  it('leaves out a year of zero, which is how an unknown year is stored', () => {
+    const built = workOrderTitleValues({
+      vehicle: { make: 'Ford', model: 'Transit', year: 0 },
+    })
+    expect(built.year).toBeNull()
+    expect(built.vehicle).toBe('Ford Transit')
+  })
+
+  it('has nothing to say about a car for a counter sale', () => {
+    const built = workOrderTitleValues({ orderNumber: '2026-8', customerName: 'Walk-in' })
+    expect(built.vehicle).toBeNull()
+    expect(built.license_plate).toBeNull()
+    expect(resolveWorkOrderTitle(DEFAULT_WORK_ORDER_TITLE_TEMPLATE, built)).toBe('2026-8')
+  })
+})
+
+describe('workOrderTitleTemplateFrom', () => {
+  const KEY = 'workshop.workOrderTitleTemplate'
+
+  it('is the default when the workshop has never set one', () => {
+    expect(workOrderTitleTemplateFrom({}, KEY)).toBe(DEFAULT_WORK_ORDER_TITLE_TEMPLATE)
+  })
+
+  it('is empty when the workshop saved it empty, which means the plain name', () => {
+    expect(workOrderTitleTemplateFrom({ [KEY]: '' }, KEY)).toBe('')
+    expect(resolveWorkOrderTitle(workOrderTitleTemplateFrom({ [KEY]: '' }, KEY), values)).toBe(
+      FALLBACK_WORK_ORDER_TITLE
+    )
+  })
+
+  it('is what the workshop saved, cut to the allowed length', () => {
+    expect(workOrderTitleTemplateFrom({ [KEY]: '{plate}' }, KEY)).toBe('{plate}')
+    expect(workOrderTitleTemplateFrom({ [KEY]: 'x'.repeat(500) }, KEY).length).toBe(
+      MAX_WORK_ORDER_TITLE_TEMPLATE_LENGTH
+    )
+  })
+})
+
+describe('the settings page preview', () => {
+  it('has a sample value for every token', () => {
+    for (const token of WORK_ORDER_TITLE_TOKENS) {
+      expect(SAMPLE_WORK_ORDER_TITLE_VALUES[token], token).toBeTruthy()
+    }
+  })
+})

+ 98 - 2
src/app/(authenticated)/settings/workshop/workshop-settings.tsx

@@ -1,6 +1,6 @@
 'use client'
 'use client'
 
 
-import { useState } from 'react'
+import { useRef, useState } from 'react'
 import { useRouter } from 'next/navigation'
 import { useRouter } from 'next/navigation'
 import { useTranslations } from 'next-intl'
 import { useTranslations } from 'next-intl'
 import { UnitCombobox } from '@/features/inventory/Components/UnitCombobox'
 import { UnitCombobox } from '@/features/inventory/Components/UnitCombobox'
@@ -34,7 +34,17 @@ import { toast } from 'sonner'
 import { setSettings } from '@/features/settings/Actions/settingsActions'
 import { setSettings } from '@/features/settings/Actions/settingsActions'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { assignTechToUnassignedWorkOrders } from '@/features/workboard/Actions/technicianActions'
 import { assignTechToUnassignedWorkOrders } from '@/features/workboard/Actions/technicianActions'
-import { Loader2, Percent, Ruler, Save, Wrench, Check, ChevronsUpDown, Plus } from 'lucide-react'
+import {
+  Loader2,
+  Percent,
+  Ruler,
+  Save,
+  Wrench,
+  Check,
+  ChevronsUpDown,
+  Plus,
+  Tag,
+} from 'lucide-react'
 import {
 import {
   Select,
   Select,
   SelectContent,
   SelectContent,
@@ -45,6 +55,14 @@ import {
 import { createTechnician } from '@/features/workboard/Actions/technicianActions'
 import { createTechnician } from '@/features/workboard/Actions/technicianActions'
 import { cn } from '@/lib/utils'
 import { cn } from '@/lib/utils'
 import { ReadOnlyBanner, SaveButton, ReadOnlyWrapper } from '../read-only-guard'
 import { ReadOnlyBanner, SaveButton, ReadOnlyWrapper } from '../read-only-guard'
+import {
+  DEFAULT_WORK_ORDER_TITLE_TEMPLATE,
+  MAX_WORK_ORDER_TITLE_TEMPLATE_LENGTH,
+  resolveWorkOrderTitle,
+  SAMPLE_WORK_ORDER_TITLE_VALUES,
+  unknownTokensIn,
+  WORK_ORDER_TITLE_TOKENS,
+} from '@/features/vehicles/Lib/workOrderTitle'
 import { ServiceTypeSelector } from '../company/service-type-selector'
 import { ServiceTypeSelector } from '../company/service-type-selector'
 
 
 interface TechnicianOption {
 interface TechnicianOption {
@@ -88,6 +106,31 @@ export function WorkshopSettings({
   const [workDayStart, setWorkDayStart] = useState(
   const [workDayStart, setWorkDayStart] = useState(
     settings[SETTING_KEYS.WORKBOARD_WORK_DAY_START] || '07:00'
     settings[SETTING_KEYS.WORKBOARD_WORK_DAY_START] || '07:00'
   )
   )
+  // Unset means the default; a template saved empty means the plain name,
+  // so the two are kept apart here rather than folded into one falsy value.
+  const [titleTemplate, setTitleTemplate] = useState(
+    settings[SETTING_KEYS.WORK_ORDER_TITLE_TEMPLATE] ?? DEFAULT_WORK_ORDER_TITLE_TEMPLATE
+  )
+  const titleTemplateInput = useRef<HTMLInputElement>(null)
+  const insertTitleTag = (token: string) => {
+    const tag = `{${token}}`
+    const input = titleTemplateInput.current
+    const at = input?.selectionStart ?? titleTemplate.length
+    const before = titleTemplate.slice(0, at)
+    const after = titleTemplate.slice(input?.selectionEnd ?? at)
+    // A space between two tags typed back to back, so "{a}{b}" does not
+    // print two values glued together.
+    const glue = before && !/\s$/.test(before) && !after.startsWith(' ') ? ' ' : ''
+    const next = `${before}${glue}${tag}${after}`.slice(0, MAX_WORK_ORDER_TITLE_TEMPLATE_LENGTH)
+    setTitleTemplate(next)
+    requestAnimationFrame(() => {
+      input?.focus()
+      const caret = Math.min(next.length, before.length + glue.length + tag.length)
+      input?.setSelectionRange(caret, caret)
+    })
+  }
+  const titlePreview = resolveWorkOrderTitle(titleTemplate, SAMPLE_WORK_ORDER_TITLE_VALUES)
+  const unknownTitleTags = unknownTokensIn(titleTemplate)
   const [workDayEnd, setWorkDayEnd] = useState(
   const [workDayEnd, setWorkDayEnd] = useState(
     settings[SETTING_KEYS.WORKBOARD_WORK_DAY_END] || '15:00'
     settings[SETTING_KEYS.WORKBOARD_WORK_DAY_END] || '15:00'
   )
   )
@@ -154,6 +197,7 @@ export function WorkshopSettings({
       [SETTING_KEYS.INVENTORY_DEFAULT_UNIT]: defaultUnit.trim(),
       [SETTING_KEYS.INVENTORY_DEFAULT_UNIT]: defaultUnit.trim(),
       [SETTING_KEYS.WORKBOARD_WORK_DAY_START]: workDayStart,
       [SETTING_KEYS.WORKBOARD_WORK_DAY_START]: workDayStart,
       [SETTING_KEYS.WORKBOARD_WORK_DAY_END]: workDayEnd,
       [SETTING_KEYS.WORKBOARD_WORK_DAY_END]: workDayEnd,
+      [SETTING_KEYS.WORK_ORDER_TITLE_TEMPLATE]: titleTemplate.trim(),
       [SETTING_KEYS.PARTS_DEFAULT_MARKUP_PERCENT]: defaultMarkupPercent,
       [SETTING_KEYS.PARTS_DEFAULT_MARKUP_PERCENT]: defaultMarkupPercent,
       [SETTING_KEYS.PARTS_MARKUP_APPLIES_TO_INVENTORY]: markupAppliesToInventory ? 'true' : 'false',
       [SETTING_KEYS.PARTS_MARKUP_APPLIES_TO_INVENTORY]: markupAppliesToInventory ? 'true' : 'false',
     })
     })
@@ -326,6 +370,58 @@ export function WorkshopSettings({
 
 
           <Separator />
           <Separator />
 
 
+          {/* What a job is called before anybody types a title. Resolved on
+              the server when the draft is made, so the preview here uses
+              sample values; the rules are in workOrderTitle.ts. */}
+          <div className="space-y-4">
+            <div className="flex flex-row items-center gap-3">
+              <Tag className="h-5 w-5 text-muted-foreground" />
+              <h3 className="text-lg font-semibold">{t('workshop.titleTemplateTitle')}</h3>
+            </div>
+            <p className="text-sm text-muted-foreground">
+              {t('workshop.titleTemplateDescription')}
+            </p>
+            <div className="space-y-2">
+              <Label htmlFor="workOrderTitleTemplate">{t('workshop.titleTemplate')}</Label>
+              <Input
+                id="workOrderTitleTemplate"
+                ref={titleTemplateInput}
+                value={titleTemplate}
+                maxLength={MAX_WORK_ORDER_TITLE_TEMPLATE_LENGTH}
+                placeholder={t('workshop.titleTemplatePlaceholder')}
+                autoComplete="off"
+                onChange={(e) => setTitleTemplate(e.target.value)}
+              />
+              <div className="flex flex-wrap gap-1.5">
+                {WORK_ORDER_TITLE_TOKENS.map((token) => (
+                  <Button
+                    key={token}
+                    type="button"
+                    variant="outline"
+                    size="sm"
+                    className="h-7 px-2 font-mono text-xs"
+                    data-testid={`work-order-title-tag-${token}`}
+                    title={t(`workshop.titleTemplateTags.${token}`)}
+                    onClick={() => insertTitleTag(token)}
+                  >
+                    {`{${token}}`}
+                  </Button>
+                ))}
+              </div>
+              <p className="text-xs text-muted-foreground">{t('workshop.titleTemplateHint')}</p>
+              <p className="text-sm" data-testid="work-order-title-preview">
+                {t('workshop.titleTemplatePreview', { title: titlePreview })}
+              </p>
+              {unknownTitleTags.length > 0 && (
+                <p className="text-xs text-destructive" data-testid="work-order-title-unknown">
+                  {t('workshop.titleTemplateUnknown', { tags: unknownTitleTags.join(', ') })}
+                </p>
+              )}
+            </div>
+          </div>
+
+          <Separator />
+
           {/* Pricing, not paperwork: the markup decides what a part costs the
           {/* Pricing, not paperwork: the markup decides what a part costs the
               customer, which is settled here on the job, long before an
               customer, which is settled here on the job, long before an
               invoice exists. */}
               invoice exists. */}

+ 7 - 4
src/app/(authenticated)/vehicles/[id]/service/new/page.tsx

@@ -16,9 +16,11 @@ export default async function NewServicePage({
   const { id } = await params
   const { id } = await params
   const query = await searchParams
   const query = await searchParams
 
 
-  // Guard: if a pending draft already exists for this vehicle (created within the last 5 seconds),
-  // reuse it instead of creating a duplicate. This prevents double-creation from
-  // Next.js Server Component re-renders.
+  // Guard: if an untouched pending draft already exists for this vehicle
+  // (created within the last 5 seconds, nothing on it yet), reuse it instead
+  // of creating a duplicate. This prevents double-creation from Next.js
+  // Server Component re-renders. The title is not part of the test any more:
+  // it comes from the workshop's template now, not a fixed placeholder.
   let timeZone = 'UTC'
   let timeZone = 'UTC'
   const existingResult = await withAuth(
   const existingResult = await withAuth(
     async ({ organizationId }) => {
     async ({ organizationId }) => {
@@ -29,8 +31,9 @@ export default async function NewServicePage({
           vehicleId: id,
           vehicleId: id,
           vehicle: { organizationId },
           vehicle: { organizationId },
           status: 'pending',
           status: 'pending',
-          title: 'New Service Record',
           createdAt: { gte: fiveSecondsAgo },
           createdAt: { gte: fiveSecondsAgo },
+          partItems: { none: {} },
+          laborItems: { none: {} },
         },
         },
         select: { id: true },
         select: { id: true },
         orderBy: { createdAt: 'desc' },
         orderBy: { createdAt: 'desc' },

+ 7 - 0
src/features/settings/Schema/settingsSchema.ts

@@ -43,6 +43,13 @@ export const SETTING_KEYS = {
   DEFAULT_TECHNICIAN: 'workshop.defaultTechnician',
   DEFAULT_TECHNICIAN: 'workshop.defaultTechnician',
   DEFAULT_TECHNICIAN_ID: 'workshop.defaultTechnicianId',
   DEFAULT_TECHNICIAN_ID: 'workshop.defaultTechnicianId',
   DEFAULT_LABOR_RATE: 'workshop.defaultLaborRate',
   DEFAULT_LABOR_RATE: 'workshop.defaultLaborRate',
+  /**
+   * What a new work order is called before anybody types a title, written
+   * from tags such as {order_number} and {license_plate}. Unset means the
+   * default in src/features/vehicles/Lib/workOrderTitle.ts; saved empty means
+   * the plain "New Service Record" jobs always started as.
+   */
+  WORK_ORDER_TITLE_TEMPLATE: 'workshop.workOrderTitleTemplate',
   QUOTE_PREFIX: 'workshop.quotePrefix',
   QUOTE_PREFIX: 'workshop.quotePrefix',
   QUOTE_VALID_DAYS: 'workshop.quoteValidDays',
   QUOTE_VALID_DAYS: 'workshop.quoteValidDays',
   EMAIL_FROM_NAME: 'email.fromName',
   EMAIL_FROM_NAME: 'email.fromName',

+ 2 - 1
src/features/vehicles/Actions/createDraftServiceRecord.ts

@@ -26,7 +26,8 @@ export async function createDraftServiceRecord(
           vehicleId,
           vehicleId,
           customerId: null,
           customerId: null,
           customerExempt: vehicle.customer?.taxExempt ?? false,
           customerExempt: vehicle.customer?.taxExempt ?? false,
-          title: 'New Service Record',
+          // Named from the workshop's title template once the number is known.
+          title: null,
           startDateTime,
           startDateTime,
           endDateTime,
           endDateTime,
           technicianId,
           technicianId,

+ 66 - 2
src/features/vehicles/Lib/createDraftRecord.ts

@@ -8,6 +8,12 @@ import { nextAvailableSlot } from '@/features/workboard/Lib/availability'
 import { loadBookingContext } from '@/features/workboard/Lib/bookings'
 import { loadBookingContext } from '@/features/workboard/Lib/bookings'
 import { workshopTimeZone } from '@/lib/workshop-timezone'
 import { workshopTimeZone } from '@/lib/workshop-timezone'
 import { atZonedTime, startOfZonedDay, zonedParts } from '@/lib/timezone'
 import { atZonedTime, startOfZonedDay, zonedParts } from '@/lib/timezone'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import {
+  resolveWorkOrderTitle,
+  workOrderTitleTemplateFrom,
+  workOrderTitleValues,
+} from './workOrderTitle'
 
 
 /**
 /**
  * Shared draft-record creation for both work orders (with a vehicle) and
  * Shared draft-record creation for both work orders (with a vehicle) and
@@ -23,7 +29,11 @@ export async function createDraftRecord(
     vehicleId: string | null
     vehicleId: string | null
     customerId: string | null
     customerId: string | null
     customerExempt: boolean
     customerExempt: boolean
-    title: string
+    /**
+     * The job's title, or null to name it from the workshop's title template
+     * once its number is known (see workOrderTitle.ts).
+     */
+    title: string | null
     startDateTime?: Date
     startDateTime?: Date
     endDateTime?: Date
     endDateTime?: Date
     technicianId?: string
     technicianId?: string
@@ -41,6 +51,7 @@ export async function createDraftRecord(
             'workshop.invoiceStartNumber',
             'workshop.invoiceStartNumber',
             'workshop.defaultTechnician',
             'workshop.defaultTechnician',
             'workshop.defaultTechnicianId',
             'workshop.defaultTechnicianId',
+            SETTING_KEYS.WORK_ORDER_TITLE_TEMPLATE,
             ...WORKSHOP_TAX_SETTING_KEYS,
             ...WORKSHOP_TAX_SETTING_KEYS,
             'workboard.workDayStart',
             'workboard.workDayStart',
           ],
           ],
@@ -177,10 +188,24 @@ export async function createDraftRecord(
   // serviceDate should be date-only (start of the workshop's day)
   // serviceDate should be date-only (start of the workshop's day)
   const serviceDate = startOfZonedDay(defaultStart, timeZone)
   const serviceDate = startOfZonedDay(defaultStart, timeZone)
 
 
+  // Named from the template once everything it can mention is known: the
+  // number just allocated, the car, its owner, the technician and the day.
+  const title =
+    opts.title ??
+    resolveWorkOrderTitle(
+      workOrderTitleTemplateFrom(settingsMap, SETTING_KEYS.WORK_ORDER_TITLE_TEMPLATE),
+      workOrderTitleValues({
+        orderNumber: invoiceNumber,
+        ...(await titleSubject(organizationId, opts.vehicleId, opts.customerId)),
+        technicianName: techName,
+        date: `${today.year}-${String(today.month).padStart(2, '0')}-${String(today.day).padStart(2, '0')}`,
+      })
+    )
+
   return db.serviceRecord.create({
   return db.serviceRecord.create({
     data: {
     data: {
       organizationId,
       organizationId,
-      title: opts.title,
+      title,
       type: 'maintenance',
       type: 'maintenance',
       status: 'pending',
       status: 'pending',
       vehicleId: opts.vehicleId,
       vehicleId: opts.vehicleId,
@@ -198,3 +223,42 @@ export async function createDraftRecord(
     },
     },
   })
   })
 }
 }
+
+/** The car and the customer a job is for, as far as the title template cares. */
+async function titleSubject(
+  organizationId: string,
+  vehicleId: string | null,
+  customerId: string | null
+): Promise<{
+  vehicle: {
+    licensePlate: string | null
+    make: string
+    model: string
+    year: number
+    vin: string | null
+  } | null
+  customerName: string | null
+}> {
+  if (vehicleId) {
+    const vehicle = await db.vehicle.findFirst({
+      where: { id: vehicleId, organizationId },
+      select: {
+        licensePlate: true,
+        make: true,
+        model: true,
+        year: true,
+        vin: true,
+        customer: { select: { name: true } },
+      },
+    })
+    return { vehicle, customerName: vehicle?.customer?.name ?? null }
+  }
+  if (customerId) {
+    const customer = await db.customer.findFirst({
+      where: { id: customerId, organizationId },
+      select: { name: true },
+    })
+    return { vehicle: null, customerName: customer?.name ?? null }
+  }
+  return { vehicle: null, customerName: null }
+}

+ 197 - 0
src/features/vehicles/Lib/workOrderTitle.ts

@@ -0,0 +1,197 @@
+/**
+ * What a new work order is called before anybody types a title.
+ *
+ * A workshop writes a template once, in Settings → Workshop, out of tags
+ * such as `{order_number}` and `{license_plate}`; every job created after
+ * that opens with the tags filled in from the job itself. The title is an
+ * ordinary field afterwards and can be changed like any other.
+ *
+ * Resolution is forgiving on purpose. A tag nothing is known for (a vehicle
+ * with no plate) disappears together with the separator that stood beside
+ * it, an unknown tag is dropped, and a template that comes out empty falls
+ * back to the plain name jobs always had, so a typo in the settings never
+ * produces a job called "- -" or nothing at all.
+ */
+
+export const WORK_ORDER_TITLE_TOKENS = [
+  'order_number',
+  'license_plate',
+  'customer_name',
+  'vehicle',
+  'make',
+  'model',
+  'year',
+  'vin',
+  'technician',
+  'date',
+] as const
+
+export type WorkOrderTitleToken = (typeof WORK_ORDER_TITLE_TOKENS)[number]
+
+/** Other spellings people reach for, read as the token they mean. */
+const TOKEN_ALIASES: Record<string, WorkOrderTitleToken> = {
+  order_id: 'order_number',
+  invoice_number: 'order_number',
+  job_number: 'order_number',
+  plate: 'license_plate',
+  registration: 'license_plate',
+  customer: 'customer_name',
+  client_name: 'customer_name',
+  client: 'customer_name',
+  tech: 'technician',
+}
+
+/** What every job starts with when the workshop has not said otherwise. */
+export const DEFAULT_WORK_ORDER_TITLE_TEMPLATE = '{order_number} - {license_plate}'
+
+/** The name a job had before templates existed, and what an empty result falls back to. */
+export const FALLBACK_WORK_ORDER_TITLE = 'New Service Record'
+
+/** The editor's title field takes this many characters, so the template does too. */
+export const MAX_WORK_ORDER_TITLE_LENGTH = 100
+
+/** How long a template itself may be in the settings. */
+export const MAX_WORK_ORDER_TITLE_TEMPLATE_LENGTH = 200
+
+export type WorkOrderTitleValues = Partial<Record<WorkOrderTitleToken, string | number | null>>
+
+/** Sample values for the settings page's preview. */
+export const SAMPLE_WORK_ORDER_TITLE_VALUES: WorkOrderTitleValues = {
+  order_number: '2026-1042',
+  license_plate: 'AB 12345',
+  customer_name: 'Jane Cooper',
+  vehicle: '2021 Toyota Camry',
+  make: 'Toyota',
+  model: 'Camry',
+  year: 2021,
+  vin: '4T1BF1FK5CU123456',
+  technician: 'Sam Lee',
+  date: '2026-09-11',
+}
+
+const TAG = /\{\s*([A-Za-z0-9_]+)\s*\}/g
+
+/** Characters that only ever join two values, and mean nothing on their own. */
+const SEPARATOR = '[-–—·•|/:,;#]'
+
+/** Every tag written in a template, as the token it resolves to, or null for one nobody knows. */
+export function tokensIn(template: string): { tag: string; token: WorkOrderTitleToken | null }[] {
+  const found: { tag: string; token: WorkOrderTitleToken | null }[] = []
+  for (const match of template.matchAll(TAG)) {
+    const tag = match[1].toLowerCase()
+    found.push({ tag: match[1], token: tokenFor(tag) })
+  }
+  return found
+}
+
+function tokenFor(tag: string): WorkOrderTitleToken | null {
+  if ((WORK_ORDER_TITLE_TOKENS as readonly string[]).includes(tag)) {
+    return tag as WorkOrderTitleToken
+  }
+  return TOKEN_ALIASES[tag] ?? null
+}
+
+/** The unknown tags in a template, for the settings page to point at. */
+export function unknownTokensIn(template: string): string[] {
+  return [
+    ...new Set(
+      tokensIn(template)
+        .filter((t) => !t.token)
+        .map((t) => `{${t.tag}}`)
+    ),
+  ]
+}
+
+/** Stands in for a tag that had nothing to say, until its separator is gone too. */
+const GAP = '\u0000'
+
+/**
+ * Removes the tags that came out empty, and the separator each one stood
+ * beside, without touching anything the workshop wrote or any value's own
+ * punctuation: "WO#{order_number}" keeps its hash and "2026-1042" its dash.
+ * A gap takes the separator before it when there is one, otherwise the one
+ * after it, so "A - {gap} - B" reads "A - B" and "{gap} - B" reads "B".
+ */
+function closeGaps(filled: string): string {
+  let title = filled
+  title = title.replace(new RegExp(`\\s*${SEPARATOR}\\s*${GAP}`, 'g'), '')
+  title = title.replace(new RegExp(`${GAP}\\s*${SEPARATOR}\\s*`, 'g'), '')
+  title = title.replaceAll(GAP, '')
+  // Brackets that closed around nothing: "()" or "[ ]".
+  title = title.replace(/[([]\s*[)\]]/g, '')
+  return title.replace(/\s+/g, ' ').trim()
+}
+
+/**
+ * The title a job gets from a template and what is known about it.
+ *
+ * Empty and unknown tags vanish with their separator, the result is cut to
+ * what the title field holds, and nothing at all becomes the fallback name.
+ */
+export function resolveWorkOrderTitle(
+  template: string | null | undefined,
+  values: WorkOrderTitleValues,
+  fallback = FALLBACK_WORK_ORDER_TITLE
+): string {
+  if (!template || !template.trim()) return fallback
+  const filled = template.replace(TAG, (_whole, tag: string) => {
+    const token = tokenFor(tag.toLowerCase())
+    if (!token) return GAP
+    const value = values[token]
+    if (value === null || value === undefined) return GAP
+    const text = String(value).trim()
+    return text || GAP
+  })
+  const title = closeGaps(filled).slice(0, MAX_WORK_ORDER_TITLE_LENGTH).trim()
+  return title || fallback
+}
+
+export interface WorkOrderTitleSubject {
+  orderNumber?: string | null
+  vehicle?: {
+    licensePlate?: string | null
+    make?: string | null
+    model?: string | null
+    year?: number | null
+    vin?: string | null
+  } | null
+  customerName?: string | null
+  technicianName?: string | null
+  /** The workshop's date, already formatted as YYYY-MM-DD. */
+  date?: string | null
+}
+
+/** The tag values for one job, from the pieces the draft was made with. */
+export function workOrderTitleValues(subject: WorkOrderTitleSubject): WorkOrderTitleValues {
+  const vehicle = subject.vehicle
+  const year = vehicle?.year && vehicle.year > 0 ? vehicle.year : null
+  const vehicleName = [year, vehicle?.make, vehicle?.model]
+    .filter((part) => part !== null && part !== undefined && String(part).trim() !== '')
+    .join(' ')
+  return {
+    order_number: subject.orderNumber ?? null,
+    license_plate: vehicle?.licensePlate ?? null,
+    customer_name: subject.customerName ?? null,
+    vehicle: vehicleName || null,
+    make: vehicle?.make ?? null,
+    model: vehicle?.model ?? null,
+    year,
+    vin: vehicle?.vin ?? null,
+    technician: subject.technicianName ?? null,
+    date: subject.date ?? null,
+  }
+}
+
+/**
+ * The template a workshop has chosen, from its settings. Unset means the
+ * default; a template deliberately saved empty means the plain fallback
+ * name, which is how a workshop that liked things the old way keeps them.
+ */
+export function workOrderTitleTemplateFrom(
+  settings: Record<string, string | undefined>,
+  key: string
+): string {
+  const stored = settings[key]
+  if (stored === undefined) return DEFAULT_WORK_ORDER_TITLE_TEMPLATE
+  return stored.slice(0, MAX_WORK_ORDER_TITLE_TEMPLATE_LENGTH)
+}