Parcourir la source

Let a workshop define its own work order statuses (#416)

* Let a workshop define its own work order statuses

* Let deploys pin the server actions encryption key

* Refresh the stepper when a status is renamed or recoloured
Bernt Christian Egeland il y a 1 semaine
Parent
commit
3d2f42b593
69 fichiers modifiés avec 2392 ajouts et 135 suppressions
  1. 8 1
      .github/workflows/deploy-demo.yml
  2. 8 1
      .github/workflows/deploy-prod.yml
  3. 8 1
      .github/workflows/deploy-staging.yml
  4. 4 2
      e2e/specs/work-orders/modern-layout.spec.ts
  5. 10 4
      e2e/specs/work-orders/modern-page.spec.ts
  6. 4 1
      messages/de/audit.json
  7. 7 2
      messages/de/service.json
  8. 55 0
      messages/de/settings.json
  9. 4 1
      messages/en/audit.json
  10. 7 2
      messages/en/service.json
  11. 55 0
      messages/en/settings.json
  12. 4 1
      messages/es/audit.json
  13. 7 2
      messages/es/service.json
  14. 55 0
      messages/es/settings.json
  15. 4 1
      messages/fr/audit.json
  16. 7 2
      messages/fr/service.json
  17. 55 0
      messages/fr/settings.json
  18. 4 1
      messages/it/audit.json
  19. 7 2
      messages/it/service.json
  20. 55 0
      messages/it/settings.json
  21. 4 1
      messages/lt/audit.json
  22. 7 2
      messages/lt/service.json
  23. 55 0
      messages/lt/settings.json
  24. 4 1
      messages/nb/audit.json
  25. 7 2
      messages/nb/service.json
  26. 55 0
      messages/nb/settings.json
  27. 4 1
      messages/nl/audit.json
  28. 7 2
      messages/nl/service.json
  29. 55 0
      messages/nl/settings.json
  30. 4 1
      messages/pl/audit.json
  31. 7 2
      messages/pl/service.json
  32. 55 0
      messages/pl/settings.json
  33. 4 1
      messages/pt-BR/audit.json
  34. 7 2
      messages/pt-BR/service.json
  35. 55 0
      messages/pt-BR/settings.json
  36. 4 1
      messages/pt-PT/audit.json
  37. 7 2
      messages/pt-PT/service.json
  38. 55 0
      messages/pt-PT/settings.json
  39. 4 1
      messages/ru/audit.json
  40. 7 2
      messages/ru/service.json
  41. 55 0
      messages/ru/settings.json
  42. 4 1
      messages/tr/audit.json
  43. 7 2
      messages/tr/service.json
  44. 55 0
      messages/tr/settings.json
  45. 39 0
      prisma/migrations/20260921180000_work_order_statuses/migration.sql
  46. 1 0
      prisma/schema/organization.prisma
  47. 42 0
      prisma/schema/service-records.prisma
  48. 126 0
      src/__tests__/features/work-order-statuses/status-rules.test.ts
  49. 7 7
      src/__tests__/i18n/keys-resolve.test.ts
  50. 2 0
      src/app/(authenticated)/settings/settings-nav.tsx
  51. 7 0
      src/app/(authenticated)/settings/work-order-statuses/page.tsx
  52. 28 6
      src/app/(authenticated)/work-orders/work-orders-client.tsx
  53. 8 0
      src/app/api/protected/backup/export/route.ts
  54. 39 0
      src/app/api/protected/backup/import/route.ts
  55. 4 1
      src/app/api/v1/tech/jobs/[id]/status/route.ts
  56. 3 0
      src/features/ai/tools/workshop-columns.ts
  57. 45 4
      src/features/vehicles/Actions/serviceActions.ts
  58. 3 0
      src/features/vehicles/Components/service-detail/types.ts
  59. 33 0
      src/features/vehicles/Components/service-page/ServicePageClient.tsx
  60. 8 0
      src/features/vehicles/Components/service-page/ServiceRecordPage.tsx
  61. 9 0
      src/features/vehicles/Components/service-page/modern/ActivityCard.tsx
  62. 41 10
      src/features/vehicles/Components/service-page/modern/ModernDetails.tsx
  63. 152 59
      src/features/vehicles/Components/service-page/modern/StatusStepper.tsx
  64. 3 0
      src/features/vehicles/Components/service-page/service-page-types.ts
  65. 198 0
      src/features/work-order-statuses/Actions/workOrderStatusActions.ts
  66. 531 0
      src/features/work-order-statuses/Components/WorkOrderStatusSettings.tsx
  67. 136 0
      src/features/work-order-statuses/Lib/stages.ts
  68. 22 0
      src/features/work-order-statuses/Schema/workOrderStatusSchema.ts
  69. 9 0
      src/lib/backup/manifest.ts

+ 8 - 1
.github/workflows/deploy-demo.yml

@@ -76,6 +76,12 @@ jobs:
           VIRTUAL_HOST: "demo.torqvoice.com"
           VIRTUAL_HOST: "demo.torqvoice.com"
           DATABASE_URL: ${{ secrets.DEMO_DATABASE_URL }}
           DATABASE_URL: ${{ secrets.DEMO_DATABASE_URL }}
           BETTER_AUTH_SECRET: ${{ secrets.DEMO_BETTER_AUTH_SECRET }}
           BETTER_AUTH_SECRET: ${{ secrets.DEMO_BETTER_AUTH_SECRET }}
+          # Optional. Next encrypts what a server action closes over with a key
+          # made fresh by every build, so a tab left open across a deploy can
+          # no longer be read by the new container. A fixed key keeps those tabs
+          # working. 32 random bytes, base64: `openssl rand -base64 32`. Left
+          # unset, the build's own key is used, as before.
+          NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ secrets.DEMO_NEXT_SERVER_ACTIONS_ENCRYPTION_KEY }}
           APP_URL: "https://demo.torqvoice.com"
           APP_URL: "https://demo.torqvoice.com"
           DEMO_USER_EMAIL: "demo@torqvoice.com"
           DEMO_USER_EMAIL: "demo@torqvoice.com"
           DEMO_USER_PASSWORD: "demo"
           DEMO_USER_PASSWORD: "demo"
@@ -87,7 +93,7 @@ jobs:
           mkdir -p $HOME/torqvoice-deploy/demo
           mkdir -p $HOME/torqvoice-deploy/demo
           cd $HOME/torqvoice-deploy/demo
           cd $HOME/torqvoice-deploy/demo
 
 
-          env | grep -E '^(VIRTUAL_HOST|DATA_PATH|DATABASE_URL|BETTER_AUTH_SECRET|APP_URL|DEMO_|POSTHOG_)' > .env
+          env | grep -E '^(VIRTUAL_HOST|DATA_PATH|DATABASE_URL|BETTER_AUTH_SECRET|APP_URL|DEMO_|POSTHOG_|NEXT_SERVER_ACTIONS_)' > .env
 
 
           cat > docker-compose.yml << 'COMPOSE'
           cat > docker-compose.yml << 'COMPOSE'
           networks:
           networks:
@@ -109,6 +115,7 @@ jobs:
                 LETSENCRYPT_HOST: ${VIRTUAL_HOST}
                 LETSENCRYPT_HOST: ${VIRTUAL_HOST}
                 DATABASE_URL: ${DATABASE_URL}
                 DATABASE_URL: ${DATABASE_URL}
                 BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
                 BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
+                NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${NEXT_SERVER_ACTIONS_ENCRYPTION_KEY}
                 NEXT_PUBLIC_APP_URL: ${APP_URL}
                 NEXT_PUBLIC_APP_URL: ${APP_URL}
                 DEMO_MODE: "true"
                 DEMO_MODE: "true"
                 DEMO_USER_EMAIL: ${DEMO_USER_EMAIL}
                 DEMO_USER_EMAIL: ${DEMO_USER_EMAIL}

+ 8 - 1
.github/workflows/deploy-prod.yml

@@ -54,6 +54,12 @@ jobs:
           POSTHOG_KEY: ${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }}
           POSTHOG_KEY: ${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }}
           BACKUP_HEARTBEAT_TOKEN: ${{ secrets.BACKUP_HEARTBEAT_TOKEN }}
           BACKUP_HEARTBEAT_TOKEN: ${{ secrets.BACKUP_HEARTBEAT_TOKEN }}
           INTEGRATIONS_ENCRYPTION_KEY: ${{ secrets.CLOUD_INTEGRATIONS_ENCRYPTION_KEY }}
           INTEGRATIONS_ENCRYPTION_KEY: ${{ secrets.CLOUD_INTEGRATIONS_ENCRYPTION_KEY }}
+          # Optional. Next encrypts what a server action closes over with a key
+          # made fresh by every build, so a tab left open across a deploy can
+          # no longer be read by the new container. A fixed key keeps those tabs
+          # working. 32 random bytes, base64: `openssl rand -base64 32`. Left
+          # unset, the build's own key is used, as before.
+          NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ secrets.CLOUD_NEXT_SERVER_ACTIONS_ENCRYPTION_KEY }}
           GOOGLE_INTEGRATION_CLIENT_ID: ${{ secrets.CLOUD_GOOGLE_INTEGRATION_CLIENT_ID }}
           GOOGLE_INTEGRATION_CLIENT_ID: ${{ secrets.CLOUD_GOOGLE_INTEGRATION_CLIENT_ID }}
           GOOGLE_INTEGRATION_CLIENT_SECRET: ${{ secrets.CLOUD_GOOGLE_INTEGRATION_CLIENT_SECRET }}
           GOOGLE_INTEGRATION_CLIENT_SECRET: ${{ secrets.CLOUD_GOOGLE_INTEGRATION_CLIENT_SECRET }}
           GOOGLE_AUTH_CLIENT_ID: ${{ secrets.CLOUD_GOOGLE_AUTH_CLIENT_ID }}
           GOOGLE_AUTH_CLIENT_ID: ${{ secrets.CLOUD_GOOGLE_AUTH_CLIENT_ID }}
@@ -68,7 +74,7 @@ jobs:
           mkdir -p $HOME/torqvoice-deploy/prod
           mkdir -p $HOME/torqvoice-deploy/prod
           cd $HOME/torqvoice-deploy/prod
           cd $HOME/torqvoice-deploy/prod
 
 
-          env | grep -E '^(VIRTUAL_HOST|DATA_PATH|DATABASE_URL|BETTER_AUTH_SECRET|APP_URL|TORQVOICE_|POSTHOG_|BACKUP_|INTEGRATIONS_|GOOGLE_INTEGRATION_|GOOGLE_AUTH_|MICROSOFT_INTEGRATION_|ZOOM_INTEGRATION_|QUICKBOOKS_INTEGRATION_)' > .env
+          env | grep -E '^(VIRTUAL_HOST|DATA_PATH|DATABASE_URL|BETTER_AUTH_SECRET|APP_URL|TORQVOICE_|POSTHOG_|BACKUP_|INTEGRATIONS_|GOOGLE_INTEGRATION_|GOOGLE_AUTH_|MICROSOFT_INTEGRATION_|ZOOM_INTEGRATION_|QUICKBOOKS_INTEGRATION_|NEXT_SERVER_ACTIONS_)' > .env
 
 
           cat > docker-compose.yml << 'COMPOSE'
           cat > docker-compose.yml << 'COMPOSE'
           networks:
           networks:
@@ -103,6 +109,7 @@ jobs:
                 POSTHOG_KEY: ${POSTHOG_KEY}
                 POSTHOG_KEY: ${POSTHOG_KEY}
                 BACKUP_HEARTBEAT_TOKEN: ${BACKUP_HEARTBEAT_TOKEN}
                 BACKUP_HEARTBEAT_TOKEN: ${BACKUP_HEARTBEAT_TOKEN}
                 INTEGRATIONS_ENCRYPTION_KEY: ${INTEGRATIONS_ENCRYPTION_KEY}
                 INTEGRATIONS_ENCRYPTION_KEY: ${INTEGRATIONS_ENCRYPTION_KEY}
+                NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${NEXT_SERVER_ACTIONS_ENCRYPTION_KEY}
                 GOOGLE_INTEGRATION_CLIENT_ID: ${GOOGLE_INTEGRATION_CLIENT_ID}
                 GOOGLE_INTEGRATION_CLIENT_ID: ${GOOGLE_INTEGRATION_CLIENT_ID}
                 GOOGLE_INTEGRATION_CLIENT_SECRET: ${GOOGLE_INTEGRATION_CLIENT_SECRET}
                 GOOGLE_INTEGRATION_CLIENT_SECRET: ${GOOGLE_INTEGRATION_CLIENT_SECRET}
                 GOOGLE_AUTH_CLIENT_ID: ${GOOGLE_AUTH_CLIENT_ID}
                 GOOGLE_AUTH_CLIENT_ID: ${GOOGLE_AUTH_CLIENT_ID}

+ 8 - 1
.github/workflows/deploy-staging.yml

@@ -38,6 +38,12 @@ jobs:
           POSTHOG_KEY: ${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }}
           POSTHOG_KEY: ${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }}
           BACKUP_HEARTBEAT_TOKEN: ${{ secrets.BACKUP_HEARTBEAT_TOKEN }}
           BACKUP_HEARTBEAT_TOKEN: ${{ secrets.BACKUP_HEARTBEAT_TOKEN }}
           INTEGRATIONS_ENCRYPTION_KEY: ${{ secrets.STAGING_INTEGRATIONS_ENCRYPTION_KEY }}
           INTEGRATIONS_ENCRYPTION_KEY: ${{ secrets.STAGING_INTEGRATIONS_ENCRYPTION_KEY }}
+          # Optional. Next encrypts what a server action closes over with a key
+          # made fresh by every build, so a tab left open across a deploy can
+          # no longer be read by the new container. A fixed key keeps those tabs
+          # working. 32 random bytes, base64: `openssl rand -base64 32`. Left
+          # unset, the build's own key is used, as before.
+          NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ secrets.STAGING_NEXT_SERVER_ACTIONS_ENCRYPTION_KEY }}
           GOOGLE_INTEGRATION_CLIENT_ID: ${{ secrets.STAGING_GOOGLE_INTEGRATION_CLIENT_ID }}
           GOOGLE_INTEGRATION_CLIENT_ID: ${{ secrets.STAGING_GOOGLE_INTEGRATION_CLIENT_ID }}
           GOOGLE_INTEGRATION_CLIENT_SECRET: ${{ secrets.STAGING_GOOGLE_INTEGRATION_CLIENT_SECRET }}
           GOOGLE_INTEGRATION_CLIENT_SECRET: ${{ secrets.STAGING_GOOGLE_INTEGRATION_CLIENT_SECRET }}
           GOOGLE_AUTH_CLIENT_ID: ${{ secrets.STAGING_GOOGLE_AUTH_CLIENT_ID }}
           GOOGLE_AUTH_CLIENT_ID: ${{ secrets.STAGING_GOOGLE_AUTH_CLIENT_ID }}
@@ -52,7 +58,7 @@ jobs:
           mkdir -p $HOME/torqvoice-deploy/staging
           mkdir -p $HOME/torqvoice-deploy/staging
           cd $HOME/torqvoice-deploy/staging
           cd $HOME/torqvoice-deploy/staging
 
 
-          env | grep -E '^(VIRTUAL_HOST|DATA_PATH|DATABASE_URL|BETTER_AUTH_SECRET|APP_URL|TORQVOICE_|POSTHOG_|BACKUP_|INTEGRATIONS_|GOOGLE_INTEGRATION_|GOOGLE_AUTH_|MICROSOFT_INTEGRATION_|ZOOM_INTEGRATION_|QUICKBOOKS_INTEGRATION_)' > .env
+          env | grep -E '^(VIRTUAL_HOST|DATA_PATH|DATABASE_URL|BETTER_AUTH_SECRET|APP_URL|TORQVOICE_|POSTHOG_|BACKUP_|INTEGRATIONS_|GOOGLE_INTEGRATION_|GOOGLE_AUTH_|MICROSOFT_INTEGRATION_|ZOOM_INTEGRATION_|QUICKBOOKS_INTEGRATION_|NEXT_SERVER_ACTIONS_)' > .env
 
 
           cat > docker-compose.yml << 'COMPOSE'
           cat > docker-compose.yml << 'COMPOSE'
           networks:
           networks:
@@ -84,6 +90,7 @@ jobs:
                 POSTHOG_HOST: ${POSTHOG_HOST}
                 POSTHOG_HOST: ${POSTHOG_HOST}
                 BACKUP_HEARTBEAT_TOKEN: ${BACKUP_HEARTBEAT_TOKEN}
                 BACKUP_HEARTBEAT_TOKEN: ${BACKUP_HEARTBEAT_TOKEN}
                 INTEGRATIONS_ENCRYPTION_KEY: ${INTEGRATIONS_ENCRYPTION_KEY}
                 INTEGRATIONS_ENCRYPTION_KEY: ${INTEGRATIONS_ENCRYPTION_KEY}
+                NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${NEXT_SERVER_ACTIONS_ENCRYPTION_KEY}
                 GOOGLE_INTEGRATION_CLIENT_ID: ${GOOGLE_INTEGRATION_CLIENT_ID}
                 GOOGLE_INTEGRATION_CLIENT_ID: ${GOOGLE_INTEGRATION_CLIENT_ID}
                 GOOGLE_INTEGRATION_CLIENT_SECRET: ${GOOGLE_INTEGRATION_CLIENT_SECRET}
                 GOOGLE_INTEGRATION_CLIENT_SECRET: ${GOOGLE_INTEGRATION_CLIENT_SECRET}
                 GOOGLE_AUTH_CLIENT_ID: ${GOOGLE_AUTH_CLIENT_ID}
                 GOOGLE_AUTH_CLIENT_ID: ${GOOGLE_AUTH_CLIENT_ID}

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

@@ -178,8 +178,10 @@ test.describe('the overhauled work order page', () => {
     await expect(page.getByTestId('service-opened')).toBeHidden()
     await expect(page.getByTestId('service-opened')).toBeHidden()
 
 
     // Each control of the status stepper stays in its own space: a label is
     // Each control of the status stepper stays in its own space: a label is
-    // cut short rather than running into the next. Three stages and the
-    // waiting-for-parts hold beside the second, four buttons in all.
+    // cut short rather than running into the next. Three stages, and the
+    // arrow beside "In Progress" that opens the statuses filed under it
+    // (always there, for "Waiting Parts"): four buttons in all on a workshop
+    // that has defined no statuses of its own.
     const stages = await page
     const stages = await page
       .getByTestId('status-stepper')
       .getByTestId('status-stepper')
       .getByRole('button')
       .getByRole('button')

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

@@ -467,10 +467,16 @@ test.describe('the overhauled work order page', () => {
         'false'
         'false'
       )
       )
 
 
-      // Still moving: the status, saved on its own.
-      await stepper(page)
-        .getByRole('button', { name: /Waiting Parts/ })
-        .click()
+      // Still moving: the status, saved on its own. "Waiting Parts" is filed
+      // under "In Progress", in the menu behind that stage's arrow, and the
+      // stage then reads "In Progress: Waiting Parts".
+      await expect(async () => {
+        await page.getByTestId('status-menu-in-progress').click()
+        await expect(page.getByRole('menuitem', { name: 'Waiting Parts' })).toBeVisible({
+          timeout: 2_000,
+        })
+      }).toPass({ timeout: 30_000 })
+      await page.getByRole('menuitem', { name: 'Waiting Parts' }).click()
       await expectStatus(page, /Waiting Parts/)
       await expectStatus(page, /Waiting Parts/)
 
 
       // Still written: the internal notes, saved on their own.
       // Still written: the internal notes, saved on their own.

+ 4 - 1
messages/de/audit.json

@@ -176,7 +176,10 @@
     "service_videoCall_send": "Link zum Videoanruf gesendet",
     "service_videoCall_send": "Link zum Videoanruf gesendet",
     "timeEntry_create": "Zeiteintrag hinzugefügt",
     "timeEntry_create": "Zeiteintrag hinzugefügt",
     "timeEntry_update": "Zeiteintrag korrigiert",
     "timeEntry_update": "Zeiteintrag korrigiert",
-    "timeEntry_delete": "Zeiteintrag gelöscht"
+    "timeEntry_delete": "Zeiteintrag gelöscht",
+    "settings_workOrderStatus_create": "Auftragsstatus hinzugefügt",
+    "settings_workOrderStatus_update": "Auftragsstatus geändert",
+    "settings_workOrderStatus_archive": "Auftragsstatus entfernt"
   },
   },
   "summary": {
   "summary": {
     "customField_create": "Benutzerdefiniertes Feld „{name}“ erstellt",
     "customField_create": "Benutzerdefiniertes Feld „{name}“ erstellt",

+ 7 - 2
messages/de/service.json

@@ -587,7 +587,8 @@
       "messageSent": "Nachricht an den Kunden gesendet",
       "messageSent": "Nachricht an den Kunden gesendet",
       "linkShared": "Rechnungslink geteilt",
       "linkShared": "Rechnungslink geteilt",
       "linkViewed": "{count, plural, one {Kunde hat den Link geöffnet} other {Kunde hat den Link # Mal geöffnet}}",
       "linkViewed": "{count, plural, one {Kunde hat den Link geöffnet} other {Kunde hat den Link # Mal geöffnet}}",
-      "paymentTaken": "Zahlung von {amount} aufgezeichnet"
+      "paymentTaken": "Zahlung von {amount} aufgezeichnet",
+      "statusSet": "Status auf {status} gesetzt"
     },
     },
     "invite": {
     "invite": {
       "title": "Die Seite für Arbeitsaufträge wurde überarbeitet.",
       "title": "Die Seite für Arbeitsaufträge wurde überarbeitet.",
@@ -647,7 +648,8 @@
       "purchasePrice": "Kaufpreis",
       "purchasePrice": "Kaufpreis",
       "overdue": "überfällig",
       "overdue": "überfällig",
       "customerSince": "Kunde seit"
       "customerSince": "Kunde seit"
-    }
+    },
+    "moreStatuses": "Weitere Status"
   },
   },
   "photoHandoff": {
   "photoHandoff": {
     "button": "Vom Handy hinzufügen",
     "button": "Vom Handy hinzufügen",
@@ -726,5 +728,8 @@
     "paid": "Zahlung eingegangen",
     "paid": "Zahlung eingegangen",
     "paidBody": "Die Zahlung steht auf dem Arbeitsauftrag.",
     "paidBody": "Die Zahlung steht auf dem Arbeitsauftrag.",
     "done": "Fertig"
     "done": "Fertig"
+  },
+  "customStatus": {
+    "defaultMessage": "Hallo {customer_name}, ein Update zu Ihrem {vehicle}: {status}. {company_name}"
   }
   }
 }
 }

+ 55 - 0
messages/de/settings.json

@@ -142,6 +142,10 @@
       "emailTemplates": {
       "emailTemplates": {
         "title": "E-Mail-Vorlagen",
         "title": "E-Mail-Vorlagen",
         "description": "Was Ihre E-Mails zu Rechnung, Angebot und Bericht sagen"
         "description": "Was Ihre E-Mails zu Rechnung, Angebot und Bericht sagen"
+      },
+      "workOrderStatuses": {
+        "title": "Auftragsstatus",
+        "description": "Eigene Schritte, und wer informiert wird"
       }
       }
     },
     },
     "pro": "PRO",
     "pro": "PRO",
@@ -2490,5 +2494,56 @@
     "dragHandle": "Zum Verschieben ziehen",
     "dragHandle": "Zum Verschieben ziehen",
     "dragHandleKeyboard": "Ziehen Sie diesen Block oder verschieben Sie ihn mit den Pfeiltasten",
     "dragHandleKeyboard": "Ziehen Sie diesen Block oder verschieben Sie ihn mit den Pfeiltasten",
     "tagDoesNotFit": "Das Tag passt nicht in dieses Feld."
     "tagDoesNotFit": "Das Tag passt nicht in dieses Feld."
+  },
+  "workOrderStatuses": {
+    "title": "Auftragsstatus",
+    "description": "Ihre eigenen Namen für die Schritte eines Auftrags. Jeder Status gehört zu einer der drei Phasen und erscheint im Menü dieser Phase auf jedem Arbeitsauftrag.",
+    "cardTitle": "Phasen und Status",
+    "suggestionsLabel": "Schnell hinzufügen",
+    "suggestions": {
+      "readyForPickup": {
+        "name": "Abholbereit",
+        "message": "Hallo {customer_name}, Ihr {vehicle} ist abholbereit. {company_name}"
+      },
+      "waitingForCustomer": {
+        "name": "Warten auf Kunden"
+      },
+      "waitingForApproval": {
+        "name": "Warten auf Freigabe",
+        "message": "Hallo {customer_name}, wir benötigen Ihre Freigabe, bevor wir an Ihrem {vehicle} weiterarbeiten. Bitte melden Sie sich. {company_name}"
+      }
+    },
+    "stageHints": {
+      "pending": "Bevor die Arbeit beginnt. Der Auftrag gilt als offen.",
+      "in-progress": "Während am Fahrzeug gearbeitet wird. Der Auftrag gilt als offen.",
+      "completed": "Die Arbeit ist erledigt. Der Kunde kann die Rechnung sehen und bezahlen, und der Auftrag verschwindet aus der Liste des Mechanikers."
+    },
+    "add": "Status hinzufügen",
+    "builtIn": "Integriert",
+    "asksToNotify": "Bietet Benachrichtigung an",
+    "notifyHint": "Erreicht ein Auftrag diesen Status, wird angeboten, dem Kunden eine Nachricht zu senden. Nichts wird ohne Ihre Bestätigung gesendet.",
+    "moveUp": "Nach oben",
+    "moveDown": "Nach unten",
+    "edit": "Bearbeiten",
+    "remove": "Entfernen",
+    "empty": "Hier gibt es noch keine eigenen Status.",
+    "removeTitle": "{name} entfernen?",
+    "removeBody": "Er verschwindet aus den Menüs. Aufträge, die ihn tragen, zeigen ihn weiterhin.",
+    "addTitle": "Neuer Status",
+    "editTitle": "Status bearbeiten",
+    "dialogBody": "Ein Status ist ein Name mit einer Farbe. Die Phase, zu der er gehört, bestimmt, wie der Auftrag behandelt wird.",
+    "name": "Name",
+    "namePlaceholder": "Abholbereit",
+    "stage": "Phase",
+    "color": "Farbe",
+    "notify": "Benachrichtigung des Kunden anbieten",
+    "message": "Nachricht",
+    "tokens": "Einfügen:",
+    "cancel": "Abbrechen",
+    "save": "Speichern",
+    "saved": "Status gespeichert",
+    "failed": "Der Status konnte nicht gespeichert werden",
+    "messageEmptyHint": "Bleibt das Feld leer, wird der Text oben gesendet.",
+    "useSuggested": "Diesen Text bearbeiten"
   }
   }
 }
 }

+ 4 - 1
messages/en/audit.json

@@ -176,7 +176,10 @@
     "service_videoCall_send": "Sent video call link",
     "service_videoCall_send": "Sent video call link",
     "timeEntry_create": "Added time entry",
     "timeEntry_create": "Added time entry",
     "timeEntry_update": "Corrected time entry",
     "timeEntry_update": "Corrected time entry",
-    "timeEntry_delete": "Deleted time entry"
+    "timeEntry_delete": "Deleted time entry",
+    "settings_workOrderStatus_create": "Added Work Order Status",
+    "settings_workOrderStatus_update": "Changed Work Order Status",
+    "settings_workOrderStatus_archive": "Removed Work Order Status"
   },
   },
   "summary": {
   "summary": {
     "customField_create": "Created custom field \"{name}\"",
     "customField_create": "Created custom field \"{name}\"",

+ 7 - 2
messages/en/service.json

@@ -587,7 +587,8 @@
       "messageSent": "Message sent to the customer",
       "messageSent": "Message sent to the customer",
       "linkShared": "Invoice link shared",
       "linkShared": "Invoice link shared",
       "linkViewed": "{count, plural, one {Customer opened the link} other {Customer opened the link # times}}",
       "linkViewed": "{count, plural, one {Customer opened the link} other {Customer opened the link # times}}",
-      "paymentTaken": "Payment of {amount} recorded"
+      "paymentTaken": "Payment of {amount} recorded",
+      "statusSet": "Status set to {status}"
     },
     },
     "invite": {
     "invite": {
       "title": "The work order page has been overhauled.",
       "title": "The work order page has been overhauled.",
@@ -647,7 +648,8 @@
       "purchasePrice": "Purchase price",
       "purchasePrice": "Purchase price",
       "overdue": "overdue",
       "overdue": "overdue",
       "customerSince": "Customer since"
       "customerSince": "Customer since"
-    }
+    },
+    "moreStatuses": "More statuses"
   },
   },
   "photoHandoff": {
   "photoHandoff": {
     "button": "Add from phone",
     "button": "Add from phone",
@@ -726,5 +728,8 @@
     "paid": "Payment received",
     "paid": "Payment received",
     "paidBody": "The payment is on the work order.",
     "paidBody": "The payment is on the work order.",
     "done": "Done"
     "done": "Done"
+  },
+  "customStatus": {
+    "defaultMessage": "Hi {customer_name}, an update on your {vehicle}: {status}. {company_name}"
   }
   }
 }
 }

+ 55 - 0
messages/en/settings.json

@@ -142,6 +142,10 @@
       "emailTemplates": {
       "emailTemplates": {
         "title": "Email templates",
         "title": "Email templates",
         "description": "What your invoice, quote and report emails say"
         "description": "What your invoice, quote and report emails say"
+      },
+      "workOrderStatuses": {
+        "title": "Work order statuses",
+        "description": "Your own steps, and who gets told"
       }
       }
     },
     },
     "pro": "PRO",
     "pro": "PRO",
@@ -2490,5 +2494,56 @@
     "dragHandle": "Drag to reorder",
     "dragHandle": "Drag to reorder",
     "dragHandleKeyboard": "Drag, or use the arrow keys, to move this block",
     "dragHandleKeyboard": "Drag, or use the arrow keys, to move this block",
     "tagDoesNotFit": "The tag does not fit in this field."
     "tagDoesNotFit": "The tag does not fit in this field."
+  },
+  "workOrderStatuses": {
+    "title": "Work order statuses",
+    "description": "Your own names for the steps a job goes through. Each sits under one of the three stages and shows up in that stage's menu on every work order.",
+    "cardTitle": "Stages and statuses",
+    "suggestionsLabel": "Quick add",
+    "suggestions": {
+      "readyForPickup": {
+        "name": "Ready for pickup",
+        "message": "Hi {customer_name}, your {vehicle} is ready for pickup. {company_name}"
+      },
+      "waitingForCustomer": {
+        "name": "Waiting for customer"
+      },
+      "waitingForApproval": {
+        "name": "Waiting for approval",
+        "message": "Hi {customer_name}, we need your approval before we continue on your {vehicle}. Please get in touch. {company_name}"
+      }
+    },
+    "stageHints": {
+      "pending": "Before work starts. The job counts as open.",
+      "in-progress": "While the vehicle is being worked on. The job counts as open.",
+      "completed": "The work is done. The customer can see and pay the invoice, and the job leaves the technician's list."
+    },
+    "add": "Add status",
+    "builtIn": "Built in",
+    "asksToNotify": "Offers to notify",
+    "notifyHint": "When a job reaches this status, offer to send the customer a message. Nothing is sent until you confirm it.",
+    "moveUp": "Move up",
+    "moveDown": "Move down",
+    "edit": "Edit",
+    "remove": "Remove",
+    "empty": "No statuses of your own here yet.",
+    "removeTitle": "Remove {name}?",
+    "removeBody": "It leaves the menus. Jobs that carry it keep showing it.",
+    "addTitle": "New status",
+    "editTitle": "Edit status",
+    "dialogBody": "A status is a name with a colour. The stage it sits under decides how the job is treated.",
+    "name": "Name",
+    "namePlaceholder": "Ready for pickup",
+    "stage": "Stage",
+    "color": "Colour",
+    "notify": "Offer to notify the customer",
+    "message": "Message",
+    "tokens": "Insert:",
+    "cancel": "Cancel",
+    "save": "Save",
+    "saved": "Status saved",
+    "failed": "Could not save the status",
+    "messageEmptyHint": "Left empty, the text above is sent.",
+    "useSuggested": "Edit this text"
   }
   }
 }
 }

+ 4 - 1
messages/es/audit.json

@@ -176,7 +176,10 @@
     "service_videoCall_send": "Enlace de videollamada enviado",
     "service_videoCall_send": "Enlace de videollamada enviado",
     "timeEntry_create": "Entrada de tiempo añadida",
     "timeEntry_create": "Entrada de tiempo añadida",
     "timeEntry_update": "Entrada de tiempo corregida",
     "timeEntry_update": "Entrada de tiempo corregida",
-    "timeEntry_delete": "Entrada de tiempo eliminada"
+    "timeEntry_delete": "Entrada de tiempo eliminada",
+    "settings_workOrderStatus_create": "Estado de orden añadido",
+    "settings_workOrderStatus_update": "Estado de orden modificado",
+    "settings_workOrderStatus_archive": "Estado de orden quitado"
   },
   },
   "summary": {
   "summary": {
     "customField_create": "Campo personalizado «{name}» creado",
     "customField_create": "Campo personalizado «{name}» creado",

+ 7 - 2
messages/es/service.json

@@ -587,7 +587,8 @@
       "messageSent": "Mensaje enviado al cliente",
       "messageSent": "Mensaje enviado al cliente",
       "linkShared": "Enlace de la factura compartido",
       "linkShared": "Enlace de la factura compartido",
       "linkViewed": "{count, plural, one {El cliente abrió el enlace} other {El cliente abrió el enlace # veces}}",
       "linkViewed": "{count, plural, one {El cliente abrió el enlace} other {El cliente abrió el enlace # veces}}",
-      "paymentTaken": "Pago de {amount} registrado"
+      "paymentTaken": "Pago de {amount} registrado",
+      "statusSet": "Estado cambiado a {status}"
     },
     },
     "invite": {
     "invite": {
       "title": "La página de la orden de trabajo se ha renovado.",
       "title": "La página de la orden de trabajo se ha renovado.",
@@ -647,7 +648,8 @@
       "purchasePrice": "Precio de compra",
       "purchasePrice": "Precio de compra",
       "overdue": "vencida",
       "overdue": "vencida",
       "customerSince": "Cliente desde"
       "customerSince": "Cliente desde"
-    }
+    },
+    "moreStatuses": "Más estados"
   },
   },
   "photoHandoff": {
   "photoHandoff": {
     "button": "Añadir desde el móvil",
     "button": "Añadir desde el móvil",
@@ -726,5 +728,8 @@
     "paid": "Pago recibido",
     "paid": "Pago recibido",
     "paidBody": "El pago ya está en la orden de trabajo.",
     "paidBody": "El pago ya está en la orden de trabajo.",
     "done": "Listo"
     "done": "Listo"
+  },
+  "customStatus": {
+    "defaultMessage": "Hola {customer_name}, novedades sobre su {vehicle}: {status}. {company_name}"
   }
   }
 }
 }

+ 55 - 0
messages/es/settings.json

@@ -142,6 +142,10 @@
       "emailTemplates": {
       "emailTemplates": {
         "title": "Plantillas de correo",
         "title": "Plantillas de correo",
         "description": "Lo que dicen tus correos de factura, presupuesto e informe"
         "description": "Lo que dicen tus correos de factura, presupuesto e informe"
+      },
+      "workOrderStatuses": {
+        "title": "Estados de la orden",
+        "description": "Sus propios pasos, y a quién se avisa"
       }
       }
     },
     },
     "pro": "PRO",
     "pro": "PRO",
@@ -2490,5 +2494,56 @@
     "dragHandle": "Arrastra para reordenar",
     "dragHandle": "Arrastra para reordenar",
     "dragHandleKeyboard": "Arrastra, o usa las teclas de flecha, para mover este bloque",
     "dragHandleKeyboard": "Arrastra, o usa las teclas de flecha, para mover este bloque",
     "tagDoesNotFit": "La etiqueta no cabe en este campo."
     "tagDoesNotFit": "La etiqueta no cabe en este campo."
+  },
+  "workOrderStatuses": {
+    "title": "Estados de la orden de trabajo",
+    "description": "Sus propios nombres para los pasos de un trabajo. Cada uno pertenece a una de las tres etapas y aparece en el menú de esa etapa en cada orden de trabajo.",
+    "cardTitle": "Etapas y estados",
+    "suggestionsLabel": "Añadir rápido",
+    "suggestions": {
+      "readyForPickup": {
+        "name": "Listo para recoger",
+        "message": "Hola {customer_name}, su {vehicle} está listo para recoger. {company_name}"
+      },
+      "waitingForCustomer": {
+        "name": "Esperando al cliente"
+      },
+      "waitingForApproval": {
+        "name": "Esperando aprobación",
+        "message": "Hola {customer_name}, necesitamos su aprobación antes de continuar con su {vehicle}. Póngase en contacto con nosotros. {company_name}"
+      }
+    },
+    "stageHints": {
+      "pending": "Antes de empezar el trabajo. El trabajo cuenta como abierto.",
+      "in-progress": "Mientras se trabaja en el vehículo. El trabajo cuenta como abierto.",
+      "completed": "El trabajo está hecho. El cliente puede ver y pagar la factura, y el trabajo sale de la lista del técnico."
+    },
+    "add": "Añadir estado",
+    "builtIn": "Integrado",
+    "asksToNotify": "Ofrece avisar",
+    "notifyHint": "Cuando un trabajo llega a este estado, se ofrece enviar un mensaje al cliente. No se envía nada hasta que usted lo confirme.",
+    "moveUp": "Subir",
+    "moveDown": "Bajar",
+    "edit": "Editar",
+    "remove": "Quitar",
+    "empty": "Aún no hay estados propios aquí.",
+    "removeTitle": "¿Quitar {name}?",
+    "removeBody": "Desaparece de los menús. Los trabajos que lo tienen lo siguen mostrando.",
+    "addTitle": "Nuevo estado",
+    "editTitle": "Editar estado",
+    "dialogBody": "Un estado es un nombre con un color. La etapa a la que pertenece decide cómo se trata el trabajo.",
+    "name": "Nombre",
+    "namePlaceholder": "Listo para recoger",
+    "stage": "Etapa",
+    "color": "Color",
+    "notify": "Ofrecer avisar al cliente",
+    "message": "Mensaje",
+    "tokens": "Insertar:",
+    "cancel": "Cancelar",
+    "save": "Guardar",
+    "saved": "Estado guardado",
+    "failed": "No se pudo guardar el estado",
+    "messageEmptyHint": "Si se deja vacío, se envía el texto de arriba.",
+    "useSuggested": "Editar este texto"
   }
   }
 }
 }

+ 4 - 1
messages/fr/audit.json

@@ -176,7 +176,10 @@
     "service_videoCall_send": "Lien d'appel vidéo envoyé",
     "service_videoCall_send": "Lien d'appel vidéo envoyé",
     "timeEntry_create": "Entrée de temps ajoutée",
     "timeEntry_create": "Entrée de temps ajoutée",
     "timeEntry_update": "Entrée de temps corrigée",
     "timeEntry_update": "Entrée de temps corrigée",
-    "timeEntry_delete": "Entrée de temps supprimée"
+    "timeEntry_delete": "Entrée de temps supprimée",
+    "settings_workOrderStatus_create": "Statut d’ordre ajouté",
+    "settings_workOrderStatus_update": "Statut d’ordre modifié",
+    "settings_workOrderStatus_archive": "Statut d’ordre retiré"
   },
   },
   "summary": {
   "summary": {
     "customField_create": "Champ personnalisé « {name} » créé",
     "customField_create": "Champ personnalisé « {name} » créé",

+ 7 - 2
messages/fr/service.json

@@ -587,7 +587,8 @@
       "messageSent": "Message envoyé au client",
       "messageSent": "Message envoyé au client",
       "linkShared": "Lien de la facture partagé",
       "linkShared": "Lien de la facture partagé",
       "linkViewed": "{count, plural, one {Le client a ouvert le lien} other {Le client a ouvert le lien # fois}}",
       "linkViewed": "{count, plural, one {Le client a ouvert le lien} other {Le client a ouvert le lien # fois}}",
-      "paymentTaken": "Paiement de {amount} enregistré"
+      "paymentTaken": "Paiement de {amount} enregistré",
+      "statusSet": "Statut passé à {status}"
     },
     },
     "invite": {
     "invite": {
       "title": "La page de l'ordre de travail a été refaite.",
       "title": "La page de l'ordre de travail a été refaite.",
@@ -647,7 +648,8 @@
       "purchasePrice": "Prix d'achat",
       "purchasePrice": "Prix d'achat",
       "overdue": "en retard",
       "overdue": "en retard",
       "customerSince": "Client depuis"
       "customerSince": "Client depuis"
-    }
+    },
+    "moreStatuses": "Autres statuts"
   },
   },
   "photoHandoff": {
   "photoHandoff": {
     "button": "Ajouter depuis le téléphone",
     "button": "Ajouter depuis le téléphone",
@@ -726,5 +728,8 @@
     "paid": "Paiement reçu",
     "paid": "Paiement reçu",
     "paidBody": "Le paiement figure sur l’ordre de réparation.",
     "paidBody": "Le paiement figure sur l’ordre de réparation.",
     "done": "Terminé"
     "done": "Terminé"
+  },
+  "customStatus": {
+    "defaultMessage": "Bonjour {customer_name}, des nouvelles de votre {vehicle} : {status}. {company_name}"
   }
   }
 }
 }

+ 55 - 0
messages/fr/settings.json

@@ -142,6 +142,10 @@
       "emailTemplates": {
       "emailTemplates": {
         "title": "Modèles d'e-mail",
         "title": "Modèles d'e-mail",
         "description": "Ce que disent vos e-mails de facture, devis et rapport"
         "description": "Ce que disent vos e-mails de facture, devis et rapport"
+      },
+      "workOrderStatuses": {
+        "title": "Statuts des ordres",
+        "description": "Vos propres étapes, et qui est prévenu"
       }
       }
     },
     },
     "pro": "PRO",
     "pro": "PRO",
@@ -2490,5 +2494,56 @@
     "dragHandle": "Glisser pour réorganiser",
     "dragHandle": "Glisser pour réorganiser",
     "dragHandleKeyboard": "Glisser, ou utiliser les touches fléchées, pour déplacer ce bloc",
     "dragHandleKeyboard": "Glisser, ou utiliser les touches fléchées, pour déplacer ce bloc",
     "tagDoesNotFit": "La balise ne tient pas dans ce champ."
     "tagDoesNotFit": "La balise ne tient pas dans ce champ."
+  },
+  "workOrderStatuses": {
+    "title": "Statuts des ordres de réparation",
+    "description": "Vos propres noms pour les étapes d’un travail. Chacun appartient à l’une des trois phases et apparaît dans le menu de cette phase sur chaque ordre de réparation.",
+    "cardTitle": "Phases et statuts",
+    "suggestionsLabel": "Ajout rapide",
+    "suggestions": {
+      "readyForPickup": {
+        "name": "Prêt à être récupéré",
+        "message": "Bonjour {customer_name}, votre {vehicle} est prêt à être récupéré. {company_name}"
+      },
+      "waitingForCustomer": {
+        "name": "En attente du client"
+      },
+      "waitingForApproval": {
+        "name": "En attente d’accord",
+        "message": "Bonjour {customer_name}, nous avons besoin de votre accord avant de poursuivre sur votre {vehicle}. Merci de nous contacter. {company_name}"
+      }
+    },
+    "stageHints": {
+      "pending": "Avant le début des travaux. Le travail compte comme ouvert.",
+      "in-progress": "Pendant l’intervention sur le véhicule. Le travail compte comme ouvert.",
+      "completed": "Le travail est terminé. Le client peut voir et payer la facture, et le travail quitte la liste du technicien."
+    },
+    "add": "Ajouter un statut",
+    "builtIn": "Intégré",
+    "asksToNotify": "Propose de prévenir",
+    "notifyHint": "Lorsqu’un travail atteint ce statut, il est proposé d’envoyer un message au client. Rien n’est envoyé sans votre confirmation.",
+    "moveUp": "Monter",
+    "moveDown": "Descendre",
+    "edit": "Modifier",
+    "remove": "Retirer",
+    "empty": "Aucun statut personnel ici pour l’instant.",
+    "removeTitle": "Retirer {name} ?",
+    "removeBody": "Il disparaît des menus. Les travaux qui le portent continuent de l’afficher.",
+    "addTitle": "Nouveau statut",
+    "editTitle": "Modifier le statut",
+    "dialogBody": "Un statut est un nom avec une couleur. La phase à laquelle il appartient décide du traitement du travail.",
+    "name": "Nom",
+    "namePlaceholder": "Prêt à être récupéré",
+    "stage": "Phase",
+    "color": "Couleur",
+    "notify": "Proposer de prévenir le client",
+    "message": "Message",
+    "tokens": "Insérer :",
+    "cancel": "Annuler",
+    "save": "Enregistrer",
+    "saved": "Statut enregistré",
+    "failed": "Impossible d’enregistrer le statut",
+    "messageEmptyHint": "Si le champ reste vide, le texte ci-dessus est envoyé.",
+    "useSuggested": "Modifier ce texte"
   }
   }
 }
 }

+ 4 - 1
messages/it/audit.json

@@ -176,7 +176,10 @@
     "service_videoCall_send": "Link videochiamata inviato",
     "service_videoCall_send": "Link videochiamata inviato",
     "timeEntry_create": "Voce oraria aggiunta",
     "timeEntry_create": "Voce oraria aggiunta",
     "timeEntry_update": "Voce oraria corretta",
     "timeEntry_update": "Voce oraria corretta",
-    "timeEntry_delete": "Voce oraria eliminata"
+    "timeEntry_delete": "Voce oraria eliminata",
+    "settings_workOrderStatus_create": "Stato ordine aggiunto",
+    "settings_workOrderStatus_update": "Stato ordine modificato",
+    "settings_workOrderStatus_archive": "Stato ordine rimosso"
   },
   },
   "summary": {
   "summary": {
     "customField_create": "Campo personalizzato «{name}» creato",
     "customField_create": "Campo personalizzato «{name}» creato",

+ 7 - 2
messages/it/service.json

@@ -587,7 +587,8 @@
       "messageSent": "Messaggio inviato al cliente",
       "messageSent": "Messaggio inviato al cliente",
       "linkShared": "Link della fattura condiviso",
       "linkShared": "Link della fattura condiviso",
       "linkViewed": "{count, plural, one {Il cliente ha aperto il link} other {Il cliente ha aperto il link # volte}}",
       "linkViewed": "{count, plural, one {Il cliente ha aperto il link} other {Il cliente ha aperto il link # volte}}",
-      "paymentTaken": "Pagamento di {amount} registrato"
+      "paymentTaken": "Pagamento di {amount} registrato",
+      "statusSet": "Stato impostato su {status}"
     },
     },
     "invite": {
     "invite": {
       "title": "La pagina dell'ordine di lavoro è stata rinnovata.",
       "title": "La pagina dell'ordine di lavoro è stata rinnovata.",
@@ -647,7 +648,8 @@
       "purchasePrice": "Prezzo di acquisto",
       "purchasePrice": "Prezzo di acquisto",
       "overdue": "scaduta",
       "overdue": "scaduta",
       "customerSince": "Cliente dal"
       "customerSince": "Cliente dal"
-    }
+    },
+    "moreStatuses": "Altri stati"
   },
   },
   "photoHandoff": {
   "photoHandoff": {
     "button": "Aggiungi dal telefono",
     "button": "Aggiungi dal telefono",
@@ -726,5 +728,8 @@
     "paid": "Pagamento ricevuto",
     "paid": "Pagamento ricevuto",
     "paidBody": "Il pagamento è registrato sull’ordine di lavoro.",
     "paidBody": "Il pagamento è registrato sull’ordine di lavoro.",
     "done": "Fatto"
     "done": "Fatto"
+  },
+  "customStatus": {
+    "defaultMessage": "Ciao {customer_name}, un aggiornamento sulla tua {vehicle}: {status}. {company_name}"
   }
   }
 }
 }

+ 55 - 0
messages/it/settings.json

@@ -142,6 +142,10 @@
       "emailTemplates": {
       "emailTemplates": {
         "title": "Modelli email",
         "title": "Modelli email",
         "description": "Cosa dicono le email di fattura, preventivo e rapporto"
         "description": "Cosa dicono le email di fattura, preventivo e rapporto"
+      },
+      "workOrderStatuses": {
+        "title": "Stati degli ordini",
+        "description": "I tuoi passaggi, e chi viene avvisato"
       }
       }
     },
     },
     "pro": "PRO",
     "pro": "PRO",
@@ -2490,5 +2494,56 @@
     "dragHandle": "Trascina per riordinare",
     "dragHandle": "Trascina per riordinare",
     "dragHandleKeyboard": "Trascina, o usa i tasti freccia, per spostare questo blocco",
     "dragHandleKeyboard": "Trascina, o usa i tasti freccia, per spostare questo blocco",
     "tagDoesNotFit": "Il tag non entra in questo campo."
     "tagDoesNotFit": "Il tag non entra in questo campo."
+  },
+  "workOrderStatuses": {
+    "title": "Stati degli ordini di lavoro",
+    "description": "I tuoi nomi per i passaggi di un lavoro. Ognuno appartiene a una delle tre fasi e compare nel menu di quella fase su ogni ordine di lavoro.",
+    "cardTitle": "Fasi e stati",
+    "suggestionsLabel": "Aggiunta rapida",
+    "suggestions": {
+      "readyForPickup": {
+        "name": "Pronto per il ritiro",
+        "message": "Ciao {customer_name}, la tua {vehicle} è pronta per il ritiro. {company_name}"
+      },
+      "waitingForCustomer": {
+        "name": "In attesa del cliente"
+      },
+      "waitingForApproval": {
+        "name": "In attesa di approvazione",
+        "message": "Ciao {customer_name}, ci serve la tua approvazione prima di proseguire sulla tua {vehicle}. Contattaci. {company_name}"
+      }
+    },
+    "stageHints": {
+      "pending": "Prima dell’inizio dei lavori. Il lavoro risulta aperto.",
+      "in-progress": "Mentre si lavora sul veicolo. Il lavoro risulta aperto.",
+      "completed": "Il lavoro è finito. Il cliente può vedere e pagare la fattura e il lavoro esce dalla lista del tecnico."
+    },
+    "add": "Aggiungi stato",
+    "builtIn": "Integrato",
+    "asksToNotify": "Propone l’avviso",
+    "notifyHint": "Quando un lavoro raggiunge questo stato, viene proposto di inviare un messaggio al cliente. Nulla viene inviato senza la tua conferma.",
+    "moveUp": "Sposta su",
+    "moveDown": "Sposta giù",
+    "edit": "Modifica",
+    "remove": "Rimuovi",
+    "empty": "Nessuno stato personale qui per ora.",
+    "removeTitle": "Rimuovere {name}?",
+    "removeBody": "Sparisce dai menu. I lavori che lo hanno continuano a mostrarlo.",
+    "addTitle": "Nuovo stato",
+    "editTitle": "Modifica stato",
+    "dialogBody": "Uno stato è un nome con un colore. La fase a cui appartiene decide come viene trattato il lavoro.",
+    "name": "Nome",
+    "namePlaceholder": "Pronto per il ritiro",
+    "stage": "Fase",
+    "color": "Colore",
+    "notify": "Proponi di avvisare il cliente",
+    "message": "Messaggio",
+    "tokens": "Inserisci:",
+    "cancel": "Annulla",
+    "save": "Salva",
+    "saved": "Stato salvato",
+    "failed": "Impossibile salvare lo stato",
+    "messageEmptyHint": "Se lasciato vuoto, viene inviato il testo qui sopra.",
+    "useSuggested": "Modifica questo testo"
   }
   }
 }
 }

+ 4 - 1
messages/lt/audit.json

@@ -176,7 +176,10 @@
     "service_videoCall_send": "Išsiųsta vaizdo skambučio nuoroda",
     "service_videoCall_send": "Išsiųsta vaizdo skambučio nuoroda",
     "timeEntry_create": "Pridėtas laiko įrašas",
     "timeEntry_create": "Pridėtas laiko įrašas",
     "timeEntry_update": "Pataisytas laiko įrašas",
     "timeEntry_update": "Pataisytas laiko įrašas",
-    "timeEntry_delete": "Ištrintas laiko įrašas"
+    "timeEntry_delete": "Ištrintas laiko įrašas",
+    "settings_workOrderStatus_create": "Pridėta užsakymo būsena",
+    "settings_workOrderStatus_update": "Pakeista užsakymo būsena",
+    "settings_workOrderStatus_archive": "Pašalinta užsakymo būsena"
   },
   },
   "summary": {
   "summary": {
     "customField_create": "Sukurtas pasirinktinis laukas „{name}“",
     "customField_create": "Sukurtas pasirinktinis laukas „{name}“",

+ 7 - 2
messages/lt/service.json

@@ -587,7 +587,8 @@
       "messageSent": "Žinutė išsiųsta klientui",
       "messageSent": "Žinutė išsiųsta klientui",
       "linkShared": "Pasidalyta sąskaitos nuoroda",
       "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ų}}",
       "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"
+      "paymentTaken": "Užregistruotas {amount} mokėjimas",
+      "statusSet": "Būsena pakeista į {status}"
     },
     },
     "invite": {
     "invite": {
       "title": "Darbo užsakymo puslapis atnaujintas.",
       "title": "Darbo užsakymo puslapis atnaujintas.",
@@ -647,7 +648,8 @@
       "purchasePrice": "Pirkimo kaina",
       "purchasePrice": "Pirkimo kaina",
       "overdue": "pavėluota",
       "overdue": "pavėluota",
       "customerSince": "Klientas nuo"
       "customerSince": "Klientas nuo"
-    }
+    },
+    "moreStatuses": "Daugiau būsenų"
   },
   },
   "photoHandoff": {
   "photoHandoff": {
     "button": "Pridėti iš telefono",
     "button": "Pridėti iš telefono",
@@ -726,5 +728,8 @@
     "paid": "Mokėjimas gautas",
     "paid": "Mokėjimas gautas",
     "paidBody": "Mokėjimas įrašytas darbo užsakyme.",
     "paidBody": "Mokėjimas įrašytas darbo užsakyme.",
     "done": "Atlikta"
     "done": "Atlikta"
+  },
+  "customStatus": {
+    "defaultMessage": "Sveiki, {customer_name}, naujiena apie jūsų {vehicle}: {status}. {company_name}"
   }
   }
 }
 }

+ 55 - 0
messages/lt/settings.json

@@ -142,6 +142,10 @@
       "emailTemplates": {
       "emailTemplates": {
         "title": "El. laiškų šablonai",
         "title": "El. laiškų šablonai",
         "description": "Ką rašo sąskaitų, pasiūlymų ir ataskaitų laiškai"
         "description": "Ką rašo sąskaitų, pasiūlymų ir ataskaitų laiškai"
+      },
+      "workOrderStatuses": {
+        "title": "Užsakymų būsenos",
+        "description": "Jūsų žingsniai ir kam pranešama"
       }
       }
     },
     },
     "pro": "PRO",
     "pro": "PRO",
@@ -2490,5 +2494,56 @@
     "dragHandle": "Tempkite, kad pertvarkytumėte",
     "dragHandle": "Tempkite, kad pertvarkytumėte",
     "dragHandleKeyboard": "Tempkite arba naudokite rodyklių klavišus, kad perkeltumėte šį bloką",
     "dragHandleKeyboard": "Tempkite arba naudokite rodyklių klavišus, kad perkeltumėte šį bloką",
     "tagDoesNotFit": "Žymė netelpa į šį laukelį."
     "tagDoesNotFit": "Žymė netelpa į šį laukelį."
+  },
+  "workOrderStatuses": {
+    "title": "Darbo užsakymų būsenos",
+    "description": "Jūsų pačių pavadinimai darbo žingsniams. Kiekviena būsena priklauso vienam iš trijų etapų ir rodoma to etapo meniu kiekviename darbo užsakyme.",
+    "cardTitle": "Etapai ir būsenos",
+    "suggestionsLabel": "Greitas pridėjimas",
+    "suggestions": {
+      "readyForPickup": {
+        "name": "Paruošta atsiimti",
+        "message": "Sveiki, {customer_name}, jūsų {vehicle} paruoštas atsiimti. {company_name}"
+      },
+      "waitingForCustomer": {
+        "name": "Laukiama kliento"
+      },
+      "waitingForApproval": {
+        "name": "Laukiama patvirtinimo",
+        "message": "Sveiki, {customer_name}, prieš tęsdami darbus su jūsų {vehicle} turime gauti jūsų patvirtinimą. Susisiekite su mumis. {company_name}"
+      }
+    },
+    "stageHints": {
+      "pending": "Prieš pradedant darbą. Darbas laikomas atviru.",
+      "in-progress": "Kol dirbama su transporto priemone. Darbas laikomas atviru.",
+      "completed": "Darbas atliktas. Klientas gali matyti ir apmokėti sąskaitą, o darbas dingsta iš techniko sąrašo."
+    },
+    "add": "Pridėti būseną",
+    "builtIn": "Įtaisyta",
+    "asksToNotify": "Siūlo pranešti",
+    "notifyHint": "Kai darbas pasiekia šią būseną, siūloma išsiųsti klientui žinutę. Niekas nesiunčiama, kol nepatvirtinate.",
+    "moveUp": "Aukštyn",
+    "moveDown": "Žemyn",
+    "edit": "Redaguoti",
+    "remove": "Pašalinti",
+    "empty": "Čia dar nėra jūsų būsenų.",
+    "removeTitle": "Pašalinti {name}?",
+    "removeBody": "Ji dingsta iš meniu. Darbai, kurie ją turi, ją rodo toliau.",
+    "addTitle": "Nauja būsena",
+    "editTitle": "Redaguoti būseną",
+    "dialogBody": "Būsena yra pavadinimas su spalva. Etapas, kuriam ji priklauso, lemia, kaip traktuojamas darbas.",
+    "name": "Pavadinimas",
+    "namePlaceholder": "Paruošta atsiimti",
+    "stage": "Etapas",
+    "color": "Spalva",
+    "notify": "Siūlyti pranešti klientui",
+    "message": "Žinutė",
+    "tokens": "Įterpti:",
+    "cancel": "Atšaukti",
+    "save": "Išsaugoti",
+    "saved": "Būsena išsaugota",
+    "failed": "Nepavyko išsaugoti būsenos",
+    "messageEmptyHint": "Palikus tuščią, siunčiamas aukščiau esantis tekstas.",
+    "useSuggested": "Redaguoti šį tekstą"
   }
   }
 }
 }

+ 4 - 1
messages/nb/audit.json

@@ -176,7 +176,10 @@
     "service_videoCall_send": "Sendte lenke til videosamtale",
     "service_videoCall_send": "Sendte lenke til videosamtale",
     "timeEntry_create": "La til tidsoppføring",
     "timeEntry_create": "La til tidsoppføring",
     "timeEntry_update": "Korrigerte tidsoppføring",
     "timeEntry_update": "Korrigerte tidsoppføring",
-    "timeEntry_delete": "Slettet tidsoppføring"
+    "timeEntry_delete": "Slettet tidsoppføring",
+    "settings_workOrderStatus_create": "La til status for arbeidsordre",
+    "settings_workOrderStatus_update": "Endret status for arbeidsordre",
+    "settings_workOrderStatus_archive": "Fjernet status for arbeidsordre"
   },
   },
   "summary": {
   "summary": {
     "customField_create": "Opprettet egendefinert felt «{name}»",
     "customField_create": "Opprettet egendefinert felt «{name}»",

+ 7 - 2
messages/nb/service.json

@@ -587,7 +587,8 @@
       "messageSent": "Melding sendt til kunden",
       "messageSent": "Melding sendt til kunden",
       "linkShared": "Fakturalenke delt",
       "linkShared": "Fakturalenke delt",
       "linkViewed": "{count, plural, one {Kunden åpnet lenken} other {Kunden åpnet lenken # ganger}}",
       "linkViewed": "{count, plural, one {Kunden åpnet lenken} other {Kunden åpnet lenken # ganger}}",
-      "paymentTaken": "Betaling på {amount} registrert"
+      "paymentTaken": "Betaling på {amount} registrert",
+      "statusSet": "Status satt til {status}"
     },
     },
     "invite": {
     "invite": {
       "title": "Arbeidsordresiden er fornyet.",
       "title": "Arbeidsordresiden er fornyet.",
@@ -647,7 +648,8 @@
       "purchasePrice": "Kjøpspris",
       "purchasePrice": "Kjøpspris",
       "overdue": "forfalt",
       "overdue": "forfalt",
       "customerSince": "Kunde siden"
       "customerSince": "Kunde siden"
-    }
+    },
+    "moreStatuses": "Flere statuser"
   },
   },
   "photoHandoff": {
   "photoHandoff": {
     "button": "Legg til fra mobil",
     "button": "Legg til fra mobil",
@@ -726,5 +728,8 @@
     "paid": "Betaling mottatt",
     "paid": "Betaling mottatt",
     "paidBody": "Betalingen er ført på arbeidsordren.",
     "paidBody": "Betalingen er ført på arbeidsordren.",
     "done": "Ferdig"
     "done": "Ferdig"
+  },
+  "customStatus": {
+    "defaultMessage": "Hei {customer_name}, en oppdatering om din {vehicle}: {status}. {company_name}"
   }
   }
 }
 }

+ 55 - 0
messages/nb/settings.json

@@ -142,6 +142,10 @@
       "emailTemplates": {
       "emailTemplates": {
         "title": "E-postmaler",
         "title": "E-postmaler",
         "description": "Hva e-postene for faktura, tilbud og rapport sier"
         "description": "Hva e-postene for faktura, tilbud og rapport sier"
+      },
+      "workOrderStatuses": {
+        "title": "Statuser for arbeidsordre",
+        "description": "Dine egne steg, og hvem som får beskjed"
       }
       }
     },
     },
     "pro": "PRO",
     "pro": "PRO",
@@ -2490,5 +2494,56 @@
     "dragHandle": "Dra for å endre rekkefølge",
     "dragHandle": "Dra for å endre rekkefølge",
     "dragHandleKeyboard": "Dra, eller bruk piltastene, for å flytte denne blokken",
     "dragHandleKeyboard": "Dra, eller bruk piltastene, for å flytte denne blokken",
     "tagDoesNotFit": "Taggen får ikke plass i dette feltet."
     "tagDoesNotFit": "Taggen får ikke plass i dette feltet."
+  },
+  "workOrderStatuses": {
+    "title": "Statuser for arbeidsordre",
+    "description": "Dine egne navn på stegene en jobb går gjennom. Hver status ligger under ett av de tre stadiene og vises i menyen til det stadiet på alle arbeidsordre.",
+    "cardTitle": "Stadier og statuser",
+    "suggestionsLabel": "Hurtigvalg",
+    "suggestions": {
+      "readyForPickup": {
+        "name": "Klar til henting",
+        "message": "Hei {customer_name}, din {vehicle} er klar til henting. {company_name}"
+      },
+      "waitingForCustomer": {
+        "name": "Venter på kunde"
+      },
+      "waitingForApproval": {
+        "name": "Venter på godkjenning",
+        "message": "Hei {customer_name}, vi trenger din godkjenning før vi fortsetter på din {vehicle}. Ta kontakt med oss. {company_name}"
+      }
+    },
+    "stageHints": {
+      "pending": "Før arbeidet starter. Jobben regnes som åpen.",
+      "in-progress": "Mens det arbeides på kjøretøyet. Jobben regnes som åpen.",
+      "completed": "Arbeidet er ferdig. Kunden kan se og betale fakturaen, og jobben forsvinner fra mekanikerens liste."
+    },
+    "add": "Legg til status",
+    "builtIn": "Innebygd",
+    "asksToNotify": "Tilbyr varsling",
+    "notifyHint": "Når en jobb får denne statusen, tilbys det å sende kunden en melding. Ingenting sendes før du bekrefter.",
+    "moveUp": "Flytt opp",
+    "moveDown": "Flytt ned",
+    "edit": "Rediger",
+    "remove": "Fjern",
+    "empty": "Ingen egne statuser her ennå.",
+    "removeTitle": "Fjerne {name}?",
+    "removeBody": "Den forsvinner fra menyene. Jobber som har den, viser den fortsatt.",
+    "addTitle": "Ny status",
+    "editTitle": "Rediger status",
+    "dialogBody": "En status er et navn med en farge. Stadiet den ligger under, avgjør hvordan jobben behandles.",
+    "name": "Navn",
+    "namePlaceholder": "Klar til henting",
+    "stage": "Stadium",
+    "color": "Farge",
+    "notify": "Tilby å varsle kunden",
+    "message": "Melding",
+    "tokens": "Sett inn:",
+    "cancel": "Avbryt",
+    "save": "Lagre",
+    "saved": "Status lagret",
+    "failed": "Kunne ikke lagre statusen",
+    "messageEmptyHint": "Står feltet tomt, sendes teksten over.",
+    "useSuggested": "Rediger denne teksten"
   }
   }
 }
 }

+ 4 - 1
messages/nl/audit.json

@@ -176,7 +176,10 @@
     "service_videoCall_send": "Link videogesprek verstuurd",
     "service_videoCall_send": "Link videogesprek verstuurd",
     "timeEntry_create": "Tijdregistratie toegevoegd",
     "timeEntry_create": "Tijdregistratie toegevoegd",
     "timeEntry_update": "Tijdregistratie gecorrigeerd",
     "timeEntry_update": "Tijdregistratie gecorrigeerd",
-    "timeEntry_delete": "Tijdregistratie verwijderd"
+    "timeEntry_delete": "Tijdregistratie verwijderd",
+    "settings_workOrderStatus_create": "Werkorderstatus toegevoegd",
+    "settings_workOrderStatus_update": "Werkorderstatus gewijzigd",
+    "settings_workOrderStatus_archive": "Werkorderstatus verwijderd"
   },
   },
   "summary": {
   "summary": {
     "customField_create": "Aangepast veld \"{name}\" aangemaakt",
     "customField_create": "Aangepast veld \"{name}\" aangemaakt",

+ 7 - 2
messages/nl/service.json

@@ -587,7 +587,8 @@
       "messageSent": "Bericht naar de klant gestuurd",
       "messageSent": "Bericht naar de klant gestuurd",
       "linkShared": "Factuurlink gedeeld",
       "linkShared": "Factuurlink gedeeld",
       "linkViewed": "{count, plural, one {Klant heeft de link geopend} other {Klant heeft de link # keer geopend}}",
       "linkViewed": "{count, plural, one {Klant heeft de link geopend} other {Klant heeft de link # keer geopend}}",
-      "paymentTaken": "Betaling van {amount} geregistreerd"
+      "paymentTaken": "Betaling van {amount} geregistreerd",
+      "statusSet": "Status gezet op {status}"
     },
     },
     "invite": {
     "invite": {
       "title": "De werkorderpagina is vernieuwd.",
       "title": "De werkorderpagina is vernieuwd.",
@@ -647,7 +648,8 @@
       "purchasePrice": "Aankoopprijs",
       "purchasePrice": "Aankoopprijs",
       "overdue": "verlopen",
       "overdue": "verlopen",
       "customerSince": "Klant sinds"
       "customerSince": "Klant sinds"
-    }
+    },
+    "moreStatuses": "Meer statussen"
   },
   },
   "photoHandoff": {
   "photoHandoff": {
     "button": "Toevoegen via telefoon",
     "button": "Toevoegen via telefoon",
@@ -726,5 +728,8 @@
     "paid": "Betaling ontvangen",
     "paid": "Betaling ontvangen",
     "paidBody": "De betaling staat op de werkorder.",
     "paidBody": "De betaling staat op de werkorder.",
     "done": "Klaar"
     "done": "Klaar"
+  },
+  "customStatus": {
+    "defaultMessage": "Hallo {customer_name}, een update over uw {vehicle}: {status}. {company_name}"
   }
   }
 }
 }

+ 55 - 0
messages/nl/settings.json

@@ -142,6 +142,10 @@
       "emailTemplates": {
       "emailTemplates": {
         "title": "E-mailsjablonen",
         "title": "E-mailsjablonen",
         "description": "Wat je e-mails voor factuur, offerte en rapport zeggen"
         "description": "Wat je e-mails voor factuur, offerte en rapport zeggen"
+      },
+      "workOrderStatuses": {
+        "title": "Werkorderstatussen",
+        "description": "Uw eigen stappen, en wie bericht krijgt"
       }
       }
     },
     },
     "pro": "PRO",
     "pro": "PRO",
@@ -2490,5 +2494,56 @@
     "dragHandle": "Sleep om te herschikken",
     "dragHandle": "Sleep om te herschikken",
     "dragHandleKeyboard": "Sleep, of gebruik de pijltjestoetsen, om dit blok te verplaatsen",
     "dragHandleKeyboard": "Sleep, of gebruik de pijltjestoetsen, om dit blok te verplaatsen",
     "tagDoesNotFit": "De tag past niet in dit veld."
     "tagDoesNotFit": "De tag past niet in dit veld."
+  },
+  "workOrderStatuses": {
+    "title": "Werkorderstatussen",
+    "description": "Uw eigen namen voor de stappen van een opdracht. Elke status hoort bij een van de drie fasen en staat in het menu van die fase op elke werkorder.",
+    "cardTitle": "Fasen en statussen",
+    "suggestionsLabel": "Snel toevoegen",
+    "suggestions": {
+      "readyForPickup": {
+        "name": "Klaar om op te halen",
+        "message": "Hallo {customer_name}, uw {vehicle} staat klaar om opgehaald te worden. {company_name}"
+      },
+      "waitingForCustomer": {
+        "name": "Wacht op klant"
+      },
+      "waitingForApproval": {
+        "name": "Wacht op akkoord",
+        "message": "Hallo {customer_name}, we hebben uw akkoord nodig voordat we verdergaan met uw {vehicle}. Neem contact met ons op. {company_name}"
+      }
+    },
+    "stageHints": {
+      "pending": "Voordat het werk begint. De opdracht telt als open.",
+      "in-progress": "Terwijl er aan het voertuig wordt gewerkt. De opdracht telt als open.",
+      "completed": "Het werk is klaar. De klant kan de factuur zien en betalen, en de opdracht verdwijnt uit de lijst van de monteur."
+    },
+    "add": "Status toevoegen",
+    "builtIn": "Ingebouwd",
+    "asksToNotify": "Biedt melding aan",
+    "notifyHint": "Als een opdracht deze status krijgt, wordt aangeboden de klant een bericht te sturen. Er wordt niets verstuurd zonder uw bevestiging.",
+    "moveUp": "Omhoog",
+    "moveDown": "Omlaag",
+    "edit": "Bewerken",
+    "remove": "Verwijderen",
+    "empty": "Hier staan nog geen eigen statussen.",
+    "removeTitle": "{name} verwijderen?",
+    "removeBody": "De status verdwijnt uit de menu’s. Opdrachten die hem hebben, blijven hem tonen.",
+    "addTitle": "Nieuwe status",
+    "editTitle": "Status bewerken",
+    "dialogBody": "Een status is een naam met een kleur. De fase waaronder hij valt, bepaalt hoe de opdracht wordt behandeld.",
+    "name": "Naam",
+    "namePlaceholder": "Klaar om op te halen",
+    "stage": "Fase",
+    "color": "Kleur",
+    "notify": "Aanbieden de klant te informeren",
+    "message": "Bericht",
+    "tokens": "Invoegen:",
+    "cancel": "Annuleren",
+    "save": "Opslaan",
+    "saved": "Status opgeslagen",
+    "failed": "De status kon niet worden opgeslagen",
+    "messageEmptyHint": "Blijft het veld leeg, dan wordt de tekst hierboven verstuurd.",
+    "useSuggested": "Deze tekst bewerken"
   }
   }
 }
 }

+ 4 - 1
messages/pl/audit.json

@@ -176,7 +176,10 @@
     "service_videoCall_send": "Wysłano link do rozmowy wideo",
     "service_videoCall_send": "Wysłano link do rozmowy wideo",
     "timeEntry_create": "Dodano wpis czasu",
     "timeEntry_create": "Dodano wpis czasu",
     "timeEntry_update": "Poprawiono wpis czasu",
     "timeEntry_update": "Poprawiono wpis czasu",
-    "timeEntry_delete": "Usunięto wpis czasu"
+    "timeEntry_delete": "Usunięto wpis czasu",
+    "settings_workOrderStatus_create": "Dodano status zlecenia",
+    "settings_workOrderStatus_update": "Zmieniono status zlecenia",
+    "settings_workOrderStatus_archive": "Usunięto status zlecenia"
   },
   },
   "summary": {
   "summary": {
     "customField_create": "Utworzono pole niestandardowe „{name}”",
     "customField_create": "Utworzono pole niestandardowe „{name}”",

+ 7 - 2
messages/pl/service.json

@@ -587,7 +587,8 @@
       "messageSent": "Wysłano wiadomość do klienta",
       "messageSent": "Wysłano wiadomość do klienta",
       "linkShared": "Udostępniono link do faktury",
       "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}}",
       "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}"
+      "paymentTaken": "Zarejestrowano płatność {amount}",
+      "statusSet": "Status ustawiony na {status}"
     },
     },
     "invite": {
     "invite": {
       "title": "Strona zlecenia została przebudowana.",
       "title": "Strona zlecenia została przebudowana.",
@@ -647,7 +648,8 @@
       "purchasePrice": "Cena zakupu",
       "purchasePrice": "Cena zakupu",
       "overdue": "po terminie",
       "overdue": "po terminie",
       "customerSince": "Klient od"
       "customerSince": "Klient od"
-    }
+    },
+    "moreStatuses": "Więcej statusów"
   },
   },
   "photoHandoff": {
   "photoHandoff": {
     "button": "Dodaj z telefonu",
     "button": "Dodaj z telefonu",
@@ -726,5 +728,8 @@
     "paid": "Płatność otrzymana",
     "paid": "Płatność otrzymana",
     "paidBody": "Płatność jest na zleceniu.",
     "paidBody": "Płatność jest na zleceniu.",
     "done": "Gotowe"
     "done": "Gotowe"
+  },
+  "customStatus": {
+    "defaultMessage": "Dzień dobry {customer_name}, aktualizacja dotycząca {vehicle}: {status}. {company_name}"
   }
   }
 }
 }

+ 55 - 0
messages/pl/settings.json

@@ -142,6 +142,10 @@
       "emailTemplates": {
       "emailTemplates": {
         "title": "Szablony e-mail",
         "title": "Szablony e-mail",
         "description": "Co mówią e-maile z fakturą, ofertą i raportem"
         "description": "Co mówią e-maile z fakturą, ofertą i raportem"
+      },
+      "workOrderStatuses": {
+        "title": "Statusy zleceń",
+        "description": "Własne etapy i kto dostaje wiadomość"
       }
       }
     },
     },
     "pro": "PRO",
     "pro": "PRO",
@@ -2490,5 +2494,56 @@
     "dragHandle": "Przeciągnij, aby zmienić kolejność",
     "dragHandle": "Przeciągnij, aby zmienić kolejność",
     "dragHandleKeyboard": "Przeciągnij lub użyj klawiszy strzałek, aby przenieść ten blok",
     "dragHandleKeyboard": "Przeciągnij lub użyj klawiszy strzałek, aby przenieść ten blok",
     "tagDoesNotFit": "Tag nie mieści się w tym polu."
     "tagDoesNotFit": "Tag nie mieści się w tym polu."
+  },
+  "workOrderStatuses": {
+    "title": "Statusy zleceń",
+    "description": "Własne nazwy kroków, przez które przechodzi zlecenie. Każdy należy do jednego z trzech etapów i pojawia się w menu tego etapu na każdym zleceniu.",
+    "cardTitle": "Etapy i statusy",
+    "suggestionsLabel": "Szybkie dodawanie",
+    "suggestions": {
+      "readyForPickup": {
+        "name": "Gotowe do odbioru",
+        "message": "Dzień dobry {customer_name}, Państwa {vehicle} jest gotowy do odbioru. {company_name}"
+      },
+      "waitingForCustomer": {
+        "name": "Oczekiwanie na klienta"
+      },
+      "waitingForApproval": {
+        "name": "Oczekiwanie na akceptację",
+        "message": "Dzień dobry {customer_name}, potrzebujemy Państwa akceptacji, zanim będziemy kontynuować prace przy {vehicle}. Prosimy o kontakt. {company_name}"
+      }
+    },
+    "stageHints": {
+      "pending": "Przed rozpoczęciem pracy. Zlecenie liczy się jako otwarte.",
+      "in-progress": "Gdy trwają prace przy pojeździe. Zlecenie liczy się jako otwarte.",
+      "completed": "Praca jest wykonana. Klient może zobaczyć i opłacić fakturę, a zlecenie znika z listy mechanika."
+    },
+    "add": "Dodaj status",
+    "builtIn": "Wbudowany",
+    "asksToNotify": "Proponuje powiadomienie",
+    "notifyHint": "Gdy zlecenie osiągnie ten status, pojawi się propozycja wysłania wiadomości do klienta. Nic nie zostanie wysłane bez potwierdzenia.",
+    "moveUp": "W górę",
+    "moveDown": "W dół",
+    "edit": "Edytuj",
+    "remove": "Usuń",
+    "empty": "Nie ma tu jeszcze własnych statusów.",
+    "removeTitle": "Usunąć {name}?",
+    "removeBody": "Znika z menu. Zlecenia, które go mają, nadal go pokazują.",
+    "addTitle": "Nowy status",
+    "editTitle": "Edytuj status",
+    "dialogBody": "Status to nazwa z kolorem. Etap, do którego należy, decyduje o tym, jak traktowane jest zlecenie.",
+    "name": "Nazwa",
+    "namePlaceholder": "Gotowe do odbioru",
+    "stage": "Etap",
+    "color": "Kolor",
+    "notify": "Proponuj powiadomienie klienta",
+    "message": "Wiadomość",
+    "tokens": "Wstaw:",
+    "cancel": "Anuluj",
+    "save": "Zapisz",
+    "saved": "Status zapisany",
+    "failed": "Nie udało się zapisać statusu",
+    "messageEmptyHint": "Jeśli pole pozostanie puste, zostanie wysłany tekst powyżej.",
+    "useSuggested": "Edytuj ten tekst"
   }
   }
 }
 }

+ 4 - 1
messages/pt-BR/audit.json

@@ -176,7 +176,10 @@
     "service_videoCall_send": "Link da videochamada enviado",
     "service_videoCall_send": "Link da videochamada enviado",
     "timeEntry_create": "Registro de tempo adicionado",
     "timeEntry_create": "Registro de tempo adicionado",
     "timeEntry_update": "Registro de tempo corrigido",
     "timeEntry_update": "Registro de tempo corrigido",
-    "timeEntry_delete": "Registro de tempo excluído"
+    "timeEntry_delete": "Registro de tempo excluído",
+    "settings_workOrderStatus_create": "Status de ordem adicionado",
+    "settings_workOrderStatus_update": "Status de ordem alterado",
+    "settings_workOrderStatus_archive": "Status de ordem removido"
   },
   },
   "summary": {
   "summary": {
     "customField_create": "Campo personalizado \"{name}\" criado",
     "customField_create": "Campo personalizado \"{name}\" criado",

+ 7 - 2
messages/pt-BR/service.json

@@ -587,7 +587,8 @@
       "messageSent": "Mensagem enviada ao cliente",
       "messageSent": "Mensagem enviada ao cliente",
       "linkShared": "Link da fatura compartilhado",
       "linkShared": "Link da fatura compartilhado",
       "linkViewed": "{count, plural, one {O cliente abriu o link} other {O cliente abriu o link # vezes}}",
       "linkViewed": "{count, plural, one {O cliente abriu o link} other {O cliente abriu o link # vezes}}",
-      "paymentTaken": "Pagamento de {amount} registrado"
+      "paymentTaken": "Pagamento de {amount} registrado",
+      "statusSet": "Status alterado para {status}"
     },
     },
     "invite": {
     "invite": {
       "title": "A página da ordem de serviço foi reformulada.",
       "title": "A página da ordem de serviço foi reformulada.",
@@ -647,7 +648,8 @@
       "purchasePrice": "Preço de compra",
       "purchasePrice": "Preço de compra",
       "overdue": "vencida",
       "overdue": "vencida",
       "customerSince": "Cliente desde"
       "customerSince": "Cliente desde"
-    }
+    },
+    "moreStatuses": "Mais status"
   },
   },
   "photoHandoff": {
   "photoHandoff": {
     "button": "Adicionar pelo celular",
     "button": "Adicionar pelo celular",
@@ -726,5 +728,8 @@
     "paid": "Pagamento recebido",
     "paid": "Pagamento recebido",
     "paidBody": "O pagamento está na ordem de serviço.",
     "paidBody": "O pagamento está na ordem de serviço.",
     "done": "Concluir"
     "done": "Concluir"
+  },
+  "customStatus": {
+    "defaultMessage": "Olá {customer_name}, uma atualização sobre o seu {vehicle}: {status}. {company_name}"
   }
   }
 }
 }

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

@@ -142,6 +142,10 @@
       "emailTemplates": {
       "emailTemplates": {
         "title": "Modelos de e-mail",
         "title": "Modelos de e-mail",
         "description": "O que dizem seus e-mails de fatura, orçamento e relatório"
         "description": "O que dizem seus e-mails de fatura, orçamento e relatório"
+      },
+      "workOrderStatuses": {
+        "title": "Status das ordens",
+        "description": "Suas próprias etapas, e quem é avisado"
       }
       }
     },
     },
     "pro": "PRO",
     "pro": "PRO",
@@ -2490,5 +2494,56 @@
     "dragHandle": "Arraste para reordenar",
     "dragHandle": "Arraste para reordenar",
     "dragHandleKeyboard": "Arraste, ou use as teclas de seta, para mover este bloco",
     "dragHandleKeyboard": "Arraste, ou use as teclas de seta, para mover este bloco",
     "tagDoesNotFit": "A tag não cabe neste campo."
     "tagDoesNotFit": "A tag não cabe neste campo."
+  },
+  "workOrderStatuses": {
+    "title": "Status das ordens de serviço",
+    "description": "Seus próprios nomes para as etapas de um serviço. Cada um pertence a uma das três fases e aparece no menu dessa fase em toda ordem de serviço.",
+    "cardTitle": "Fases e status",
+    "suggestionsLabel": "Adição rápida",
+    "suggestions": {
+      "readyForPickup": {
+        "name": "Pronto para retirada",
+        "message": "Olá {customer_name}, seu {vehicle} está pronto para retirada. {company_name}"
+      },
+      "waitingForCustomer": {
+        "name": "Aguardando o cliente"
+      },
+      "waitingForApproval": {
+        "name": "Aguardando aprovação",
+        "message": "Olá {customer_name}, precisamos da sua aprovação antes de continuar o serviço no seu {vehicle}. Entre em contato. {company_name}"
+      }
+    },
+    "stageHints": {
+      "pending": "Antes de o serviço começar. O serviço conta como aberto.",
+      "in-progress": "Enquanto o veículo está em serviço. O serviço conta como aberto.",
+      "completed": "O serviço está concluído. O cliente pode ver e pagar a fatura, e o serviço sai da lista do técnico."
+    },
+    "add": "Adicionar status",
+    "builtIn": "Integrado",
+    "asksToNotify": "Oferece avisar",
+    "notifyHint": "Quando um serviço chega a este status, é oferecido enviar uma mensagem ao cliente. Nada é enviado sem a sua confirmação.",
+    "moveUp": "Mover para cima",
+    "moveDown": "Mover para baixo",
+    "edit": "Editar",
+    "remove": "Remover",
+    "empty": "Ainda não há status próprios aqui.",
+    "removeTitle": "Remover {name}?",
+    "removeBody": "Ele sai dos menus. Os serviços que o têm continuam a mostrá-lo.",
+    "addTitle": "Novo status",
+    "editTitle": "Editar status",
+    "dialogBody": "Um status é um nome com uma cor. A fase a que pertence decide como o serviço é tratado.",
+    "name": "Nome",
+    "namePlaceholder": "Pronto para retirada",
+    "stage": "Fase",
+    "color": "Cor",
+    "notify": "Oferecer avisar o cliente",
+    "message": "Mensagem",
+    "tokens": "Inserir:",
+    "cancel": "Cancelar",
+    "save": "Salvar",
+    "saved": "Status salvo",
+    "failed": "Não foi possível salvar o status",
+    "messageEmptyHint": "Se ficar vazio, o texto acima é enviado.",
+    "useSuggested": "Editar este texto"
   }
   }
 }
 }

+ 4 - 1
messages/pt-PT/audit.json

@@ -176,7 +176,10 @@
     "service_videoCall_send": "Link da videochamada enviado",
     "service_videoCall_send": "Link da videochamada enviado",
     "timeEntry_create": "Registo de tempo adicionado",
     "timeEntry_create": "Registo de tempo adicionado",
     "timeEntry_update": "Registo de tempo corrigido",
     "timeEntry_update": "Registo de tempo corrigido",
-    "timeEntry_delete": "Registo de tempo eliminado"
+    "timeEntry_delete": "Registo de tempo eliminado",
+    "settings_workOrderStatus_create": "Estado de ordem adicionado",
+    "settings_workOrderStatus_update": "Estado de ordem alterado",
+    "settings_workOrderStatus_archive": "Estado de ordem removido"
   },
   },
   "summary": {
   "summary": {
     "customField_create": "Campo personalizado \"{name}\" criado",
     "customField_create": "Campo personalizado \"{name}\" criado",

+ 7 - 2
messages/pt-PT/service.json

@@ -587,7 +587,8 @@
       "messageSent": "Mensagem enviada ao cliente",
       "messageSent": "Mensagem enviada ao cliente",
       "linkShared": "Link da fatura partilhado",
       "linkShared": "Link da fatura partilhado",
       "linkViewed": "{count, plural, one {O cliente abriu o link} other {O cliente abriu o link # vezes}}",
       "linkViewed": "{count, plural, one {O cliente abriu o link} other {O cliente abriu o link # vezes}}",
-      "paymentTaken": "Pagamento de {amount} registado"
+      "paymentTaken": "Pagamento de {amount} registado",
+      "statusSet": "Estado alterado para {status}"
     },
     },
     "invite": {
     "invite": {
       "title": "A página da ordem de serviço foi renovada.",
       "title": "A página da ordem de serviço foi renovada.",
@@ -647,7 +648,8 @@
       "purchasePrice": "Preço de compra",
       "purchasePrice": "Preço de compra",
       "overdue": "em atraso",
       "overdue": "em atraso",
       "customerSince": "Cliente desde"
       "customerSince": "Cliente desde"
-    }
+    },
+    "moreStatuses": "Mais estados"
   },
   },
   "photoHandoff": {
   "photoHandoff": {
     "button": "Adicionar pelo telemóvel",
     "button": "Adicionar pelo telemóvel",
@@ -726,5 +728,8 @@
     "paid": "Pagamento recebido",
     "paid": "Pagamento recebido",
     "paidBody": "O pagamento está na ordem de reparação.",
     "paidBody": "O pagamento está na ordem de reparação.",
     "done": "Concluir"
     "done": "Concluir"
+  },
+  "customStatus": {
+    "defaultMessage": "Olá {customer_name}, uma atualização sobre a sua {vehicle}: {status}. {company_name}"
   }
   }
 }
 }

+ 55 - 0
messages/pt-PT/settings.json

@@ -142,6 +142,10 @@
       "emailTemplates": {
       "emailTemplates": {
         "title": "Modelos de e-mail",
         "title": "Modelos de e-mail",
         "description": "O que dizem os seus e-mails de fatura, orçamento e relatório"
         "description": "O que dizem os seus e-mails de fatura, orçamento e relatório"
+      },
+      "workOrderStatuses": {
+        "title": "Estados das ordens",
+        "description": "Os seus próprios passos, e quem é avisado"
       }
       }
     },
     },
     "pro": "PRO",
     "pro": "PRO",
@@ -2490,5 +2494,56 @@
     "dragHandle": "Arraste para reordenar",
     "dragHandle": "Arraste para reordenar",
     "dragHandleKeyboard": "Arraste, ou use as teclas de seta, para mover este bloco",
     "dragHandleKeyboard": "Arraste, ou use as teclas de seta, para mover este bloco",
     "tagDoesNotFit": "A etiqueta não cabe neste campo."
     "tagDoesNotFit": "A etiqueta não cabe neste campo."
+  },
+  "workOrderStatuses": {
+    "title": "Estados das ordens de reparação",
+    "description": "Os seus próprios nomes para os passos de um trabalho. Cada um pertence a uma das três fases e aparece no menu dessa fase em todas as ordens de reparação.",
+    "cardTitle": "Fases e estados",
+    "suggestionsLabel": "Adição rápida",
+    "suggestions": {
+      "readyForPickup": {
+        "name": "Pronta para levantamento",
+        "message": "Olá {customer_name}, a sua {vehicle} está pronta para levantamento. {company_name}"
+      },
+      "waitingForCustomer": {
+        "name": "A aguardar o cliente"
+      },
+      "waitingForApproval": {
+        "name": "A aguardar aprovação",
+        "message": "Olá {customer_name}, precisamos da sua aprovação antes de continuar o trabalho na sua {vehicle}. Contacte-nos. {company_name}"
+      }
+    },
+    "stageHints": {
+      "pending": "Antes de o trabalho começar. O trabalho conta como aberto.",
+      "in-progress": "Enquanto se trabalha na viatura. O trabalho conta como aberto.",
+      "completed": "O trabalho está concluído. O cliente pode ver e pagar a fatura, e o trabalho sai da lista do técnico."
+    },
+    "add": "Adicionar estado",
+    "builtIn": "Integrado",
+    "asksToNotify": "Propõe avisar",
+    "notifyHint": "Quando um trabalho chega a este estado, é proposto enviar uma mensagem ao cliente. Nada é enviado sem a sua confirmação.",
+    "moveUp": "Mover para cima",
+    "moveDown": "Mover para baixo",
+    "edit": "Editar",
+    "remove": "Remover",
+    "empty": "Ainda não há estados próprios aqui.",
+    "removeTitle": "Remover {name}?",
+    "removeBody": "Sai dos menus. Os trabalhos que o têm continuam a mostrá-lo.",
+    "addTitle": "Novo estado",
+    "editTitle": "Editar estado",
+    "dialogBody": "Um estado é um nome com uma cor. A fase a que pertence decide como o trabalho é tratado.",
+    "name": "Nome",
+    "namePlaceholder": "Pronta para levantamento",
+    "stage": "Fase",
+    "color": "Cor",
+    "notify": "Propor avisar o cliente",
+    "message": "Mensagem",
+    "tokens": "Inserir:",
+    "cancel": "Cancelar",
+    "save": "Guardar",
+    "saved": "Estado guardado",
+    "failed": "Não foi possível guardar o estado",
+    "messageEmptyHint": "Se ficar vazio, é enviado o texto acima.",
+    "useSuggested": "Editar este texto"
   }
   }
 }
 }

+ 4 - 1
messages/ru/audit.json

@@ -176,7 +176,10 @@
     "service_videoCall_send": "Отправлена ссылка на видеозвонок",
     "service_videoCall_send": "Отправлена ссылка на видеозвонок",
     "timeEntry_create": "Добавлена запись времени",
     "timeEntry_create": "Добавлена запись времени",
     "timeEntry_update": "Исправлена запись времени",
     "timeEntry_update": "Исправлена запись времени",
-    "timeEntry_delete": "Удалена запись времени"
+    "timeEntry_delete": "Удалена запись времени",
+    "settings_workOrderStatus_create": "Добавлен статус заказ-наряда",
+    "settings_workOrderStatus_update": "Изменён статус заказ-наряда",
+    "settings_workOrderStatus_archive": "Удалён статус заказ-наряда"
   },
   },
   "summary": {
   "summary": {
     "customField_create": "Создано настраиваемое поле «{name}»",
     "customField_create": "Создано настраиваемое поле «{name}»",

+ 7 - 2
messages/ru/service.json

@@ -587,7 +587,8 @@
       "messageSent": "Клиенту отправлено сообщение",
       "messageSent": "Клиенту отправлено сообщение",
       "linkShared": "Отправлена ссылка на счёт",
       "linkShared": "Отправлена ссылка на счёт",
       "linkViewed": "{count, plural, =1 {Клиент открыл ссылку} one {Клиент открыл ссылку # раз} few {Клиент открыл ссылку # раза} many {Клиент открыл ссылку # раз} other {Клиент открыл ссылку # раза}}",
       "linkViewed": "{count, plural, =1 {Клиент открыл ссылку} one {Клиент открыл ссылку # раз} few {Клиент открыл ссылку # раза} many {Клиент открыл ссылку # раз} other {Клиент открыл ссылку # раза}}",
-      "paymentTaken": "Записан платёж {amount}"
+      "paymentTaken": "Записан платёж {amount}",
+      "statusSet": "Статус изменён на {status}"
     },
     },
     "invite": {
     "invite": {
       "title": "Страница заказ-наряда обновлена.",
       "title": "Страница заказ-наряда обновлена.",
@@ -647,7 +648,8 @@
       "purchasePrice": "Цена покупки",
       "purchasePrice": "Цена покупки",
       "overdue": "просрочен",
       "overdue": "просрочен",
       "customerSince": "Клиент с"
       "customerSince": "Клиент с"
-    }
+    },
+    "moreStatuses": "Другие статусы"
   },
   },
   "photoHandoff": {
   "photoHandoff": {
     "button": "Добавить с телефона",
     "button": "Добавить с телефона",
@@ -726,5 +728,8 @@
     "paid": "Оплата получена",
     "paid": "Оплата получена",
     "paidBody": "Оплата отражена в заказ-наряде.",
     "paidBody": "Оплата отражена в заказ-наряде.",
     "done": "Готово"
     "done": "Готово"
+  },
+  "customStatus": {
+    "defaultMessage": "Здравствуйте, {customer_name}! Новости о вашем {vehicle}: {status}. {company_name}"
   }
   }
 }
 }

+ 55 - 0
messages/ru/settings.json

@@ -142,6 +142,10 @@
       "emailTemplates": {
       "emailTemplates": {
         "title": "Шаблоны писем",
         "title": "Шаблоны писем",
         "description": "Что говорят письма о счёте, предложении и отчёте"
         "description": "Что говорят письма о счёте, предложении и отчёте"
+      },
+      "workOrderStatuses": {
+        "title": "Статусы заказ-нарядов",
+        "description": "Ваши этапы и кого уведомлять"
       }
       }
     },
     },
     "pro": "PRO",
     "pro": "PRO",
@@ -2490,5 +2494,56 @@
     "dragHandle": "Перетащите, чтобы изменить порядок",
     "dragHandle": "Перетащите, чтобы изменить порядок",
     "dragHandleKeyboard": "Перетащите этот блок или переместите его клавишами со стрелками",
     "dragHandleKeyboard": "Перетащите этот блок или переместите его клавишами со стрелками",
     "tagDoesNotFit": "Тег не помещается в это поле."
     "tagDoesNotFit": "Тег не помещается в это поле."
+  },
+  "workOrderStatuses": {
+    "title": "Статусы заказ-нарядов",
+    "description": "Ваши собственные названия шагов, через которые проходит работа. Каждый относится к одной из трёх стадий и появляется в меню этой стадии в каждом заказ-наряде.",
+    "cardTitle": "Стадии и статусы",
+    "suggestionsLabel": "Быстрое добавление",
+    "suggestions": {
+      "readyForPickup": {
+        "name": "Готов к выдаче",
+        "message": "Здравствуйте, {customer_name}! Ваш {vehicle} готов к выдаче. {company_name}"
+      },
+      "waitingForCustomer": {
+        "name": "Ожидание клиента"
+      },
+      "waitingForApproval": {
+        "name": "Ожидание согласования",
+        "message": "Здравствуйте, {customer_name}! Нам нужно ваше согласие, прежде чем продолжить работы с {vehicle}. Свяжитесь с нами. {company_name}"
+      }
+    },
+    "stageHints": {
+      "pending": "До начала работ. Заказ считается открытым.",
+      "in-progress": "Пока идут работы с автомобилем. Заказ считается открытым.",
+      "completed": "Работа выполнена. Клиент может увидеть и оплатить счёт, а заказ исчезает из списка механика."
+    },
+    "add": "Добавить статус",
+    "builtIn": "Встроенный",
+    "asksToNotify": "Предлагает уведомить",
+    "notifyHint": "Когда заказ получает этот статус, предлагается отправить клиенту сообщение. Ничего не отправляется без вашего подтверждения.",
+    "moveUp": "Выше",
+    "moveDown": "Ниже",
+    "edit": "Изменить",
+    "remove": "Удалить",
+    "empty": "Здесь пока нет ваших статусов.",
+    "removeTitle": "Удалить {name}?",
+    "removeBody": "Он исчезнет из меню. Заказы, у которых он есть, продолжат его показывать.",
+    "addTitle": "Новый статус",
+    "editTitle": "Изменить статус",
+    "dialogBody": "Статус — это название с цветом. Стадия, к которой он относится, определяет, как обрабатывается заказ.",
+    "name": "Название",
+    "namePlaceholder": "Готов к выдаче",
+    "stage": "Стадия",
+    "color": "Цвет",
+    "notify": "Предлагать уведомить клиента",
+    "message": "Сообщение",
+    "tokens": "Вставить:",
+    "cancel": "Отмена",
+    "save": "Сохранить",
+    "saved": "Статус сохранён",
+    "failed": "Не удалось сохранить статус",
+    "messageEmptyHint": "Если оставить поле пустым, будет отправлен текст выше.",
+    "useSuggested": "Изменить этот текст"
   }
   }
 }
 }

+ 4 - 1
messages/tr/audit.json

@@ -176,7 +176,10 @@
     "service_videoCall_send": "Görüntülü görüşme bağlantısı gönderildi",
     "service_videoCall_send": "Görüntülü görüşme bağlantısı gönderildi",
     "timeEntry_create": "Zaman kaydı eklendi",
     "timeEntry_create": "Zaman kaydı eklendi",
     "timeEntry_update": "Zaman kaydı düzeltildi",
     "timeEntry_update": "Zaman kaydı düzeltildi",
-    "timeEntry_delete": "Zaman kaydı silindi"
+    "timeEntry_delete": "Zaman kaydı silindi",
+    "settings_workOrderStatus_create": "İş emri durumu eklendi",
+    "settings_workOrderStatus_update": "İş emri durumu değiştirildi",
+    "settings_workOrderStatus_archive": "İş emri durumu kaldırıldı"
   },
   },
   "summary": {
   "summary": {
     "customField_create": "\"{name}\" özel alanı oluşturuldu",
     "customField_create": "\"{name}\" özel alanı oluşturuldu",

+ 7 - 2
messages/tr/service.json

@@ -587,7 +587,8 @@
       "messageSent": "Müşteriye mesaj gönderildi",
       "messageSent": "Müşteriye mesaj gönderildi",
       "linkShared": "Fatura bağlantısı paylaşıldı",
       "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ı}}",
       "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"
+      "paymentTaken": "{amount} tutarında ödeme kaydedildi",
+      "statusSet": "Durum {status} olarak ayarlandı"
     },
     },
     "invite": {
     "invite": {
       "title": "İş emri sayfası yenilendi.",
       "title": "İş emri sayfası yenilendi.",
@@ -647,7 +648,8 @@
       "purchasePrice": "Satın alma fiyatı",
       "purchasePrice": "Satın alma fiyatı",
       "overdue": "gecikmiş",
       "overdue": "gecikmiş",
       "customerSince": "Müşteri olma tarihi"
       "customerSince": "Müşteri olma tarihi"
-    }
+    },
+    "moreStatuses": "Diğer durumlar"
   },
   },
   "photoHandoff": {
   "photoHandoff": {
     "button": "Telefondan ekle",
     "button": "Telefondan ekle",
@@ -726,5 +728,8 @@
     "paid": "Ödeme alındı",
     "paid": "Ödeme alındı",
     "paidBody": "Ödeme iş emrine işlendi.",
     "paidBody": "Ödeme iş emrine işlendi.",
     "done": "Tamam"
     "done": "Tamam"
+  },
+  "customStatus": {
+    "defaultMessage": "Merhaba {customer_name}, {vehicle} aracınızla ilgili bir güncelleme: {status}. {company_name}"
   }
   }
 }
 }

+ 55 - 0
messages/tr/settings.json

@@ -142,6 +142,10 @@
       "emailTemplates": {
       "emailTemplates": {
         "title": "E-posta şablonları",
         "title": "E-posta şablonları",
         "description": "Fatura, teklif ve rapor e-postalarınızın metni"
         "description": "Fatura, teklif ve rapor e-postalarınızın metni"
+      },
+      "workOrderStatuses": {
+        "title": "İş emri durumları",
+        "description": "Kendi adımlarınız ve kime haber verileceği"
       }
       }
     },
     },
     "pro": "PRO",
     "pro": "PRO",
@@ -2490,5 +2494,56 @@
     "dragHandle": "Yeniden sıralamak için sürükleyin",
     "dragHandle": "Yeniden sıralamak için sürükleyin",
     "dragHandleKeyboard": "Bu bloğu taşımak için sürükleyin ya da ok tuşlarını kullanın",
     "dragHandleKeyboard": "Bu bloğu taşımak için sürükleyin ya da ok tuşlarını kullanın",
     "tagDoesNotFit": "Etiket bu alana sığmıyor."
     "tagDoesNotFit": "Etiket bu alana sığmıyor."
+  },
+  "workOrderStatuses": {
+    "title": "İş emri durumları",
+    "description": "Bir işin geçtiği adımlar için kendi adlarınız. Her durum üç aşamadan birine bağlıdır ve her iş emrinde o aşamanın menüsünde görünür.",
+    "cardTitle": "Aşamalar ve durumlar",
+    "suggestionsLabel": "Hızlı ekle",
+    "suggestions": {
+      "readyForPickup": {
+        "name": "Teslime hazır",
+        "message": "Merhaba {customer_name}, {vehicle} aracınız teslime hazır. {company_name}"
+      },
+      "waitingForCustomer": {
+        "name": "Müşteri bekleniyor"
+      },
+      "waitingForApproval": {
+        "name": "Onay bekleniyor",
+        "message": "Merhaba {customer_name}, {vehicle} aracınızda devam etmeden önce onayınıza ihtiyacımız var. Lütfen bize ulaşın. {company_name}"
+      }
+    },
+    "stageHints": {
+      "pending": "İş başlamadan önce. İş açık sayılır.",
+      "in-progress": "Araç üzerinde çalışılırken. İş açık sayılır.",
+      "completed": "İş tamamlandı. Müşteri faturayı görüp ödeyebilir ve iş teknisyenin listesinden çıkar."
+    },
+    "add": "Durum ekle",
+    "builtIn": "Yerleşik",
+    "asksToNotify": "Bildirim önerir",
+    "notifyHint": "Bir iş bu duruma geldiğinde müşteriye mesaj göndermek önerilir. Siz onaylamadan hiçbir şey gönderilmez.",
+    "moveUp": "Yukarı taşı",
+    "moveDown": "Aşağı taşı",
+    "edit": "Düzenle",
+    "remove": "Kaldır",
+    "empty": "Burada henüz kendi durumunuz yok.",
+    "removeTitle": "{name} kaldırılsın mı?",
+    "removeBody": "Menülerden çıkar. Onu taşıyan işler göstermeye devam eder.",
+    "addTitle": "Yeni durum",
+    "editTitle": "Durumu düzenle",
+    "dialogBody": "Durum, rengi olan bir addır. Bağlı olduğu aşama, işin nasıl ele alınacağını belirler.",
+    "name": "Ad",
+    "namePlaceholder": "Teslime hazır",
+    "stage": "Aşama",
+    "color": "Renk",
+    "notify": "Müşteriye haber vermeyi öner",
+    "message": "Mesaj",
+    "tokens": "Ekle:",
+    "cancel": "İptal",
+    "save": "Kaydet",
+    "saved": "Durum kaydedildi",
+    "failed": "Durum kaydedilemedi",
+    "messageEmptyHint": "Boş bırakılırsa yukarıdaki metin gönderilir.",
+    "useSuggested": "Bu metni düzenle"
   }
   }
 }
 }

+ 39 - 0
prisma/migrations/20260921180000_work_order_statuses/migration.sql

@@ -0,0 +1,39 @@
+-- Prisma does not wrap a migration in a transaction. Nothing here touches
+-- existing rows, but the table and the column that points at it belong
+-- together, so they land together or not at all.
+BEGIN;
+
+-- CreateTable
+CREATE TABLE "work_order_statuses" (
+    "id" TEXT NOT NULL,
+    "name" TEXT NOT NULL,
+    "stage" TEXT NOT NULL,
+    "color" TEXT NOT NULL DEFAULT 'slate',
+    "sortOrder" INTEGER NOT NULL DEFAULT 0,
+    "notifyCustomer" BOOLEAN NOT NULL DEFAULT false,
+    "messageTemplate" TEXT,
+    "archivedAt" TIMESTAMP(3),
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" TIMESTAMP(3) NOT NULL,
+    "organizationId" TEXT NOT NULL,
+
+    CONSTRAINT "work_order_statuses_pkey" PRIMARY KEY ("id")
+);
+
+-- AlterTable
+ALTER TABLE "service_records" ADD COLUMN     "customStatusId" TEXT,
+ADD COLUMN     "customStatusSince" TIMESTAMP(3);
+
+-- CreateIndex
+CREATE INDEX "work_order_statuses_organizationId_stage_sortOrder_idx" ON "work_order_statuses"("organizationId", "stage", "sortOrder");
+
+-- CreateIndex
+CREATE INDEX "service_records_customStatusId_idx" ON "service_records"("customStatusId");
+
+-- AddForeignKey
+ALTER TABLE "work_order_statuses" ADD CONSTRAINT "work_order_statuses_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "service_records" ADD CONSTRAINT "service_records_customStatusId_fkey" FOREIGN KEY ("customStatusId") REFERENCES "work_order_statuses"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+COMMIT;

+ 1 - 0
prisma/schema/organization.prisma

@@ -71,6 +71,7 @@ model Organization {
   tireWarehouses              TireWarehouse[]
   tireWarehouses              TireWarehouse[]
   tireLocations               TireLocation[]
   tireLocations               TireLocation[]
   tireSets                    TireSet[]
   tireSets                    TireSet[]
+  workOrderStatuses           WorkOrderStatus[]
   tireSetAttachments          TireSetAttachment[]
   tireSetAttachments          TireSetAttachment[]
   tireMovements               TireMovement[]
   tireMovements               TireMovement[]
   tireTreatments              TireTreatment[]
   tireTreatments              TireTreatment[]

+ 42 - 0
prisma/schema/service-records.prisma

@@ -108,6 +108,16 @@ model ServiceRecord {
   tireSetId String?
   tireSetId String?
   tireSet   TireSet? @relation(fields: [tireSetId], references: [id], onDelete: SetNull)
   tireSet   TireSet? @relation(fields: [tireSetId], references: [id], onDelete: SetNull)
 
 
+  /// One of the workshop's own statuses, under the stage `status` names
+  /// ("Ready for pickup" under completed). A label with a colour and a
+  /// follow-up, never something the system decides by: whether a job is open,
+  /// shows in the portal or stops a clock is read from `status` alone. Cleared
+  /// whenever `status` moves, since it belongs to the stage it was chosen under.
+  customStatusId    String?
+  customStatus      WorkOrderStatus? @relation(fields: [customStatusId], references: [id], onDelete: SetNull)
+  /// When that status was chosen, for "Ready for pickup since 14:02".
+  customStatusSince DateTime?
+
   /// Null for parts-only / counter sales; such records use customerId
   /// Null for parts-only / counter sales; such records use customerId
   /// (or neither, for anonymous walk-in sales)
   /// (or neither, for anonymous walk-in sales)
   vehicleId String?
   vehicleId String?
@@ -165,6 +175,7 @@ model ServiceRecord {
   @@index([customerId])
   @@index([customerId])
   @@index([inspectionId])
   @@index([inspectionId])
   @@index([importBatchId])
   @@index([importBatchId])
+  @@index([customStatusId])
   @@map("service_records")
   @@map("service_records")
 }
 }
 
 
@@ -361,3 +372,34 @@ model RecurringLabor {
 
 
   @@map("recurring_labor")
   @@map("recurring_labor")
 }
 }
+
+/// A status the workshop defined for its work orders, filed under one of the
+/// three fixed stages. The stages are what the app reads; these are what the
+/// workshop calls the steps in between, with what should happen when a job
+/// reaches one.
+model WorkOrderStatus {
+  id              String    @id @default(cuid())
+  name            String
+  /// 'pending' | 'in-progress' | 'completed': the stage it sits under.
+  stage           String
+  /// A colour name from the app's palette, not a hex value, so it follows the theme.
+  color           String    @default("slate")
+  sortOrder       Int       @default(0)
+  /// Offer to tell the customer when a job reaches this status. Asked, never
+  /// sent on its own.
+  notifyCustomer  Boolean   @default(false)
+  /// The message that offer starts from, with {tokens}. Null uses the app's own.
+  messageTemplate String?
+  /// Removed from the menus but kept, so jobs that carry it still say what it was.
+  archivedAt      DateTime?
+  createdAt       DateTime  @default(now())
+  updatedAt       DateTime  @updatedAt
+
+  organizationId String
+  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+  serviceRecords ServiceRecord[]
+
+  @@index([organizationId, stage, sortOrder])
+  @@map("work_order_statuses")
+}

+ 126 - 0
src/__tests__/features/work-order-statuses/status-rules.test.ts

@@ -0,0 +1,126 @@
+/**
+ * The workshop's own work order statuses.
+ *
+ * The promise behind them is that nothing else in the app has to know they
+ * exist: the record keeps one of the four statuses it always had, and a
+ * status of the workshop's own is a label under it. These pin the rules that
+ * keep that true: a label can only sit under its own stage, it goes when the
+ * stage moves, and the four stored values are the only ones ever written.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+
+const serviceRecord = { findFirst: vi.fn(), update: vi.fn() }
+const workOrderStatus = { findFirst: vi.fn() }
+vi.mock('@/lib/db', () => ({ db: { serviceRecord, workOrderStatus } }))
+vi.mock('@/lib/with-auth', () => ({
+  withAuth: async (fn: (ctx: { organizationId: string; userId: string }) => Promise<unknown>) => {
+    try {
+      return { success: true, data: await fn({ organizationId: 'org-1', userId: 'user-1' }) }
+    } catch (error) {
+      return { success: false, error: (error as Error).message }
+    }
+  },
+}))
+vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
+vi.mock('@/lib/notification-bus', () => ({ notificationBus: { emit: vi.fn() } }))
+
+import {
+  SYSTEM_STATUSES,
+  stageOf,
+  statusColorClasses,
+  statusColumns,
+} from '@/features/work-order-statuses/Lib/stages'
+
+const { updateServiceStatus } = await import('@/features/vehicles/Actions/serviceActions')
+
+const READY = { id: 'st-ready', name: 'Ready for pickup', stage: 'completed' }
+
+beforeEach(() => {
+  vi.clearAllMocks()
+  serviceRecord.findFirst.mockResolvedValue({ id: 'rec-1', title: 'Job', vehicleId: 'veh-1' })
+  serviceRecord.update.mockResolvedValue({})
+  workOrderStatus.findFirst.mockResolvedValue(READY)
+})
+
+describe('stages', () => {
+  it('draws the built-in hold at the stage it belongs to', () => {
+    expect(stageOf('waiting-parts')).toBe('in-progress')
+    expect(stageOf('completed')).toBe('completed')
+  })
+
+  it('reads a status it does not know as not started, rather than as nothing', () => {
+    // Online booking writes 'scheduled', which no stage is called.
+    expect(stageOf('scheduled')).toBe('pending')
+  })
+
+  it('falls back to a neutral colour for one it does not have', () => {
+    expect(statusColorClasses('chartreuse')).toBe(statusColorClasses('slate'))
+    expect(statusColorClasses(null)).toBe(statusColorClasses('slate'))
+  })
+
+  it('drops the label whenever a job moves without one', () => {
+    expect(statusColumns('in-progress')).toEqual({
+      status: 'in-progress',
+      customStatusId: null,
+      customStatusSince: null,
+    })
+  })
+})
+
+describe('updateServiceStatus', () => {
+  it('moves a job exactly as before when no status of the workshop is named', async () => {
+    const result = await updateServiceStatus('rec-1', 'completed')
+
+    expect(result.success).toBe(true)
+    expect(workOrderStatus.findFirst).not.toHaveBeenCalled()
+    expect(serviceRecord.update).toHaveBeenCalledWith({
+      where: { id: 'rec-1' },
+      data: { status: 'completed', customStatusId: null, customStatusSince: null },
+    })
+  })
+
+  it('accepts each of the four stored statuses and nothing else', async () => {
+    for (const status of SYSTEM_STATUSES) {
+      expect((await updateServiceStatus('rec-1', status)).success).toBe(true)
+    }
+    const stray = await updateServiceStatus('rec-1', 'ready-for-pickup')
+    expect(stray.success).toBe(false)
+    expect(serviceRecord.update).toHaveBeenCalledTimes(SYSTEM_STATUSES.length)
+  })
+
+  it('sets the stage and the workshop status together, with when', async () => {
+    const result = await updateServiceStatus('rec-1', 'completed', 'st-ready')
+
+    expect(result.success).toBe(true)
+    const data = serviceRecord.update.mock.calls[0][0].data
+    expect(data).toMatchObject({ status: 'completed', customStatusId: 'st-ready' })
+    expect(data.customStatusSince).toBeInstanceOf(Date)
+  })
+
+  it('looks the status up in this workshop, among the ones still in use', async () => {
+    await updateServiceStatus('rec-1', 'completed', 'st-ready')
+
+    expect(workOrderStatus.findFirst.mock.calls[0][0].where).toEqual({
+      id: 'st-ready',
+      organizationId: 'org-1',
+      archivedAt: null,
+    })
+  })
+
+  it('refuses a status filed under another stage', async () => {
+    const result = await updateServiceStatus('rec-1', 'in-progress', 'st-ready')
+
+    expect(result.success).toBe(false)
+    expect(serviceRecord.update).not.toHaveBeenCalled()
+  })
+
+  it('refuses one that does not exist, or belongs to somebody else', async () => {
+    workOrderStatus.findFirst.mockResolvedValue(null)
+
+    const result = await updateServiceStatus('rec-1', 'completed', 'st-other-workshop')
+
+    expect(result.success).toBe(false)
+    expect(serviceRecord.update).not.toHaveBeenCalled()
+  })
+})

+ 7 - 7
src/__tests__/i18n/keys-resolve.test.ts

@@ -76,18 +76,18 @@ function requestedKeys(): { file: string; key: string; hasArgs: boolean }[] {
       if (counts.get(binding) !== 1) continue
       if (counts.get(binding) !== 1) continue
       // The trailing group tells us whether anything followed the key, which
       // The trailing group tells us whether anything followed the key, which
       // is what says the call passed values for the message's placeholders.
       // is what says the call passed values for the message's placeholders.
-      const call = new RegExp(
-        `\\b${binding}(?:\\.rich|\\.raw)?\\(\\s*['"]([\\w.]+)['"]\\s*(,?)`,
-        'g'
-      )
+      const call = new RegExp(`\\b${binding}(\\.rich|\\.raw)?\\(\\s*['"]([\\w.]+)['"]\\s*(,?)`, 'g')
       for (const m of src.matchAll(call)) {
       for (const m of src.matchAll(call)) {
         // `t(`prefix.${code}`)` leaves a trailing dot on the literal part.
         // `t(`prefix.${code}`)` leaves a trailing dot on the literal part.
         // The key is assembled at runtime and there is nothing to check.
         // The key is assembled at runtime and there is nothing to check.
-        if (m[1].endsWith('.')) continue
+        if (m[2].endsWith('.')) continue
         found.push({
         found.push({
           file: path.relative(ROOT, file),
           file: path.relative(ROOT, file),
-          key: `${namespace}.${m[1]}`,
-          hasArgs: m[2] === ',',
+          key: `${namespace}.${m[2]}`,
+          // `t.raw` hands the text back unformatted, so it never needs
+          // values: that is how a message carrying {tokens} for something
+          // else (an SMS template) is read without next-intl filling them.
+          hasArgs: m[3] === ',' || m[1] === '.raw',
         })
         })
       }
       }
     }
     }

+ 2 - 0
src/app/(authenticated)/settings/settings-nav.tsx

@@ -38,6 +38,7 @@ import {
   Plug,
   Plug,
   ClipboardCheck,
   ClipboardCheck,
   ShieldCheck,
   ShieldCheck,
+  ListChecks,
 } from 'lucide-react'
 } from 'lucide-react'
 
 
 type SettingsNavItem = {
 type SettingsNavItem = {
@@ -103,6 +104,7 @@ const settingsCategories: SettingsCategory[] = [
     key: 'workshop',
     key: 'workshop',
     items: [
     items: [
       { key: 'workshop', href: '/settings/workshop', icon: Wrench },
       { key: 'workshop', href: '/settings/workshop', icon: Wrench },
+      { key: 'workOrderStatuses', href: '/settings/work-order-statuses', icon: ListChecks },
       { key: 'maintenance', href: '/settings/maintenance', icon: Gauge },
       { key: 'maintenance', href: '/settings/maintenance', icon: Gauge },
       { key: 'inspectionReminders', href: '/settings/inspection-reminders', icon: ClipboardCheck },
       { key: 'inspectionReminders', href: '/settings/inspection-reminders', icon: ClipboardCheck },
       { key: 'alerts', href: '/settings/alerts', icon: BellRing },
       { key: 'alerts', href: '/settings/alerts', icon: BellRing },

+ 7 - 0
src/app/(authenticated)/settings/work-order-statuses/page.tsx

@@ -0,0 +1,7 @@
+import { listWorkOrderStatuses } from '@/features/work-order-statuses/Actions/workOrderStatusActions'
+import { WorkOrderStatusSettings } from '@/features/work-order-statuses/Components/WorkOrderStatusSettings'
+
+export default async function WorkOrderStatusSettingsPage() {
+  const result = await listWorkOrderStatuses()
+  return <WorkOrderStatusSettings statuses={result.success && result.data ? result.data : []} />
+}

+ 28 - 6
src/app/(authenticated)/work-orders/work-orders-client.tsx

@@ -63,13 +63,31 @@ import { useTranslations } from 'next-intl'
 import { getSmsTemplates } from '@/features/sms/Actions/smsActions'
 import { getSmsTemplates } from '@/features/sms/Actions/smsActions'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { SMS_TEMPLATE_DEFAULTS, interpolateSmsTemplate } from '@/lib/sms-templates'
 import { SMS_TEMPLATE_DEFAULTS, interpolateSmsTemplate } from '@/lib/sms-templates'
+import { statusColorClasses } from '@/features/work-order-statuses/Lib/stages'
+import { cn } from '@/lib/utils'
 import { ListEmpty } from '@/components/list-empty'
 import { ListEmpty } from '@/components/list-empty'
 
 
+/** The workshop's own status, in its colour, beside the stage. */
+function CustomStatusChip({ status }: { status: { name: string; color: string } }) {
+  return (
+    <span
+      className={cn(
+        'max-w-40 truncate rounded-full border px-2 py-0.5 text-xs font-medium',
+        statusColorClasses(status.color).chip
+      )}
+    >
+      {status.name}
+    </span>
+  )
+}
+
 interface WorkOrder {
 interface WorkOrder {
   id: string
   id: string
   title: string
   title: string
   type: string
   type: string
   status: string
   status: string
+  /** One of the workshop's own statuses, under the stage `status` names. */
+  customStatus?: { name: string; color: string } | null
   totalAmount: number
   totalAmount: number
   cost: number
   cost: number
   serviceDate: Date
   serviceDate: Date
@@ -466,6 +484,7 @@ export function WorkOrdersClient({
                     <Badge variant="outline" className={`text-xs ${statusColors[r.status] || ''}`}>
                     <Badge variant="outline" className={`text-xs ${statusColors[r.status] || ''}`}>
                       {r.status}
                       {r.status}
                     </Badge>
                     </Badge>
+                    {r.customStatus && <CustomStatusChip status={r.customStatus} />}
                     <span className="font-mono">
                     <span className="font-mono">
                       {formatDate(new Date(r.startDateTime ?? r.serviceDate))}
                       {formatDate(new Date(r.startDateTime ?? r.serviceDate))}
                     </span>
                     </span>
@@ -666,12 +685,15 @@ export function WorkOrdersClient({
                           <span className="font-medium">{r.title}</span>
                           <span className="font-medium">{r.title}</span>
                         </TableCell>
                         </TableCell>
                         <TableCell>
                         <TableCell>
-                          <Badge
-                            variant="outline"
-                            className={`text-xs ${statusColors[r.status] || ''}`}
-                          >
-                            {r.status}
-                          </Badge>
+                          <div className="flex flex-wrap items-center gap-1">
+                            <Badge
+                              variant="outline"
+                              className={`text-xs ${statusColors[r.status] || ''}`}
+                            >
+                              {r.status}
+                            </Badge>
+                            {r.customStatus && <CustomStatusChip status={r.customStatus} />}
+                          </div>
                         </TableCell>
                         </TableCell>
                         {shows('tech') && (
                         {shows('tech') && (
                           <TableCell className="hidden lg:table-cell text-sm text-muted-foreground">
                           <TableCell className="hidden lg:table-cell text-sm text-muted-foreground">

+ 8 - 0
src/app/api/protected/backup/export/route.ts

@@ -333,6 +333,14 @@ export async function POST(request: NextRequest) {
   if (options.workshopConfig) {
   if (options.workshopConfig) {
     // Everything a workshop configures that is not a key/value setting: none
     // Everything a workshop configures that is not a key/value setting: none
     // of it was in a backup before, so a restore rebuilt an empty shop.
     // of it was in a backup before, so a restore rebuilt an empty shop.
+    queries.push(
+      // Archived ones too: a finished job still names the status it carried.
+      db.workOrderStatus
+        .findMany({ where: { organizationId: ctx.organizationId } })
+        .then((result) => {
+          data.workOrderStatuses = result
+        })
+    )
     queries.push(
     queries.push(
       db.laborPreset
       db.laborPreset
         .findMany({
         .findMany({

+ 39 - 0
src/app/api/protected/backup/import/route.ts

@@ -1,3 +1,4 @@
+import { isStage } from '@/features/work-order-statuses/Lib/stages'
 import { NextRequest, NextResponse } from 'next/server'
 import { NextRequest, NextResponse } from 'next/server'
 import { assertContentLength, assertZipWithinLimits } from '@/lib/backup/zip-guard'
 import { assertContentLength, assertZipWithinLimits } from '@/lib/backup/zip-guard'
 import { rateLimit } from '@/lib/rate-limit'
 import { rateLimit } from '@/lib/rate-limit'
@@ -91,6 +92,8 @@ async function importServiceRecordTree(
     timeZone: string
     timeZone: string
     /** Technicians restored by this import. See the time entries below. */
     /** Technicians restored by this import. See the time entries below. */
     technicianIds: ReadonlySet<string>
     technicianIds: ReadonlySet<string>
+    /** The workshop's own statuses restored by this import. */
+    workOrderStatusIds: ReadonlySet<string>
     /**
     /**
      * Designs and snapshots restored by this import. A reference to one the
      * Designs and snapshots restored by this import. A reference to one the
      * backup did not carry is dropped rather than left dangling: the record
      * backup did not carry is dropped rather than left dangling: the record
@@ -159,6 +162,14 @@ async function importServiceRecordTree(
       // backup does not carry would be refused, and take the import with it.
       // backup does not carry would be refused, and take the import with it.
       technicianId: keptReference(sr.technicianId, opts.technicianIds),
       technicianId: keptReference(sr.technicianId, opts.technicianIds),
       workBayId: (sr.workBayId as string) || null,
       workBayId: (sr.workBayId as string) || null,
+      // Only a status this import restored. A backup made without the workshop
+      // configuration carries jobs that name statuses it does not hold; those
+      // jobs keep their stage and lose the label, rather than failing the import.
+      customStatusId: keptReference(sr.customStatusId, opts.workOrderStatusIds),
+      customStatusSince:
+        sr.customStatusSince && opts.workOrderStatusIds.has(sr.customStatusId as string)
+          ? toSafeDate(sr.customStatusSince as string)
+          : null,
       sortOrder: (sr.sortOrder as number) || 0,
       sortOrder: (sr.sortOrder as number) || 0,
       createdAt: toSafeDate(sr.createdAt as string),
       createdAt: toSafeDate(sr.createdAt as string),
       updatedAt: toSafeDate(sr.updatedAt as string),
       updatedAt: toSafeDate(sr.updatedAt as string),
@@ -463,6 +474,7 @@ export async function POST(request: NextRequest) {
         LaborPreset: () => tx.laborPreset.deleteMany({ where: { organizationId } }),
         LaborPreset: () => tx.laborPreset.deleteMany({ where: { organizationId } }),
         Webhook: () => tx.webhook.deleteMany({ where: { organizationId } }),
         Webhook: () => tx.webhook.deleteMany({ where: { organizationId } }),
         ReportSchedule: () => tx.reportSchedule.deleteMany({ where: { organizationId } }),
         ReportSchedule: () => tx.reportSchedule.deleteMany({ where: { organizationId } }),
+        WorkOrderStatus: () => tx.workOrderStatus.deleteMany({ where: { organizationId } }),
         AppSetting: () => tx.appSetting.deleteMany({ where: { organizationId } }),
         AppSetting: () => tx.appSetting.deleteMany({ where: { organizationId } }),
         DocumentDesign: () => tx.documentDesign.deleteMany({ where: { organizationId } }),
         DocumentDesign: () => tx.documentDesign.deleteMany({ where: { organizationId } }),
         EmailTemplate: () => tx.emailTemplate.deleteMany({ where: { organizationId } }),
         EmailTemplate: () => tx.emailTemplate.deleteMany({ where: { organizationId } }),
@@ -684,6 +696,31 @@ export async function POST(request: NextRequest) {
         })
         })
       }
       }
 
 
+      // 4c. Insert the workshop's own work order statuses. Service records
+      // point at them, so they have to exist before any job is restored.
+      const workOrderStatusIds = new Set<string>()
+      if (data.workOrderStatuses?.length) {
+        const rows = (data.workOrderStatuses as Record<string, unknown>[]).filter(
+          (s) => typeof s.id === 'string' && typeof s.name === 'string' && isStage(s.stage)
+        )
+        await tx.workOrderStatus.createMany({
+          data: rows.map((s) => ({
+            id: s.id as string,
+            name: s.name as string,
+            stage: s.stage as string,
+            color: (s.color as string) || 'slate',
+            sortOrder: (s.sortOrder as number) || 0,
+            notifyCustomer: s.notifyCustomer === true,
+            messageTemplate: (s.messageTemplate as string) || null,
+            archivedAt: s.archivedAt ? toSafeDate(s.archivedAt as string) : null,
+            createdAt: toSafeDate(s.createdAt as string),
+            updatedAt: toSafeDate(s.updatedAt as string),
+            organizationId: ctx.organizationId,
+          })),
+        })
+        for (const s of rows) workOrderStatusIds.add(s.id as string)
+      }
+
       // 5. Insert custom field definitions
       // 5. Insert custom field definitions
       if (data.customFieldDefinitions?.length) {
       if (data.customFieldDefinitions?.length) {
         for (const def of data.customFieldDefinitions as Record<string, unknown>[]) {
         for (const def of data.customFieldDefinitions as Record<string, unknown>[]) {
@@ -873,6 +910,7 @@ export async function POST(request: NextRequest) {
                 workDayStartTime,
                 workDayStartTime,
                 timeZone,
                 timeZone,
                 technicianIds,
                 technicianIds,
+                workOrderStatusIds,
                 designIds,
                 designIds,
                 designSnapshotIds,
                 designSnapshotIds,
                 assetSnapshotIds,
                 assetSnapshotIds,
@@ -959,6 +997,7 @@ export async function POST(request: NextRequest) {
             workDayStartTime,
             workDayStartTime,
             timeZone,
             timeZone,
             technicianIds,
             technicianIds,
+            workOrderStatusIds,
             designIds,
             designIds,
             designSnapshotIds,
             designSnapshotIds,
             assetSnapshotIds,
             assetSnapshotIds,

+ 4 - 1
src/app/api/v1/tech/jobs/[id]/status/route.ts

@@ -1,3 +1,4 @@
+import { statusColumns } from '@/features/work-order-statuses/Lib/stages'
 import { z } from 'zod'
 import { z } from 'zod'
 import { announceJobStatusChanged } from '@/features/vehicles/Lib/jobEvents'
 import { announceJobStatusChanged } from '@/features/vehicles/Lib/jobEvents'
 import { db } from '@/lib/db'
 import { db } from '@/lib/db'
@@ -72,7 +73,9 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
 
 
       const updated = await db.serviceRecord.update({
       const updated = await db.serviceRecord.update({
         where: { id: job.id },
         where: { id: job.id },
-        data: { status },
+        // The phone moves the stage; a status of the workshop's own belonged
+        // to the stage it left.
+        data: statusColumns(status),
         select: { id: true, status: true },
         select: { id: true, status: true },
       })
       })
 
 

+ 3 - 0
src/features/ai/tools/workshop-columns.ts

@@ -59,6 +59,9 @@ export const TABLE_COLUMNS: Readonly<Record<string, readonly string[]>> = {
     'description',
     'description',
     'type',
     'type',
     'status',
     'status',
+    // The workshop's own status under that stage, and when it was chosen.
+    'customStatusId',
+    'customStatusSince',
     'cost',
     'cost',
     'mileage',
     'mileage',
     'serviceDate',
     'serviceDate',

+ 45 - 4
src/features/vehicles/Actions/serviceActions.ts

@@ -20,6 +20,7 @@ import { issueInvoice } from '@/features/invoices/Lib/issueInvoice'
 import { withAuth } from '@/lib/with-auth'
 import { withAuth } from '@/lib/with-auth'
 import { createServiceSchema, updateServiceSchema } from '../Schema/serviceSchema'
 import { createServiceSchema, updateServiceSchema } from '../Schema/serviceSchema'
 import { revalidatePath } from 'next/cache'
 import { revalidatePath } from 'next/cache'
+import { isSystemStatus, statusColumns } from '@/features/work-order-statuses/Lib/stages'
 import { onInventoryChanged } from '@/features/inventory/Lib/onInventoryChanged'
 import { onInventoryChanged } from '@/features/inventory/Lib/onInventoryChanged'
 import { randomUUID } from 'crypto'
 import { randomUUID } from 'crypto'
 import { releaseFiles } from '@/lib/files/manager'
 import { releaseFiles } from '@/lib/files/manager'
@@ -233,6 +234,9 @@ export async function getServiceRecord(recordId: string) {
           payments: { orderBy: { date: 'desc' } },
           payments: { orderBy: { date: 'desc' } },
           // Who opened the job, for the line under its number.
           // Who opened the job, for the line under its number.
           createdBy: { select: { name: true } },
           createdBy: { select: { name: true } },
+          // The workshop's own status, archived or not: a job keeps saying
+          // what it was called after the status has left the menus.
+          customStatus: { select: { id: true, name: true, color: true, stage: true } },
           // A tire job is meaningless without knowing which set and which
           // A tire job is meaningless without knowing which set and which
           // shelf, so it travels with the record rather than being fetched
           // shelf, so it travels with the record rather than being fetched
           // separately by whatever screen happens to need it.
           // separately by whatever screen happens to need it.
@@ -738,6 +742,13 @@ export async function updateServiceRecord(input: unknown) {
           where: { id },
           where: { id },
           data: {
           data: {
             ...recordData,
             ...recordData,
+            // The form carries the stage, not the workshop's own status, which
+            // is chosen and saved on its own. So a save that moves the stage
+            // drops a status that belonged to the old one, and a save that
+            // leaves the stage alone leaves the status alone.
+            ...(recordData.status !== undefined && recordData.status !== existing.status
+              ? { customStatusId: null, customStatusSince: null }
+              : {}),
             taxComponents: splitTax?.taxComponents,
             taxComponents: splitTax?.taxComponents,
             // Attaching a vehicle to a counter sale: the direct customer link is
             // Attaching a vehicle to a counter sale: the direct customer link is
             // cleared so the invoice follows the vehicle's customer again.
             // cleared so the invoice follows the vehicle's customer again.
@@ -963,9 +974,33 @@ export async function updateServiceRecord(input: unknown) {
   )
   )
 }
 }
 
 
-export async function updateServiceStatus(recordId: string, status: string) {
+/**
+ * Moves a job along. `customStatusId` names one of the workshop's own
+ * statuses, which has to sit under the stage being moved to; without one the
+ * job stands at the plain stage and lets go of whatever status it carried,
+ * since that belonged to the stage it left. See work-order-statuses/Lib/stages.
+ */
+export async function updateServiceStatus(
+  recordId: string,
+  status: string,
+  customStatusId: string | null = null
+) {
   return withAuth(
   return withAuth(
     async ({ organizationId }) => {
     async ({ organizationId }) => {
+      // Written as it arrived until now; every screen that reads it keeps a
+      // closed list, so a stray value made the job vanish from half of them.
+      if (!isSystemStatus(status)) throw new Error('Unknown status')
+      const custom = customStatusId
+        ? await db.workOrderStatus.findFirst({
+            where: { id: customStatusId, organizationId, archivedAt: null },
+            select: { id: true, name: true, stage: true },
+          })
+        : null
+      if (customStatusId && !custom) throw new Error('Status not found')
+      if (custom && custom.stage !== status) {
+        throw new Error('That status belongs to another stage')
+      }
+
       const record = await db.serviceRecord.findFirst({
       const record = await db.serviceRecord.findFirst({
         where: { id: recordId, organizationId },
         where: { id: recordId, organizationId },
         include: {
         include: {
@@ -978,7 +1013,7 @@ export async function updateServiceStatus(recordId: string, status: string) {
 
 
       await db.serviceRecord.update({
       await db.serviceRecord.update({
         where: { id: recordId },
         where: { id: recordId },
-        data: { status },
+        data: statusColumns(status, custom),
       })
       })
 
 
       notificationBus.emit('workboard', {
       notificationBus.emit('workboard', {
@@ -999,7 +1034,7 @@ export async function updateServiceStatus(recordId: string, status: string) {
       revalidatePath('/services')
       revalidatePath('/services')
       if (record.vehicleId) revalidatePath(`/vehicles/${record.vehicleId}`)
       if (record.vehicleId) revalidatePath(`/vehicles/${record.vehicleId}`)
       else revalidatePath(`/sales/${recordId}`)
       else revalidatePath(`/sales/${recordId}`)
-      return { success: true, recordId, status }
+      return { success: true, recordId, status, customStatus: custom?.name ?? null }
     },
     },
     {
     {
       requiredPermissions: [
       requiredPermissions: [
@@ -1015,7 +1050,11 @@ export async function updateServiceStatus(recordId: string, status: string) {
           key: 'service_status',
           key: 'service_status',
           params: { status: result.status.replaceAll('-', '_') },
           params: { status: result.status.replaceAll('-', '_') },
         },
         },
-        metadata: { serviceRecordId: result.recordId, status: result.status },
+        metadata: {
+          serviceRecordId: result.recordId,
+          status: result.status,
+          ...(result.customStatus ? { customStatus: result.customStatus } : {}),
+        },
       }),
       }),
     }
     }
   )
   )
@@ -1163,6 +1202,8 @@ export async function getWorkOrders(params: {
         db.serviceRecord.findMany({
         db.serviceRecord.findMany({
           where,
           where,
           include: {
           include: {
+            // The workshop's own status, shown beside the stage in the list.
+            customStatus: { select: { name: true, color: true } },
             customer: { select: { id: true, name: true, email: true, phone: true } },
             customer: { select: { id: true, name: true, email: true, phone: true } },
             vehicle: {
             vehicle: {
               select: {
               select: {

+ 3 - 0
src/features/vehicles/Components/service-detail/types.ts

@@ -43,6 +43,9 @@ export interface ServiceDetail {
   description: string | null
   description: string | null
   type: string
   type: string
   status: string
   status: string
+  /** One of the workshop's own statuses, under the stage `status` names. */
+  customStatus?: { id: string; name: string; color: string; stage: string } | null
+  customStatusSince?: Date | string | null
   cost: number
   cost: number
   mileage: number | null
   mileage: number | null
   serviceDate: Date
   serviceDate: Date

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

@@ -4,6 +4,7 @@ import { DocumentLockBanner } from '@/components/document-lock-banner'
 import { setInvoiceEditUnlocked } from '@/features/settings/Actions/documentLockActions'
 import { setInvoiceEditUnlocked } from '@/features/settings/Actions/documentLockActions'
 import { InvoiceDesignMenu } from './InvoiceDesignMenu'
 import { InvoiceDesignMenu } from './InvoiceDesignMenu'
 import { PayCodeButton } from './PayCodeButton'
 import { PayCodeButton } from './PayCodeButton'
+import type { WorkOrderStatusOption } from '@/features/work-order-statuses/Lib/stages'
 import { useState, useCallback, useMemo, useRef, useEffect, type ComponentProps } from 'react'
 import { useState, useCallback, useMemo, useRef, useEffect, type ComponentProps } from 'react'
 import { useRouter } from 'next/navigation'
 import { useRouter } from 'next/navigation'
 import { sendInvoiceEmail } from '@/features/email/Actions/emailActions'
 import { sendInvoiceEmail } from '@/features/email/Actions/emailActions'
@@ -78,6 +79,7 @@ export function ServicePageClient({
   warrantyTexts,
   warrantyTexts,
   tireHotelEnabled = false,
   tireHotelEnabled = false,
   onlinePayments = false,
   onlinePayments = false,
+  workOrderStatuses = [],
   videoCall = { link: null, providers: [] },
   videoCall = { link: null, providers: [] },
   tireThresholds,
   tireThresholds,
   defaultTaxRate,
   defaultTaxRate,
@@ -304,6 +306,35 @@ export function ServicePageClient({
     setShowNotifyDialog(true)
     setShowNotifyDialog(true)
   }, [formState.status, customer, record.vehicle, record.title]) // eslint-disable-line react-hooks/exhaustive-deps
   }, [formState.status, customer, record.vehicle, record.title]) // eslint-disable-line react-hooks/exhaustive-deps
 
 
+  // One of the workshop's own statuses asked for the customer to be told. The
+  // message is the status's own, or a plain one naming it; either way it opens
+  // in the dialog to be read and changed before anything is sent.
+  const handleNotifyForStatus = useCallback(
+    async (option: WorkOrderStatusOption) => {
+      if (!customer) return
+      const tplResult = await getSmsTemplates()
+      const tplData = tplResult.success && tplResult.data ? tplResult.data : null
+      const vehicle = record.vehicle
+        ? `${record.vehicle.year} ${record.vehicle.make} ${record.vehicle.model}`
+        : record.title
+      const message = interpolateSmsTemplate(
+        // Read raw: the text carries {tokens} of its own, which are filled in
+        // below and are not message arguments.
+        option.messageTemplate?.trim() ||
+          String(t.raw('customStatus.defaultMessage')).replace('{status}', option.name),
+        {
+          customer_name: customer.name,
+          vehicle,
+          company_name: tplData?.companyName || '',
+          current_user: tplData?.currentUser || '',
+        }
+      )
+      setNotifyMessage(message)
+      setShowNotifyDialog(true)
+    },
+    [customer, record.vehicle, record.title, t]
+  )
+
   // Observations state
   // Observations state
   const tf = useTranslations('vehicles.findings')
   const tf = useTranslations('vehicles.findings')
   const [addingObservations, setAddingObservations] = useState(false)
   const [addingObservations, setAddingObservations] = useState(false)
@@ -630,6 +661,8 @@ export function ServicePageClient({
             <ModernDetails
             <ModernDetails
               {...leftColumnProps}
               {...leftColumnProps}
               {...rightColumnProps}
               {...rightColumnProps}
+              workOrderStatuses={workOrderStatuses}
+              onNotifyForStatus={handleNotifyForStatus}
               locked={lockState.locked}
               locked={lockState.locked}
               lockedLabel={t('invoice.lockedFieldsetLabel')}
               lockedLabel={t('invoice.lockedFieldsetLabel')}
               form={{
               form={{

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

@@ -33,6 +33,7 @@ import { ServicePageClient } from '@/features/vehicles/Components/service-page/S
 import { listDesignOptions } from '@/features/invoice-designer/Actions/documentDesignActions'
 import { listDesignOptions } from '@/features/invoice-designer/Actions/documentDesignActions'
 import { rendersFromIssue } from '@/features/invoices/Lib/issuedInvoice'
 import { rendersFromIssue } from '@/features/invoices/Lib/issuedInvoice'
 import { offeredPaymentProviders } from '@/features/integrations/Lib/payments'
 import { offeredPaymentProviders } from '@/features/integrations/Lib/payments'
+import { listWorkOrderStatuses } from '@/features/work-order-statuses/Actions/workOrderStatusActions'
 import { PageHeader } from '@/components/page-header'
 import { PageHeader } from '@/components/page-header'
 import { getTranslations } from 'next-intl/server'
 import { getTranslations } from 'next-intl/server'
 import { workshopTimeZone } from '@/lib/workshop-timezone'
 import { workshopTimeZone } from '@/lib/workshop-timezone'
@@ -67,6 +68,7 @@ export async function ServiceRecordPage({
     designOptionsResult,
     designOptionsResult,
     jobClockResult,
     jobClockResult,
     initialLayout,
     initialLayout,
+    workOrderStatusesResult,
   ] = await Promise.all([
   ] = await Promise.all([
     getServiceRecord(serviceId),
     getServiceRecord(serviceId),
     getDisplaySettings([
     getDisplaySettings([
@@ -94,6 +96,7 @@ export async function ServiceRecordPage({
     listDesignOptions('invoice'),
     listDesignOptions('invoice'),
     getJobClock(serviceId),
     getJobClock(serviceId),
     resolveWorkOrderLayout(),
     resolveWorkOrderLayout(),
+    listWorkOrderStatuses(),
   ])
   ])
 
 
   if (!result.success || !result.data) {
   if (!result.success || !result.data) {
@@ -392,6 +395,11 @@ export async function ServiceRecordPage({
         dictationMode={dictation.mode}
         dictationMode={dictation.mode}
         tireHotelEnabled={tireHotel.enabled}
         tireHotelEnabled={tireHotel.enabled}
         onlinePayments={onlinePayments}
         onlinePayments={onlinePayments}
+        workOrderStatuses={
+          workOrderStatusesResult.success && workOrderStatusesResult.data
+            ? workOrderStatusesResult.data
+            : []
+        }
         tireThresholds={{
         tireThresholds={{
           summerReplace: tireHotel.summerReplaceMm,
           summerReplace: tireHotel.summerReplaceMm,
           winterReplace: tireHotel.winterReplaceMm,
           winterReplace: tireHotel.winterReplaceMm,

+ 9 - 0
src/features/vehicles/Components/service-page/modern/ActivityCard.tsx

@@ -40,6 +40,15 @@ export function ActivityCard({ record, currencyCode, notificationHistory }: Acti
       detail: n.body,
       detail: n.body,
     })
     })
   }
   }
+  // When the job reached the workshop's own status it carries now ("Ready for
+  // pickup"). Only the current one: nothing records the ones before it.
+  if (record.customStatus && record.customStatusSince) {
+    items.push({
+      key: 'custom-status',
+      at: new Date(record.customStatusSince),
+      text: t('modern.activity.statusSet', { status: record.customStatus.name }),
+    })
+  }
   if (record.sharedAt) {
   if (record.sharedAt) {
     items.push({
     items.push({
       key: 'shared',
       key: 'shared',

+ 41 - 10
src/features/vehicles/Components/service-page/modern/ModernDetails.tsx

@@ -36,12 +36,17 @@ import { ActivityCard } from './ActivityCard'
 import { FilesMediaCard } from './FilesMediaCard'
 import { FilesMediaCard } from './FilesMediaCard'
 import { JobFactsCard } from './JobFactsCard'
 import { JobFactsCard } from './JobFactsCard'
 import { MoneyBar, MoneyLine } from './MoneyBar'
 import { MoneyBar, MoneyLine } from './MoneyBar'
+import type { WorkOrderStatusOption } from '@/features/work-order-statuses/Lib/stages'
 import { StatusStepper } from './StatusStepper'
 import { StatusStepper } from './StatusStepper'
 
 
 type LeftProps = ComponentProps<typeof DetailsLeftColumn>
 type LeftProps = ComponentProps<typeof DetailsLeftColumn>
 type RightProps = ComponentProps<typeof DetailsRightColumn>
 type RightProps = ComponentProps<typeof DetailsRightColumn>
 
 
 export interface ModernDetailsProps extends LeftProps, Omit<RightProps, keyof LeftProps> {
 export interface ModernDetailsProps extends LeftProps, Omit<RightProps, keyof LeftProps> {
+  /** The workshop's own statuses, for the menus on the stepper. */
+  workOrderStatuses?: WorkOrderStatusOption[]
+  /** Offers to tell the customer about a status that asks for it. */
+  onNotifyForStatus?: (status: WorkOrderStatusOption) => void
   /**
   /**
    * A locked invoice disables what it freezes: the lines, totals, invoice
    * A locked invoice disables what it freezes: the lines, totals, invoice
    * fields, warranty, notes and custom fields. Status, booking, clock,
    * fields, warranty, notes and custom fields. Status, booking, clock,
@@ -137,7 +142,20 @@ export function ModernDetails(props: ModernDetailsProps) {
   const confirmUnconfirmed = useConfirm()
   const confirmUnconfirmed = useConfirm()
   // Bumped by the bar's "Take payment": the payments form opens and scrolls into view.
   // Bumped by the bar's "Take payment": the payments form opens and scrolls into view.
   const [paymentSignal, setPaymentSignal] = useState(0)
   const [paymentSignal, setPaymentSignal] = useState(0)
-  const changeStatus = async (next: string) => {
+  // The workshop's own status the job carries. Held here as well as on the
+  // record so the stepper answers at once, before the page has been re-read.
+  const [customStatus, setCustomStatus] = useState(record.customStatus ?? null)
+  // Keyed on what is drawn, not on the object: the record arrives as a new
+  // object on every refresh, and a status renamed or recoloured in settings
+  // keeps its id while what the stepper shows has changed.
+  const recordCustomKey = record.customStatus
+    ? `${record.customStatus.id}:${record.customStatus.name}:${record.customStatus.color}`
+    : ''
+  useEffect(() => {
+    setCustomStatus(record.customStatus ?? null)
+  }, [recordCustomKey]) // eslint-disable-line react-hooks/exhaustive-deps -- see above
+
+  const changeStatus = async (next: string, custom: WorkOrderStatusOption | null = null) => {
     const open = formState.concerns.filter((c) => c.description.trim() && !c.confirmed)
     const open = formState.concerns.filter((c) => c.description.trim() && !c.confirmed)
     if (next === 'completed' && open.length > 0) {
     if (next === 'completed' && open.length > 0) {
       const ok = await confirmUnconfirmed({
       const ok = await confirmUnconfirmed({
@@ -149,22 +167,30 @@ export function ModernDetails(props: ModernDetailsProps) {
       })
       })
       if (!ok) return
       if (!ok) return
     }
     }
-    if (!locked) {
+    // A plain move between stages, with none of the workshop's statuses on
+    // either side of it, goes with the form as it always has.
+    if (!locked && !custom && !customStatus) {
       formState.dirtySetStatus(next)
       formState.dirtySetStatus(next)
       return
       return
     }
     }
-    // A locked invoice refuses the form's save, but the lock freezes what the
-    // invoice says is owed, not how far along the job is. So the status is
-    // saved on its own, the way the work board moves a card.
-    const previous = formState.status
+    // Everything else is saved on its own, the way the work board moves a
+    // card. A locked invoice refuses the form's save, but the lock freezes
+    // what the invoice says is owed, not how far along the job is. And one of
+    // the workshop's statuses is not a field of the form at all: it is chosen,
+    // and dropped, here.
+    const previous = { status: formState.status, custom: customStatus }
     formState.setStatus(next)
     formState.setStatus(next)
-    const result = await updateServiceStatus(record.id, next)
+    setCustomStatus(custom)
+    const result = await updateServiceStatus(record.id, next, custom?.id ?? null)
     if (result.success) {
     if (result.success) {
       formState.flashSaved()
       formState.flashSaved()
       router.refresh()
       router.refresh()
+      // Asked, never sent: the desk decides whether this customer hears.
+      if (custom?.notifyCustomer) props.onNotifyForStatus?.(custom)
     } else {
     } else {
-      formState.setStatus(previous)
-      toast.error(t('page.failedUpdate'))
+      formState.setStatus(previous.status)
+      setCustomStatus(previous.custom)
+      toast.error(result.error || t('page.failedUpdate'))
     }
     }
   }
   }
 
 
@@ -262,7 +288,12 @@ export function ModernDetails(props: ModernDetailsProps) {
               value={formState.initialData.serviceDate || new Date().toISOString().split('T')[0]}
               value={formState.initialData.serviceDate || new Date().toISOString().split('T')[0]}
             />
             />
             {/* Outside the lock: a locked invoice still moves along. */}
             {/* Outside the lock: a locked invoice still moves along. */}
-            <StatusStepper status={formState.status} onChange={(next) => void changeStatus(next)} />
+            <StatusStepper
+              status={formState.status}
+              customStatus={customStatus}
+              options={props.workOrderStatuses}
+              onChange={(next, custom) => void changeStatus(next, custom)}
+            />
 
 
             {/* The side column gives way before the job does: the parts and labour
             {/* The side column gives way before the job does: the parts and labour
             editors turn into a table at 672px of their own, so two columns
             editors turn into a table at 672px of their own, so two columns

+ 152 - 59
src/features/vehicles/Components/service-page/modern/StatusStepper.tsx

@@ -1,47 +1,67 @@
 'use client'
 'use client'
 
 
 import { useTranslations } from 'next-intl'
 import { useTranslations } from 'next-intl'
-import { Check, PackageSearch } from 'lucide-react'
+import { Check, ChevronDown } from 'lucide-react'
+import {
+  DropdownMenu,
+  DropdownMenuContent,
+  DropdownMenuItem,
+  DropdownMenuSeparator,
+  DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu'
 import { cn } from '@/lib/utils'
 import { cn } from '@/lib/utils'
+import {
+  STAGES,
+  type Stage,
+  WAITING_PARTS,
+  type WorkOrderStatusOption,
+  stageOf,
+  statusColorClasses,
+} from '@/features/work-order-statuses/Lib/stages'
 import { statusMessageKeys } from '../../service-detail/types'
 import { statusMessageKeys } from '../../service-detail/types'
 
 
-/**
- * The stages every job passes through. Waiting for parts is not one of them:
- * it is a hold on a job that is under way, and most jobs never have it. Drawn
- * as a fourth stage it was ticked as done on every completed job, including
- * the ones that never waited for anything.
- */
-const STAGES = ['pending', 'in-progress', 'completed'] as const
-const WAITING = 'waiting-parts'
+/** What the stepper shows as chosen under a stage: the built-in hold, or one of the workshop's own. */
+type Chosen = { id: string; name: string; color: string } | null
 
 
 /**
 /**
- * The job's status as a line it travels along: a marker for each stage, joined
- * by a track that fills behind the job as it moves. Stages that are done carry
- * a tick, the current one is the solid marker with its name in full weight,
- * and what is still ahead is an outline. No box around it: it is a property
- * of the page, not a card on it.
+ * The job's status as a line it travels along: a marker for each of the three
+ * stages, joined by a track that fills behind the job as it moves. Stages that
+ * are done carry a tick, the current one is the solid marker with its name in
+ * full weight, and what is still ahead is an outline. No box around it: it is
+ * a property of the page, not a card on it.
  *
  *
- * Waiting for parts sits on the line as a hold beside "in progress": a pill
- * that is off until somebody sets it, amber while the job is held, and gone
- * back to off when work resumes. It is still one of the four statuses the
- * record stores; only the drawing changed.
+ * A stage that has statuses filed under it carries a small arrow. The menu
+ * behind it lists them: "Waiting for parts", which the app has always had,
+ * under "in progress", and whatever the workshop defined in settings under
+ * the stage it chose. Picking one moves the job to that stage and names the
+ * status in one step; the name then sits beside the stage in its own colour.
+ * A stage with nothing under it has no arrow, so a workshop that defines no
+ * statuses sees three stages and one arrow, and nothing else changes for it.
  *
  *
- * Clicking a stage sets the status exactly as the select on the classic page
- * does, saved with the rest of the form. It sits inside the form's fieldset,
- * so a locked invoice disables it with everything else.
+ * It sits outside the form's lock: a locked invoice still moves along.
  */
  */
 export function StatusStepper({
 export function StatusStepper({
   status,
   status,
+  customStatus = null,
+  options = [],
   onChange,
   onChange,
 }: {
 }: {
   status: string
   status: string
-  onChange: (status: string) => void
+  /** The workshop's own status the job carries, archived or not. */
+  customStatus?: { id: string; name: string; color: string } | null
+  /** The workshop's statuses that can be chosen. */
+  options?: WorkOrderStatusOption[]
+  /** A stage alone, or a stage with one of the workshop's statuses under it. */
+  onChange: (status: string, custom: WorkOrderStatusOption | null) => void
 }) {
 }) {
   const t = useTranslations('service')
   const t = useTranslations('service')
-  const waiting = status === WAITING
-  // A held job is under way, so it stands where "in progress" does.
-  const current = STAGES.indexOf((waiting ? 'in-progress' : status) as (typeof STAGES)[number])
-  const waitingLabel = t(`basicInfo.statusOptions.${statusMessageKeys[WAITING]}`)
+  const current = STAGES.indexOf(stageOf(status))
+  const waitingLabel = t(`basicInfo.statusOptions.${statusMessageKeys[WAITING_PARTS]}`)
+
+  const chosen: Chosen =
+    status === WAITING_PARTS
+      ? { id: WAITING_PARTS, name: waitingLabel, color: 'amber' }
+      : customStatus
 
 
   return (
   return (
     <nav
     <nav
@@ -55,21 +75,24 @@ export function StatusStepper({
         const isCurrent = i === current
         const isCurrent = i === current
         const isDone = i < current
         const isDone = i < current
         const isLast = i === STAGES.length - 1
         const isLast = i === STAGES.length - 1
-        // While the job is held, the hold is the current step and this stage
-        // is where it is held.
-        const isHeld = isCurrent && waiting
         const label = t(`basicInfo.statusOptions.${statusMessageKeys[stage]}`)
         const label = t(`basicInfo.statusOptions.${statusMessageKeys[stage]}`)
+        const under = options.filter((option) => option.stage === stage)
+        const hasMenu = under.length > 0 || stage === 'in-progress'
+        const sub = isCurrent ? chosen : null
+        const name = sub ? `${label}: ${sub.name}` : label
         return (
         return (
           <div key={stage} className={cn('flex min-w-0 items-center', !isLast && 'flex-1')}>
           <div key={stage} className={cn('flex min-w-0 items-center', !isLast && 'flex-1')}>
             <button
             <button
               type="button"
               type="button"
-              aria-current={isCurrent && !isHeld ? 'step' : undefined}
+              aria-current={isCurrent ? 'step' : undefined}
               // Named even where only its number shows, for screen readers and
               // Named even where only its number shows, for screen readers and
               // for the tooltip on a stage whose label is cut short.
               // for the tooltip on a stage whose label is cut short.
-              aria-label={label}
-              title={label}
+              aria-label={name}
+              title={name}
               onClick={() => {
               onClick={() => {
-                if (!isCurrent || isHeld) onChange(stage)
+                // Clicking the stage a status sits under goes back to the
+                // plain stage, which is how a hold is lifted.
+                if (!isCurrent || sub) onChange(stage, null)
               }}
               }}
               // Allowed to shrink, so a long label (or a longer language) is
               // Allowed to shrink, so a long label (or a longer language) is
               // cut with an ellipsis instead of running into the next stage.
               // cut with an ellipsis instead of running into the next stage.
@@ -79,10 +102,7 @@ export function StatusStepper({
                 className={cn(
                 className={cn(
                   'flex h-5 w-5 shrink-0 items-center justify-center rounded-full border text-[10px] font-semibold tabular-nums transition-colors',
                   'flex h-5 w-5 shrink-0 items-center justify-center rounded-full border text-[10px] font-semibold tabular-nums transition-colors',
                   isCurrent &&
                   isCurrent &&
-                    !isHeld &&
                     'border-primary bg-primary text-primary-foreground ring-4 ring-primary/15',
                     'border-primary bg-primary text-primary-foreground ring-4 ring-primary/15',
-                  isHeld &&
-                    'border-amber-500/60 bg-amber-500/15 text-amber-700 dark:text-amber-400',
                   isDone && 'border-primary/60 bg-primary/15 text-primary',
                   isDone && 'border-primary/60 bg-primary/15 text-primary',
                   !isCurrent &&
                   !isCurrent &&
                     !isDone &&
                     !isDone &&
@@ -96,37 +116,37 @@ export function StatusStepper({
               <span
               <span
                 className={cn(
                 className={cn(
                   'min-w-0 truncate py-px text-[13px] leading-none',
                   'min-w-0 truncate py-px text-[13px] leading-none',
-                  isCurrent && !isHeld
+                  isCurrent
                     ? 'font-semibold text-foreground'
                     ? 'font-semibold text-foreground'
                     : 'hidden text-muted-foreground group-hover/step:text-foreground group-disabled/step:group-hover/step:text-muted-foreground @2xl:inline'
                     : 'hidden text-muted-foreground group-hover/step:text-foreground group-disabled/step:group-hover/step:text-muted-foreground @2xl:inline'
                 )}
                 )}
               >
               >
                 {label}
                 {label}
               </span>
               </span>
+              {sub && (
+                <span
+                  data-testid="status-sub"
+                  className={cn(
+                    'min-w-0 truncate rounded-full border px-2 py-0.5 text-[12px] font-medium leading-none',
+                    statusColorClasses(sub.color).chip
+                  )}
+                >
+                  {sub.name}
+                </span>
+              )}
             </button>
             </button>
 
 
-            {stage === 'in-progress' && (
-              <button
-                type="button"
-                data-testid="status-waiting"
-                aria-current={waiting ? 'step' : undefined}
-                aria-pressed={waiting}
-                aria-label={waitingLabel}
-                title={waitingLabel}
-                onClick={() => onChange(waiting ? 'in-progress' : WAITING)}
-                className={cn(
-                  'group/hold ml-1 flex min-w-0 shrink-0 cursor-pointer items-center gap-1.5 rounded-full border px-2 py-1 text-[12px] leading-none outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default',
-                  waiting
-                    ? 'border-amber-500/50 bg-amber-500/15 font-semibold text-amber-700 ring-4 ring-amber-500/10 dark:text-amber-400'
-                    : 'border-dashed border-border text-muted-foreground hover:border-amber-500/50 hover:text-foreground disabled:hover:border-border disabled:hover:text-muted-foreground'
-                )}
-              >
-                <PackageSearch className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
-                {/* Named while it is on, and wherever there is room for it. */}
-                <span className={cn('truncate', !waiting && 'hidden @2xl:inline')}>
-                  {waitingLabel}
-                </span>
-              </button>
+            {hasMenu && (
+              <StageMenu
+                stage={stage}
+                stageLabel={label}
+                waitingLabel={waitingLabel}
+                moreLabel={t('modern.moreStatuses')}
+                under={under}
+                chosenId={sub?.id ?? null}
+                isCurrent={isCurrent}
+                onChange={onChange}
+              />
             )}
             )}
 
 
             {!isLast && (
             {!isLast && (
@@ -144,3 +164,76 @@ export function StatusStepper({
     </nav>
     </nav>
   )
   )
 }
 }
+
+/** The arrow beside a stage, and the statuses filed under it. */
+function StageMenu({
+  stage,
+  stageLabel,
+  waitingLabel,
+  moreLabel,
+  under,
+  chosenId,
+  isCurrent,
+  onChange,
+}: {
+  stage: Stage
+  stageLabel: string
+  waitingLabel: string
+  moreLabel: string
+  under: WorkOrderStatusOption[]
+  chosenId: string | null
+  isCurrent: boolean
+  onChange: (status: string, custom: WorkOrderStatusOption | null) => void
+}) {
+  const row = 'flex cursor-pointer items-center gap-2 text-[13px]'
+  const tick = (on: boolean) => (
+    <Check className={cn('ml-auto h-3.5 w-3.5', on ? 'opacity-100' : 'opacity-0')} />
+  )
+  return (
+    <DropdownMenu>
+      <DropdownMenuTrigger asChild>
+        <button
+          type="button"
+          data-testid={`status-menu-${stage}`}
+          // The same words on every arrow, on purpose: the stage's own button
+          // is the one that carries its name.
+          aria-label={moreLabel}
+          title={moreLabel}
+          className="flex h-6 w-5 shrink-0 cursor-pointer items-center justify-center rounded text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default"
+        >
+          <ChevronDown className="h-3.5 w-3.5" aria-hidden="true" />
+        </button>
+      </DropdownMenuTrigger>
+      <DropdownMenuContent align="start" className="min-w-52">
+        <DropdownMenuItem className={row} onSelect={() => onChange(stage, null)}>
+          <span className="h-2 w-2 shrink-0 rounded-full bg-primary" />
+          {stageLabel}
+          {tick(isCurrent && chosenId === null)}
+        </DropdownMenuItem>
+        <DropdownMenuSeparator />
+        {stage === 'in-progress' && (
+          <DropdownMenuItem className={row} onSelect={() => onChange(WAITING_PARTS, null)}>
+            <span
+              className={cn('h-2 w-2 shrink-0 rounded-full', statusColorClasses('amber').dot)}
+            />
+            {waitingLabel}
+            {tick(chosenId === WAITING_PARTS)}
+          </DropdownMenuItem>
+        )}
+        {under.map((option) => (
+          <DropdownMenuItem
+            key={option.id}
+            className={row}
+            onSelect={() => onChange(stage, option)}
+          >
+            <span
+              className={cn('h-2 w-2 shrink-0 rounded-full', statusColorClasses(option.color).dot)}
+            />
+            <span className="min-w-0 truncate">{option.name}</span>
+            {tick(chosenId === option.id)}
+          </DropdownMenuItem>
+        ))}
+      </DropdownMenuContent>
+    </DropdownMenu>
+  )
+}

+ 3 - 0
src/features/vehicles/Components/service-page/service-page-types.ts

@@ -1,3 +1,4 @@
+import type { WorkOrderStatusOption } from '@/features/work-order-statuses/Lib/stages'
 import type { WorkOrderLayout } from '@/lib/work-order-layout'
 import type { WorkOrderLayout } from '@/lib/work-order-layout'
 import type { DesignAutoRule } from '@/features/invoice-designer/Lib/designRules'
 import type { DesignAutoRule } from '@/features/invoice-designer/Lib/designRules'
 import type { JobClock } from '@/features/time-tracking/Actions/timeClockActions'
 import type { JobClock } from '@/features/time-tracking/Actions/timeClockActions'
@@ -97,6 +98,8 @@ export interface ServicePageClientProps {
   tireHotelEnabled?: boolean
   tireHotelEnabled?: boolean
   /** A payment vendor is connected, so the shared invoice can be paid online. */
   /** A payment vendor is connected, so the shared invoice can be paid online. */
   onlinePayments?: boolean
   onlinePayments?: boolean
+  /** The workshop's own statuses, for the menus on the status stepper. */
+  workOrderStatuses?: WorkOrderStatusOption[]
   /** The work order's video call, and the connected services that could add one. */
   /** The work order's video call, and the connected services that could add one. */
   videoCall?: ServiceVideoCall
   videoCall?: ServiceVideoCall
   /** The workshop's tread limits, so a set checked in here grades correctly. */
   /** The workshop's tread limits, so a set checked in here grades correctly. */

+ 198 - 0
src/features/work-order-statuses/Actions/workOrderStatusActions.ts

@@ -0,0 +1,198 @@
+'use server'
+
+import { revalidatePath } from 'next/cache'
+import { db } from '@/lib/db'
+import { withAuth } from '@/lib/with-auth'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { isStage, type WorkOrderStatusOption } from '../Lib/stages'
+import {
+  reorderWorkOrderStatusesSchema,
+  updateWorkOrderStatusSchema,
+  workOrderStatusSchema,
+} from '../Schema/workOrderStatusSchema'
+
+/** A workshop needs a handful. The cap only stops a runaway script. */
+const MAX_PER_STAGE = 12
+
+const SELECT = {
+  id: true,
+  name: true,
+  stage: true,
+  color: true,
+  notifyCustomer: true,
+  messageTemplate: true,
+  sortOrder: true,
+} as const
+
+const MANAGE = [{ action: PermissionAction.UPDATE, subject: PermissionSubject.SETTINGS }]
+
+function revalidate() {
+  revalidatePath('/settings/work-order-statuses')
+  revalidatePath('/work-orders')
+}
+
+/**
+ * The workshop's statuses, for the menus on a work order. Read with the
+ * permission to read work orders, not settings: a technician choosing "Ready
+ * for pickup" must be able to see that it exists, and the settings table
+ * holds secrets a Member is never given.
+ */
+export async function listWorkOrderStatuses() {
+  return withAuth(
+    async ({ organizationId }): Promise<WorkOrderStatusOption[]> => {
+      const rows = await db.workOrderStatus.findMany({
+        where: { organizationId, archivedAt: null },
+        orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
+        select: SELECT,
+      })
+      // A stage the app no longer has would be a status nobody can reach.
+      return rows
+        .filter((row) => isStage(row.stage))
+        .map((row) => ({ ...row, stage: row.stage as WorkOrderStatusOption['stage'] }))
+    },
+    {
+      requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.SERVICES }],
+    }
+  )
+}
+
+export async function createWorkOrderStatus(input: unknown) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const data = workOrderStatusSchema.parse(input)
+      const inStage = await db.workOrderStatus.count({
+        where: { organizationId, stage: data.stage, archivedAt: null },
+      })
+      if (inStage >= MAX_PER_STAGE)
+        throw new Error('That stage has as many statuses as it can take')
+
+      const created = await db.workOrderStatus.create({
+        data: {
+          organizationId,
+          name: data.name,
+          stage: data.stage,
+          color: data.color,
+          notifyCustomer: data.notifyCustomer,
+          messageTemplate: data.messageTemplate || null,
+          // Last in its stage.
+          sortOrder: inStage,
+        },
+        select: SELECT,
+      })
+      revalidate()
+      return created
+    },
+    {
+      requiredPermissions: MANAGE,
+      audit: ({ result }) => ({
+        action: 'settings.workOrderStatus.create',
+        entity: 'WorkOrderStatus',
+        entityId: result.id,
+        message: `Added the work order status "${result.name}"`,
+        metadata: { stage: result.stage },
+      }),
+    }
+  )
+}
+
+export async function updateWorkOrderStatus(input: unknown) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const data = updateWorkOrderStatusSchema.parse(input)
+      const existing = await db.workOrderStatus.findFirst({
+        where: { id: data.id, organizationId, archivedAt: null },
+        select: { id: true, stage: true },
+      })
+      if (!existing) throw new Error('Status not found')
+
+      const updated = await db.$transaction(async (tx) => {
+        // Moved to another stage: the jobs that carry it are at the old
+        // stage, where it no longer belongs, so they let go of it. Their
+        // stage, which is what the app reads, does not move.
+        if (existing.stage !== data.stage) {
+          await tx.serviceRecord.updateMany({
+            where: { organizationId, customStatusId: existing.id },
+            data: { customStatusId: null, customStatusSince: null },
+          })
+        }
+        return tx.workOrderStatus.update({
+          where: { id: existing.id },
+          data: {
+            name: data.name,
+            stage: data.stage,
+            color: data.color,
+            notifyCustomer: data.notifyCustomer,
+            messageTemplate: data.messageTemplate || null,
+          },
+          select: SELECT,
+        })
+      })
+      revalidate()
+      return updated
+    },
+    {
+      requiredPermissions: MANAGE,
+      audit: ({ result }) => ({
+        action: 'settings.workOrderStatus.update',
+        entity: 'WorkOrderStatus',
+        entityId: result.id,
+        message: `Changed the work order status "${result.name}"`,
+        metadata: { stage: result.stage },
+      }),
+    }
+  )
+}
+
+/**
+ * Takes a status out of the menus. Archived rather than deleted: a job that
+ * carries it keeps saying what it was, and the history of a finished job
+ * should not change because the workshop tidied its settings.
+ */
+export async function archiveWorkOrderStatus(id: string) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const existing = await db.workOrderStatus.findFirst({
+        where: { id, organizationId, archivedAt: null },
+        select: { id: true, name: true },
+      })
+      if (!existing) throw new Error('Status not found')
+      await db.workOrderStatus.update({
+        where: { id: existing.id },
+        data: { archivedAt: new Date() },
+      })
+      revalidate()
+      return existing
+    },
+    {
+      requiredPermissions: MANAGE,
+      audit: ({ result }) => ({
+        action: 'settings.workOrderStatus.archive',
+        entity: 'WorkOrderStatus',
+        entityId: result.id,
+        message: `Removed the work order status "${result.name}"`,
+      }),
+    }
+  )
+}
+
+export async function reorderWorkOrderStatuses(input: unknown) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const data = reorderWorkOrderStatusesSchema.parse(input)
+      const rows = await db.workOrderStatus.findMany({
+        where: { organizationId, stage: data.stage, archivedAt: null },
+        select: { id: true },
+      })
+      const known = new Set(rows.map((row) => row.id))
+      const ordered = data.ids.filter((id) => known.has(id))
+      await db.$transaction(
+        ordered.map((id, index) =>
+          db.workOrderStatus.update({ where: { id }, data: { sortOrder: index } })
+        )
+      )
+      revalidate()
+      return { stage: data.stage, count: ordered.length }
+    },
+    { requiredPermissions: MANAGE }
+  )
+}

+ 531 - 0
src/features/work-order-statuses/Components/WorkOrderStatusSettings.tsx

@@ -0,0 +1,531 @@
+'use client'
+
+import { useState } from 'react'
+import { useRouter } from 'next/navigation'
+import { useTranslations } from 'next-intl'
+import { toast } from 'sonner'
+import {
+  ArrowDown,
+  ArrowUp,
+  BellRing,
+  ListChecks,
+  Loader2,
+  Pencil,
+  Plus,
+  Trash2,
+} from 'lucide-react'
+import { AppCard } from '@/components/app-card'
+import { useConfirm } from '@/components/confirm-dialog'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from '@/components/ui/dialog'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from '@/components/ui/select'
+import { Switch } from '@/components/ui/switch'
+import { Textarea } from '@/components/ui/textarea'
+import { cn } from '@/lib/utils'
+import { ReadOnlyBanner, ReadOnlyWrapper } from '@/app/(authenticated)/settings/read-only-guard'
+import { statusMessageKeys } from '@/features/vehicles/Components/service-detail/types'
+import {
+  archiveWorkOrderStatus,
+  createWorkOrderStatus,
+  reorderWorkOrderStatuses,
+  updateWorkOrderStatus,
+} from '../Actions/workOrderStatusActions'
+import {
+  STAGES,
+  STATUS_COLORS,
+  STATUS_MESSAGE_TOKENS,
+  type Stage,
+  type StatusColor,
+  type WorkOrderStatusOption,
+  statusColorClasses,
+} from '../Lib/stages'
+
+type Draft = {
+  id: string | null
+  name: string
+  stage: Stage
+  color: StatusColor
+  notifyCustomer: boolean
+  messageTemplate: string
+}
+
+/**
+ * The statuses most workshops end up wanting, one click each. The names are
+ * written in the workshop's language when they are added and are theirs to
+ * change afterwards; nothing in the app looks for these by name.
+ */
+const SUGGESTIONS: { key: string; stage: Stage; color: StatusColor; notify: boolean }[] = [
+  { key: 'readyForPickup', stage: 'completed', color: 'emerald', notify: true },
+  { key: 'waitingForCustomer', stage: 'in-progress', color: 'orange', notify: false },
+  { key: 'waitingForApproval', stage: 'in-progress', color: 'violet', notify: true },
+]
+
+/**
+ * Settings → Work order statuses. The three stages are fixed, because the rest
+ * of the app reads them; what a workshop adds here are its own names for the
+ * steps in between, each filed under the stage it belongs to. A status shows
+ * up in that stage's menu on every work order, and one that asks for it
+ * offers to tell the customer when a job reaches it.
+ */
+export function WorkOrderStatusSettings({ statuses }: { statuses: WorkOrderStatusOption[] }) {
+  const t = useTranslations('settings.workOrderStatuses')
+  const tService = useTranslations('service')
+  const router = useRouter()
+  const confirm = useConfirm()
+  const [draft, setDraft] = useState<Draft | null>(null)
+  const [saving, setSaving] = useState(false)
+  const [busyId, setBusyId] = useState<string | null>(null)
+
+  const stageLabel = (stage: Stage) =>
+    tService(`basicInfo.statusOptions.${statusMessageKeys[stage]}`)
+
+  /** What is sent when the message is left empty, naming the status as it stands. */
+  const suggestedMessage = (d: Draft) =>
+    String(tService.raw('customStatus.defaultMessage')).replace(
+      '{status}',
+      d.name.trim() || t('namePlaceholder')
+    )
+
+  const blank = (stage: Stage): Draft => ({
+    id: null,
+    name: '',
+    stage,
+    color: 'blue',
+    notifyCustomer: false,
+    messageTemplate: '',
+  })
+
+  const save = async () => {
+    if (!draft) return
+    setSaving(true)
+    const payload = {
+      name: draft.name,
+      stage: draft.stage,
+      color: draft.color,
+      notifyCustomer: draft.notifyCustomer,
+      messageTemplate: draft.notifyCustomer ? draft.messageTemplate : '',
+    }
+    const result = draft.id
+      ? await updateWorkOrderStatus({ id: draft.id, ...payload })
+      : await createWorkOrderStatus(payload)
+    setSaving(false)
+    if (!result.success) {
+      toast.error(result.error || t('failed'))
+      return
+    }
+    toast.success(t('saved'))
+    setDraft(null)
+    router.refresh()
+  }
+
+  const addSuggestion = async (suggestion: (typeof SUGGESTIONS)[number]) => {
+    setBusyId(suggestion.key)
+    const result = await createWorkOrderStatus({
+      name: t(`suggestions.${suggestion.key}.name`),
+      stage: suggestion.stage,
+      color: suggestion.color,
+      notifyCustomer: suggestion.notify,
+      // Raw: the text carries {tokens} for the message, not arguments for t().
+      messageTemplate: suggestion.notify
+        ? String(t.raw(`suggestions.${suggestion.key}.message`))
+        : '',
+    })
+    setBusyId(null)
+    if (!result.success) {
+      toast.error(result.error || t('failed'))
+      return
+    }
+    toast.success(t('saved'))
+    router.refresh()
+  }
+
+  const remove = async (status: WorkOrderStatusOption) => {
+    const ok = await confirm({
+      title: t('removeTitle', { name: status.name }),
+      description: t('removeBody'),
+      confirmLabel: t('remove'),
+      destructive: true,
+    })
+    if (!ok) return
+    setBusyId(status.id)
+    const result = await archiveWorkOrderStatus(status.id)
+    setBusyId(null)
+    if (!result.success) {
+      toast.error(result.error || t('failed'))
+      return
+    }
+    router.refresh()
+  }
+
+  const move = async (stage: Stage, index: number, by: -1 | 1) => {
+    const inStage = statuses.filter((s) => s.stage === stage)
+    const target = index + by
+    if (target < 0 || target >= inStage.length) return
+    const ids = inStage.map((s) => s.id)
+    ;[ids[index], ids[target]] = [ids[target], ids[index]]
+    setBusyId(ids[target])
+    const result = await reorderWorkOrderStatuses({ stage, ids })
+    setBusyId(null)
+    if (!result.success) toast.error(result.error || t('failed'))
+    router.refresh()
+  }
+
+  const taken = new Set(statuses.map((s) => s.name.trim().toLowerCase()))
+  const offered = SUGGESTIONS.filter(
+    (s) => !taken.has(t(`suggestions.${s.key}.name`).trim().toLowerCase())
+  )
+
+  return (
+    <div className="space-y-6">
+      <ReadOnlyBanner />
+      <div>
+        <h2 className="text-lg font-semibold">{t('title')}</h2>
+        <p className="text-sm text-muted-foreground">{t('description')}</p>
+      </div>
+
+      <ReadOnlyWrapper>
+        <div className="space-y-6">
+          {offered.length > 0 && (
+            <div className="flex flex-wrap items-center gap-2">
+              <span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
+                {t('suggestionsLabel')}
+              </span>
+              {offered.map((suggestion) => (
+                <Button
+                  key={suggestion.key}
+                  type="button"
+                  variant="outline"
+                  size="sm"
+                  disabled={busyId !== null}
+                  onClick={() => void addSuggestion(suggestion)}
+                  className="h-8 gap-1.5"
+                >
+                  {busyId === suggestion.key ? (
+                    <Loader2 className="h-3.5 w-3.5 animate-spin" />
+                  ) : (
+                    <Plus className="h-3.5 w-3.5" />
+                  )}
+                  {t(`suggestions.${suggestion.key}.name`)}
+                </Button>
+              ))}
+            </div>
+          )}
+
+          <AppCard icon={ListChecks} title={t('cardTitle')} contentClassName="p-0">
+            <div className="divide-y divide-card-edge">
+              {STAGES.map((stage, stageIndex) => {
+                const inStage = statuses.filter((s) => s.stage === stage)
+                return (
+                  <section key={stage} className="p-5" data-testid={`status-stage-${stage}`}>
+                    <div className="mb-3 flex items-center justify-between gap-3">
+                      <div className="flex min-w-0 items-center gap-2.5">
+                        <span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-primary bg-primary text-[11px] font-semibold text-primary-foreground">
+                          {stageIndex + 1}
+                        </span>
+                        <div className="min-w-0">
+                          <h3 className="truncate text-sm font-semibold">{stageLabel(stage)}</h3>
+                          <p className="truncate text-xs text-muted-foreground">
+                            {t(`stageHints.${stage}`)}
+                          </p>
+                        </div>
+                      </div>
+                      <Button
+                        type="button"
+                        variant="outline"
+                        size="sm"
+                        className="h-8 shrink-0 gap-1.5"
+                        onClick={() => setDraft(blank(stage))}
+                      >
+                        <Plus className="h-3.5 w-3.5" />
+                        {t('add')}
+                      </Button>
+                    </div>
+
+                    <ul className="divide-y rounded-lg border">
+                      {stage === 'in-progress' && (
+                        // Older than this page, and part of the app: the work
+                        // board has a column for it and the technician app
+                        // sets it. Listed so the menu holds no surprises.
+                        <li className="flex items-center gap-3 px-3 py-2.5">
+                          <span
+                            className={cn(
+                              'h-2.5 w-2.5 shrink-0 rounded-full',
+                              statusColorClasses('amber').dot
+                            )}
+                          />
+                          <span className="min-w-0 flex-1 truncate text-sm">
+                            {tService(
+                              `basicInfo.statusOptions.${statusMessageKeys['waiting-parts']}`
+                            )}
+                          </span>
+                          <Badge variant="outline" className="shrink-0 text-[11px]">
+                            {t('builtIn')}
+                          </Badge>
+                        </li>
+                      )}
+                      {inStage.map((status, index) => (
+                        <li
+                          key={status.id}
+                          className="flex items-center gap-3 px-3 py-2"
+                          data-testid="status-row"
+                        >
+                          <span
+                            className={cn(
+                              'h-2.5 w-2.5 shrink-0 rounded-full',
+                              statusColorClasses(status.color).dot
+                            )}
+                          />
+                          <span className="min-w-0 flex-1 truncate text-sm font-medium">
+                            {status.name}
+                          </span>
+                          {status.notifyCustomer && (
+                            <span
+                              className="hidden shrink-0 items-center gap-1 text-xs text-muted-foreground sm:flex"
+                              title={t('notifyHint')}
+                            >
+                              <BellRing className="h-3.5 w-3.5" />
+                              {t('asksToNotify')}
+                            </span>
+                          )}
+                          <div className="flex shrink-0 items-center">
+                            <Button
+                              type="button"
+                              variant="ghost"
+                              size="icon-sm"
+                              aria-label={t('moveUp')}
+                              title={t('moveUp')}
+                              disabled={index === 0 || busyId !== null}
+                              onClick={() => void move(stage, index, -1)}
+                            >
+                              <ArrowUp className="h-3.5 w-3.5" />
+                            </Button>
+                            <Button
+                              type="button"
+                              variant="ghost"
+                              size="icon-sm"
+                              aria-label={t('moveDown')}
+                              title={t('moveDown')}
+                              disabled={index === inStage.length - 1 || busyId !== null}
+                              onClick={() => void move(stage, index, 1)}
+                            >
+                              <ArrowDown className="h-3.5 w-3.5" />
+                            </Button>
+                            <Button
+                              type="button"
+                              variant="ghost"
+                              size="icon-sm"
+                              aria-label={t('edit')}
+                              title={t('edit')}
+                              onClick={() =>
+                                setDraft({
+                                  id: status.id,
+                                  name: status.name,
+                                  stage: status.stage,
+                                  color: (status.color as StatusColor) ?? 'slate',
+                                  notifyCustomer: status.notifyCustomer,
+                                  messageTemplate: status.messageTemplate ?? '',
+                                })
+                              }
+                            >
+                              <Pencil className="h-3.5 w-3.5" />
+                            </Button>
+                            <Button
+                              type="button"
+                              variant="ghost"
+                              size="icon-sm"
+                              aria-label={t('remove')}
+                              title={t('remove')}
+                              disabled={busyId !== null}
+                              onClick={() => void remove(status)}
+                              className="hover:text-destructive"
+                            >
+                              {busyId === status.id ? (
+                                <Loader2 className="h-3.5 w-3.5 animate-spin" />
+                              ) : (
+                                <Trash2 className="h-3.5 w-3.5" />
+                              )}
+                            </Button>
+                          </div>
+                        </li>
+                      ))}
+                      {inStage.length === 0 && stage !== 'in-progress' && (
+                        <li className="px-3 py-3 text-sm text-muted-foreground">{t('empty')}</li>
+                      )}
+                    </ul>
+                  </section>
+                )
+              })}
+            </div>
+          </AppCard>
+        </div>
+      </ReadOnlyWrapper>
+
+      <Dialog open={draft !== null} onOpenChange={(open) => !open && setDraft(null)}>
+        <DialogContent className="sm:max-w-lg">
+          <DialogHeader>
+            <DialogTitle>{draft?.id ? t('editTitle') : t('addTitle')}</DialogTitle>
+            <DialogDescription>{t('dialogBody')}</DialogDescription>
+          </DialogHeader>
+
+          {draft && (
+            <div className="space-y-4">
+              <div className="grid gap-4 sm:grid-cols-2">
+                <div className="space-y-2">
+                  <Label htmlFor="statusName">{t('name')}</Label>
+                  <Input
+                    id="statusName"
+                    value={draft.name}
+                    maxLength={40}
+                    autoFocus
+                    onChange={(e) => setDraft({ ...draft, name: e.target.value })}
+                    placeholder={t('namePlaceholder')}
+                  />
+                </div>
+                <div className="space-y-2">
+                  <Label htmlFor="statusStage">{t('stage')}</Label>
+                  <Select
+                    value={draft.stage}
+                    onValueChange={(value) => setDraft({ ...draft, stage: value as Stage })}
+                  >
+                    <SelectTrigger id="statusStage" className="w-full">
+                      <SelectValue />
+                    </SelectTrigger>
+                    <SelectContent>
+                      {STAGES.map((stage) => (
+                        <SelectItem key={stage} value={stage}>
+                          {stageLabel(stage)}
+                        </SelectItem>
+                      ))}
+                    </SelectContent>
+                  </Select>
+                </div>
+              </div>
+              {/* What choosing a stage actually decides, said where it is chosen. */}
+              <p className="-mt-2 text-xs text-muted-foreground">
+                {t(`stageHints.${draft.stage}`)}
+              </p>
+
+              <div className="space-y-2">
+                <Label>{t('color')}</Label>
+                <div className="flex flex-wrap gap-2">
+                  {STATUS_COLORS.map((color) => (
+                    <button
+                      key={color}
+                      type="button"
+                      aria-label={color}
+                      aria-pressed={draft.color === color}
+                      onClick={() => setDraft({ ...draft, color })}
+                      className={cn(
+                        'h-7 w-7 cursor-pointer rounded-full outline-none ring-offset-2 ring-offset-background transition-shadow focus-visible:ring-2 focus-visible:ring-ring',
+                        statusColorClasses(color).dot,
+                        draft.color === color && 'ring-2 ring-foreground'
+                      )}
+                    />
+                  ))}
+                </div>
+                {draft.name.trim() && (
+                  <span
+                    className={cn(
+                      'inline-block rounded-full border px-2 py-0.5 text-[12px] font-medium',
+                      statusColorClasses(draft.color).chip
+                    )}
+                  >
+                    {draft.name.trim()}
+                  </span>
+                )}
+              </div>
+
+              <div className="space-y-3 rounded-lg border p-3">
+                <div className="flex items-center justify-between gap-4">
+                  <div className="space-y-0.5">
+                    <Label htmlFor="statusNotify">{t('notify')}</Label>
+                    <p className="text-xs text-muted-foreground">{t('notifyHint')}</p>
+                  </div>
+                  <Switch
+                    id="statusNotify"
+                    checked={draft.notifyCustomer}
+                    onCheckedChange={(on) => setDraft({ ...draft, notifyCustomer: on })}
+                  />
+                </div>
+                {draft.notifyCustomer && (
+                  <div className="space-y-2">
+                    <Label htmlFor="statusMessage">{t('message')}</Label>
+                    <Textarea
+                      id="statusMessage"
+                      rows={3}
+                      maxLength={600}
+                      value={draft.messageTemplate}
+                      onChange={(e) => setDraft({ ...draft, messageTemplate: e.target.value })}
+                      placeholder={suggestedMessage(draft)}
+                    />
+                    {/* Empty is an answer, not an omission: the suggested text
+                        is sent, with whatever the status is called that day.
+                        Said here, because a placeholder looks like nothing. */}
+                    {draft.messageTemplate.trim() === '' && (
+                      <p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
+                        <span>{t('messageEmptyHint')}</span>
+                        <button
+                          type="button"
+                          onClick={() =>
+                            setDraft({ ...draft, messageTemplate: suggestedMessage(draft) })
+                          }
+                          className="cursor-pointer font-medium text-foreground underline-offset-2 hover:underline"
+                        >
+                          {t('useSuggested')}
+                        </button>
+                      </p>
+                    )}
+                    <div className="flex flex-wrap items-center gap-1.5">
+                      <span className="text-xs text-muted-foreground">{t('tokens')}</span>
+                      {STATUS_MESSAGE_TOKENS.map((token) => (
+                        <button
+                          key={token}
+                          type="button"
+                          onClick={() =>
+                            setDraft({
+                              ...draft,
+                              messageTemplate: `${draft.messageTemplate}{${token}}`,
+                            })
+                          }
+                          className="cursor-pointer rounded border bg-muted/50 px-1.5 py-0.5 font-mono text-[11px] hover:bg-muted"
+                        >
+                          {`{${token}}`}
+                        </button>
+                      ))}
+                    </div>
+                  </div>
+                )}
+              </div>
+            </div>
+          )}
+
+          <DialogFooter>
+            <Button variant="outline" onClick={() => setDraft(null)} disabled={saving}>
+              {t('cancel')}
+            </Button>
+            <Button onClick={save} disabled={saving || !draft?.name.trim()}>
+              {saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+              {t('save')}
+            </Button>
+          </DialogFooter>
+        </DialogContent>
+      </Dialog>
+    </div>
+  )
+}

+ 136 - 0
src/features/work-order-statuses/Lib/stages.ts

@@ -0,0 +1,136 @@
+/**
+ * The three stages a work order moves through, and the workshop's own
+ * statuses filed under them.
+ *
+ * The stages are fixed because everything else in the app reads them: what
+ * counts as open, what the customer's portal shows, when a technician's clock
+ * stops, which column a card sits in. A workshop's own statuses ("Ready for
+ * pickup", "Waiting for approval") are labels under a stage. They carry a
+ * colour and a follow-up and decide nothing, which is what lets a workshop
+ * invent as many as it likes without any of those screens having to know.
+ *
+ * "Waiting for parts" is older than this and is one of the four values the
+ * record's `status` stores, since the work board has a column for it and the
+ * technician app sets it. It is drawn with the workshop's own statuses, under
+ * "in progress", because that is what it is to the person choosing.
+ */
+
+export const STAGES = ['pending', 'in-progress', 'completed'] as const
+export type Stage = (typeof STAGES)[number]
+
+/** The stored statuses, which are the stages plus the one built-in hold. */
+export const SYSTEM_STATUSES = ['pending', 'in-progress', 'waiting-parts', 'completed'] as const
+export type SystemStatus = (typeof SYSTEM_STATUSES)[number]
+
+export const WAITING_PARTS = 'waiting-parts'
+
+export function isStage(value: unknown): value is Stage {
+  return typeof value === 'string' && (STAGES as readonly string[]).includes(value)
+}
+
+export function isSystemStatus(value: unknown): value is SystemStatus {
+  return typeof value === 'string' && (SYSTEM_STATUSES as readonly string[]).includes(value)
+}
+
+/** Which stage a stored status is drawn at. Unknown values read as not started. */
+export function stageOf(status: string): Stage {
+  if (status === WAITING_PARTS) return 'in-progress'
+  return isStage(status) ? status : 'pending'
+}
+
+/**
+ * Colours a status can have: names from the app's palette rather than hex
+ * values, so a status reads correctly in the dark theme and nobody can pick
+ * grey on grey.
+ */
+export const STATUS_COLORS = [
+  'slate',
+  'blue',
+  'sky',
+  'teal',
+  'emerald',
+  'amber',
+  'orange',
+  'rose',
+  'violet',
+] as const
+export type StatusColor = (typeof STATUS_COLORS)[number]
+
+/** Written out in full so Tailwind sees every class. */
+export const STATUS_COLOR_CLASSES: Record<StatusColor, { chip: string; dot: string }> = {
+  slate: {
+    chip: 'border-slate-500/30 bg-slate-500/10 text-slate-700 dark:text-slate-300',
+    dot: 'bg-slate-500',
+  },
+  blue: {
+    chip: 'border-blue-500/30 bg-blue-500/10 text-blue-700 dark:text-blue-300',
+    dot: 'bg-blue-500',
+  },
+  sky: {
+    chip: 'border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-300',
+    dot: 'bg-sky-500',
+  },
+  teal: {
+    chip: 'border-teal-500/30 bg-teal-500/10 text-teal-700 dark:text-teal-300',
+    dot: 'bg-teal-500',
+  },
+  emerald: {
+    chip: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300',
+    dot: 'bg-emerald-500',
+  },
+  amber: {
+    chip: 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300',
+    dot: 'bg-amber-500',
+  },
+  orange: {
+    chip: 'border-orange-500/30 bg-orange-500/10 text-orange-700 dark:text-orange-300',
+    dot: 'bg-orange-500',
+  },
+  rose: {
+    chip: 'border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-300',
+    dot: 'bg-rose-500',
+  },
+  violet: {
+    chip: 'border-violet-500/30 bg-violet-500/10 text-violet-700 dark:text-violet-300',
+    dot: 'bg-violet-500',
+  },
+}
+
+export function statusColorClasses(color: string | null | undefined) {
+  return STATUS_COLOR_CLASSES[(color as StatusColor) ?? 'slate'] ?? STATUS_COLOR_CLASSES.slate
+}
+
+/** What a screen needs to draw one of the workshop's statuses. */
+export interface WorkOrderStatusOption {
+  id: string
+  name: string
+  stage: Stage
+  color: string
+  notifyCustomer: boolean
+  messageTemplate: string | null
+}
+
+/** The tokens a status message may use, the ones the other status texts already take. */
+export const STATUS_MESSAGE_TOKENS = [
+  'customer_name',
+  'vehicle',
+  'company_name',
+  'current_user',
+] as const
+
+/**
+ * What the record's status columns become when a job moves. One place, so
+ * every way a job moves (the page, the list, the board, the technician's
+ * phone, the form's save) clears a status that belonged to the stage it left.
+ */
+export function statusColumns(
+  status: string,
+  custom: { id: string } | null = null,
+  now = new Date()
+): { status: string; customStatusId: string | null; customStatusSince: Date | null } {
+  return {
+    status,
+    customStatusId: custom?.id ?? null,
+    customStatusSince: custom ? now : null,
+  }
+}

+ 22 - 0
src/features/work-order-statuses/Schema/workOrderStatusSchema.ts

@@ -0,0 +1,22 @@
+import { z } from 'zod'
+import { STAGES, STATUS_COLORS } from '../Lib/stages'
+
+export const workOrderStatusSchema = z.object({
+  name: z.string().trim().min(1, 'Give the status a name').max(40),
+  stage: z.enum(STAGES),
+  color: z.enum(STATUS_COLORS).default('slate'),
+  notifyCustomer: z.boolean().default(false),
+  messageTemplate: z.string().trim().max(600).optional().or(z.literal('')),
+})
+
+export const updateWorkOrderStatusSchema = workOrderStatusSchema.extend({
+  id: z.string().min(1),
+})
+
+export const reorderWorkOrderStatusesSchema = z.object({
+  stage: z.enum(STAGES),
+  /** Every status of the stage, in the order they should be listed. */
+  ids: z.array(z.string().min(1)).max(50),
+})
+
+export type WorkOrderStatusInput = z.infer<typeof workOrderStatusSchema>

+ 9 - 0
src/lib/backup/manifest.ts

@@ -248,6 +248,15 @@ export const BACKUP_ENTITIES: readonly BackupEntity[] = [
     restore: 'replace',
     restore: 'replace',
     clearOrder: 87,
     clearOrder: 87,
   },
   },
+  // The workshop's own work order statuses. Jobs point at them and let go when
+  // one is removed (SetNull), so they are cleared after the jobs.
+  {
+    model: 'WorkOrderStatus',
+    key: 'workOrderStatuses',
+    option: 'workshopConfig',
+    restore: 'replace',
+    clearOrder: 88,
+  },
   // Members keep pointing at their role, so a restore fills gaps rather than
   // Members keep pointing at their role, so a restore fills gaps rather than
   // clearing the table.
   // clearing the table.
   { model: 'Role', key: 'roles', option: 'workshopConfig', restore: 'merge' },
   { model: 'Role', key: 'roles', option: 'workshopConfig', restore: 'merge' },