فهرست منبع

Say when a change could not be saved instead of leaving the screen wrong, stuck or on the error page (#577)

* Say when a change could not be saved instead of leaving the screen wrong, stuck or on the error page

A server action that cannot reach the server throws instead of answering.
Across about fifty handlers this left a spinner on until reload, replaced
the page with the error page when it happened inside a transition, kept an
optimistic change the server never received, or reported success after a
refusal. Each now shows the screen's own error, puts back what it changed
and frees the button. The work board and condition map settle overlapping
changes through one shared hook.

* Read back what the server holds instead of trusting a refresh, and keep observations safe

A board or condition map read that a newer change overtook is no longer put
on screen, even when that change has already answered; it is read again.
The condition map reads its marks through an action it waits for, instead
of a refresh that answered before the marks arrived. The Telegram inbox
clears the previous customer's thread before loading the next, so a failed
load cannot leave a composer that replies to the wrong customer. Adding
observations to a work order puts the lines on first again, so one whose
removal went through unanswered is never lost; a definite refusal takes its
line back, and an unanswered removal is reported as possibly still open.
Bernt Christian Egeland 1 روز پیش
والد
کامیت
835fcb2545
100فایلهای تغییر یافته به همراه3070 افزوده شده و 66 حذف شده
  1. 1 0
      messages/de/aiChat.json
  2. 2 1
      messages/de/dashboard.json
  3. 1 0
      messages/de/onboarding.json
  4. 1 0
      messages/de/timeTracking.json
  5. 5 0
      messages/de/vehicles.json
  6. 3 1
      messages/de/workBoard.json
  7. 1 0
      messages/en/aiChat.json
  8. 2 1
      messages/en/dashboard.json
  9. 1 0
      messages/en/onboarding.json
  10. 1 0
      messages/en/timeTracking.json
  11. 5 0
      messages/en/vehicles.json
  12. 3 1
      messages/en/workBoard.json
  13. 1 0
      messages/es/aiChat.json
  14. 2 1
      messages/es/dashboard.json
  15. 1 0
      messages/es/onboarding.json
  16. 1 0
      messages/es/timeTracking.json
  17. 5 0
      messages/es/vehicles.json
  18. 3 1
      messages/es/workBoard.json
  19. 1 0
      messages/fr/aiChat.json
  20. 2 1
      messages/fr/dashboard.json
  21. 1 0
      messages/fr/onboarding.json
  22. 1 0
      messages/fr/timeTracking.json
  23. 5 0
      messages/fr/vehicles.json
  24. 3 1
      messages/fr/workBoard.json
  25. 1 0
      messages/it/aiChat.json
  26. 2 1
      messages/it/dashboard.json
  27. 1 0
      messages/it/onboarding.json
  28. 1 0
      messages/it/timeTracking.json
  29. 5 0
      messages/it/vehicles.json
  30. 3 1
      messages/it/workBoard.json
  31. 1 0
      messages/lt/aiChat.json
  32. 2 1
      messages/lt/dashboard.json
  33. 1 0
      messages/lt/onboarding.json
  34. 1 0
      messages/lt/timeTracking.json
  35. 5 0
      messages/lt/vehicles.json
  36. 3 1
      messages/lt/workBoard.json
  37. 1 0
      messages/nb/aiChat.json
  38. 2 1
      messages/nb/dashboard.json
  39. 1 0
      messages/nb/onboarding.json
  40. 1 0
      messages/nb/timeTracking.json
  41. 5 0
      messages/nb/vehicles.json
  42. 3 1
      messages/nb/workBoard.json
  43. 1 0
      messages/nl/aiChat.json
  44. 2 1
      messages/nl/dashboard.json
  45. 1 0
      messages/nl/onboarding.json
  46. 1 0
      messages/nl/timeTracking.json
  47. 5 0
      messages/nl/vehicles.json
  48. 3 1
      messages/nl/workBoard.json
  49. 1 0
      messages/pl/aiChat.json
  50. 2 1
      messages/pl/dashboard.json
  51. 1 0
      messages/pl/onboarding.json
  52. 1 0
      messages/pl/timeTracking.json
  53. 5 0
      messages/pl/vehicles.json
  54. 3 1
      messages/pl/workBoard.json
  55. 1 0
      messages/pt-BR/aiChat.json
  56. 2 1
      messages/pt-BR/dashboard.json
  57. 1 0
      messages/pt-BR/onboarding.json
  58. 1 0
      messages/pt-BR/timeTracking.json
  59. 5 0
      messages/pt-BR/vehicles.json
  60. 3 1
      messages/pt-BR/workBoard.json
  61. 1 0
      messages/pt-PT/aiChat.json
  62. 2 1
      messages/pt-PT/dashboard.json
  63. 1 0
      messages/pt-PT/onboarding.json
  64. 1 0
      messages/pt-PT/timeTracking.json
  65. 5 0
      messages/pt-PT/vehicles.json
  66. 3 1
      messages/pt-PT/workBoard.json
  67. 1 0
      messages/ru/aiChat.json
  68. 2 1
      messages/ru/dashboard.json
  69. 1 0
      messages/ru/onboarding.json
  70. 1 0
      messages/ru/timeTracking.json
  71. 5 0
      messages/ru/vehicles.json
  72. 3 1
      messages/ru/workBoard.json
  73. 1 0
      messages/tr/aiChat.json
  74. 2 1
      messages/tr/dashboard.json
  75. 1 0
      messages/tr/onboarding.json
  76. 1 0
      messages/tr/timeTracking.json
  77. 5 0
      messages/tr/vehicles.json
  78. 3 1
      messages/tr/workBoard.json
  79. 128 0
      src/__tests__/app/settings/team-revoke-app-failure.test.tsx
  80. 128 0
      src/__tests__/app/settings/webhooks-dropped-connection.test.tsx
  81. 267 0
      src/__tests__/app/vehicles/vehicle-detail-dropped-connection.test.tsx
  82. 86 0
      src/__tests__/features/ai/ask-ai-sheet-delete-chat.test.tsx
  83. 216 0
      src/__tests__/features/condition-map/mark-change-failure.test.tsx
  84. 134 0
      src/__tests__/features/inspections/template-list-dropped-connection.test.tsx
  85. 155 0
      src/__tests__/features/onboarding/onboarding-cards-dropped-connection.test.tsx
  86. 156 0
      src/__tests__/features/sms/sms-conversation-dropped-connection.test.tsx
  87. 67 0
      src/__tests__/features/team/app-setup-code-quiet-revoke.test.tsx
  88. 111 0
      src/__tests__/features/telegram/telegram-thread-switch.test.tsx
  89. 307 0
      src/__tests__/features/vehicles/add-observations-to-work-order.test.tsx
  90. 93 0
      src/__tests__/features/vehicles/archive-vehicle-dropped-connection.test.tsx
  91. 153 0
      src/__tests__/features/vehicles/media-grid-change-failure.test.tsx
  92. 159 0
      src/__tests__/features/vehicles/work-order-payments-dropped-connection.test.ts
  93. 116 0
      src/__tests__/features/whatsapp/whatsapp-conversation-dropped-connection.test.tsx
  94. 340 0
      src/__tests__/features/workboard/board-change-failure.test.tsx
  95. 109 0
      src/__tests__/features/workboard/technician-dialog-failure.test.tsx
  96. 113 0
      src/__tests__/lib/use-overlapping-saves.test.ts
  97. 5 2
      src/app/(authenticated)/dashboard-client.tsx
  98. 17 13
      src/app/(authenticated)/settings/report-schedule/report-schedule-settings.tsx
  99. 14 4
      src/app/(authenticated)/settings/team/team-settings.tsx
  100. 27 21
      src/app/(authenticated)/settings/webhooks/webhooks-settings.tsx

+ 1 - 0
messages/de/aiChat.json

@@ -12,6 +12,7 @@
   "noChats": "Keine früheren Chats zu diesem Datensatz",
   "noChats": "Keine früheren Chats zu diesem Datensatz",
   "chatsPrivate": "Ihre Chats sind privat und nur für Sie sichtbar.",
   "chatsPrivate": "Ihre Chats sind privat und nur für Sie sichtbar.",
   "deleteChat": "Chat löschen",
   "deleteChat": "Chat löschen",
+  "deleteChatFailed": "Chat konnte nicht gelöscht werden. Bitte versuchen Sie es erneut.",
   "error": "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut.",
   "error": "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut.",
   "disclaimer": "Die Antworten werden aus den in der App gezeigten Daten erzeugt und können falsch sein. Prüfen Sie sie, bevor Sie danach handeln.",
   "disclaimer": "Die Antworten werden aus den in der App gezeigten Daten erzeugt und können falsch sein. Prüfen Sie sie, bevor Sie danach handeln.",
   "vehicleSuggestion1": "Servicehistorie zusammenfassen",
   "vehicleSuggestion1": "Servicehistorie zusammenfassen",

+ 2 - 1
messages/de/dashboard.json

@@ -75,7 +75,8 @@
     "dismissedOn": "Verworfen am {date}",
     "dismissedOn": "Verworfen am {date}",
     "restore": "Wiederherstellen",
     "restore": "Wiederherstellen",
     "settingsAriaLabel": "Wartungseinstellungen",
     "settingsAriaLabel": "Wartungseinstellungen",
-    "dismissAriaLabel": "Ausblenden"
+    "dismissAriaLabel": "Ausblenden",
+    "dismissError": "Fahrzeug konnte nicht ausgeblendet werden. Bitte versuchen Sie es erneut."
   },
   },
   "reminders": {
   "reminders": {
     "title": "Anstehende Erinnerungen",
     "title": "Anstehende Erinnerungen",

+ 1 - 0
messages/de/onboarding.json

@@ -73,6 +73,7 @@
     "allDoneDescription": "Alle Schritte sind abgeschlossen und Ihre Werkstatt ist startklar.",
     "allDoneDescription": "Alle Schritte sind abgeschlossen und Ihre Werkstatt ist startklar.",
     "dismiss": "Karte ausblenden",
     "dismiss": "Karte ausblenden",
     "dismissAriaLabel": "Diese Checkliste ausblenden",
     "dismissAriaLabel": "Diese Checkliste ausblenden",
+    "dismissFailed": "Checkliste konnte nicht ausgeblendet werden. Bitte versuchen Sie es erneut.",
     "importTitle": "Haben Sie bereits Kunden und Fahrzeuge in einem anderen System?",
     "importTitle": "Haben Sie bereits Kunden und Fahrzeuge in einem anderen System?",
     "importDescription": "Importieren Sie sie aus einer Tabelle oder Ihrem bisherigen System"
     "importDescription": "Importieren Sie sie aus einer Tabelle oder Ihrem bisherigen System"
   },
   },

+ 1 - 0
messages/de/timeTracking.json

@@ -106,6 +106,7 @@
     "deleteBody": "{duration} für {job} von {technician} wird aus dem Stundenzettel entfernt. Das kann nicht rückgängig gemacht werden.",
     "deleteBody": "{duration} für {job} von {technician} wird aus dem Stundenzettel entfernt. Das kann nicht rückgängig gemacht werden.",
     "deleteConfirm": "Löschen",
     "deleteConfirm": "Löschen",
     "deleted": "Eintrag gelöscht",
     "deleted": "Eintrag gelöscht",
+    "deleteFailed": "Eintrag konnte nicht gelöscht werden",
     "stoppedByManager": "Uhr bei {minutes} gestoppt",
     "stoppedByManager": "Uhr bei {minutes} gestoppt",
     "csv": {
     "csv": {
       "technician": "Techniker",
       "technician": "Techniker",

+ 5 - 0
messages/de/vehicles.json

@@ -97,6 +97,7 @@
     "severityRare": "Selten — vereinzelte Berichte, aber kritisch wenn es auftritt",
     "severityRare": "Selten — vereinzelte Berichte, aber kritisch wenn es auftritt",
     "openMenu": "Menü öffnen",
     "openMenu": "Menü öffnen",
     "dismissMaintenance": "Ausblenden",
     "dismissMaintenance": "Ausblenden",
+    "maintenanceChangeError": "Änderung konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.",
     "inspectionDue": "HU fällig {date}",
     "inspectionDue": "HU fällig {date}",
     "inspectionCheckedAt": "Mit dem Register abgeglichen am {date}",
     "inspectionCheckedAt": "Mit dem Register abgeglichen am {date}",
     "notRegistered": "Nicht zugelassen",
     "notRegistered": "Nicht zugelassen",
@@ -256,6 +257,10 @@
     "addToWorkOrder": "Zum Auftrag hinzufügen ({count})",
     "addToWorkOrder": "Zum Auftrag hinzufügen ({count})",
     "dismiss": "Ausblenden",
     "dismiss": "Ausblenden",
     "observationsAdded": "{count} Beobachtungen zum Auftrag hinzugefügt",
     "observationsAdded": "{count} Beobachtungen zum Auftrag hinzugefügt",
+    "observationsAddFailed": "{count, plural, one {# Beobachtung konnte} other {# Beobachtungen konnten}} nicht zum Auftrag hinzugefügt werden. Bitte versuchen Sie es erneut.",
+    "observationsMayRemain": "Zum Auftrag hinzugefügt, aber die Verbindung wurde unterbrochen. {count, plural, one {# Beobachtung ist} other {# Beobachtungen sind}} möglicherweise noch am Fahrzeug offen.",
+    "observationsNotRemoved": "Der Arbeitsauftrag wurde erstellt, aber die Beobachtungen konnten nicht vom Fahrzeug entfernt werden.",
+    "workOrderFailed": "Arbeitsauftrag konnte nicht erstellt werden. Bitte versuchen Sie es erneut.",
     "delete": "Löschen",
     "delete": "Löschen",
     "findingCreated": "Beobachtung hinzugefügt",
     "findingCreated": "Beobachtung hinzugefügt",
     "findingUpdated": "Beobachtung aktualisiert",
     "findingUpdated": "Beobachtung aktualisiert",

+ 3 - 1
messages/de/workBoard.json

@@ -81,7 +81,9 @@
     "linkedManagedOnTeam": "Wird auf der Team-Seite verwaltet, zusammen mit dem App-Zugang.",
     "linkedManagedOnTeam": "Wird auf der Team-Seite verwaltet, zusammen mit dem App-Zugang.",
     "skills": "Fähigkeiten",
     "skills": "Fähigkeiten",
     "skillsPlaceholder": "Diagnose, HV-qualifiziert",
     "skillsPlaceholder": "Diagnose, HV-qualifiziert",
-    "skillsHint": "Wird unter dem Namen angezeigt, wo Arbeit verteilt wird. Optional."
+    "skillsHint": "Wird unter dem Namen angezeigt, wo Arbeit verteilt wird. Optional.",
+    "saveFailed": "Techniker konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.",
+    "deleteFailed": "Techniker konnte nicht gelöscht werden. Bitte versuchen Sie es erneut."
   },
   },
   "jobDetail": {
   "jobDetail": {
     "viewDetails": "Details anzeigen",
     "viewDetails": "Details anzeigen",

+ 1 - 0
messages/en/aiChat.json

@@ -12,6 +12,7 @@
   "noChats": "No previous chats about this record",
   "noChats": "No previous chats about this record",
   "chatsPrivate": "Your chats are private and only visible to you.",
   "chatsPrivate": "Your chats are private and only visible to you.",
   "deleteChat": "Delete chat",
   "deleteChat": "Delete chat",
+  "deleteChatFailed": "Couldn't delete the chat. Try again.",
   "error": "Something went wrong. Please try again.",
   "error": "Something went wrong. Please try again.",
   "disclaimer": "Answers are generated from the records shown in the app and can be wrong. Check before you act on them.",
   "disclaimer": "Answers are generated from the records shown in the app and can be wrong. Check before you act on them.",
   "vehicleSuggestion1": "Summarize the service history",
   "vehicleSuggestion1": "Summarize the service history",

+ 2 - 1
messages/en/dashboard.json

@@ -75,7 +75,8 @@
     "dismissedOn": "Dismissed {date}",
     "dismissedOn": "Dismissed {date}",
     "restore": "Restore",
     "restore": "Restore",
     "settingsAriaLabel": "Maintenance settings",
     "settingsAriaLabel": "Maintenance settings",
-    "dismissAriaLabel": "Dismiss"
+    "dismissAriaLabel": "Dismiss",
+    "dismissError": "Couldn't dismiss the vehicle. Try again."
   },
   },
   "reminders": {
   "reminders": {
     "title": "Upcoming Reminders",
     "title": "Upcoming Reminders",

+ 1 - 0
messages/en/onboarding.json

@@ -73,6 +73,7 @@
     "allDoneDescription": "All steps are done and your workshop is up and running.",
     "allDoneDescription": "All steps are done and your workshop is up and running.",
     "dismiss": "Hide card",
     "dismiss": "Hide card",
     "dismissAriaLabel": "Hide this checklist",
     "dismissAriaLabel": "Hide this checklist",
+    "dismissFailed": "Couldn't hide the checklist. Try again.",
     "importTitle": "Already have customers and vehicles somewhere?",
     "importTitle": "Already have customers and vehicles somewhere?",
     "importDescription": "Import them from a spreadsheet or your old system"
     "importDescription": "Import them from a spreadsheet or your old system"
   },
   },

+ 1 - 0
messages/en/timeTracking.json

@@ -106,6 +106,7 @@
     "deleteBody": "{duration} on {job} by {technician} will be removed from the timesheet. This cannot be undone.",
     "deleteBody": "{duration} on {job} by {technician} will be removed from the timesheet. This cannot be undone.",
     "deleteConfirm": "Delete",
     "deleteConfirm": "Delete",
     "deleted": "Entry deleted",
     "deleted": "Entry deleted",
+    "deleteFailed": "Could not delete the entry",
     "stoppedByManager": "Clock stopped at {minutes}",
     "stoppedByManager": "Clock stopped at {minutes}",
     "csv": {
     "csv": {
       "technician": "Technician",
       "technician": "Technician",

+ 5 - 0
messages/en/vehicles.json

@@ -97,6 +97,7 @@
     "severityRare": "Rare — isolated reports but critical when it occurs",
     "severityRare": "Rare — isolated reports but critical when it occurs",
     "openMenu": "Open menu",
     "openMenu": "Open menu",
     "dismissMaintenance": "Dismiss",
     "dismissMaintenance": "Dismiss",
+    "maintenanceChangeError": "Couldn't save the change. Try again.",
     "inspectionDue": "Inspection due {date}",
     "inspectionDue": "Inspection due {date}",
     "inspectionCheckedAt": "Checked against the registry {date}",
     "inspectionCheckedAt": "Checked against the registry {date}",
     "notRegistered": "Not registered",
     "notRegistered": "Not registered",
@@ -256,6 +257,10 @@
     "addToWorkOrder": "Add to Work Order ({count})",
     "addToWorkOrder": "Add to Work Order ({count})",
     "dismiss": "Dismiss",
     "dismiss": "Dismiss",
     "observationsAdded": "{count} observations added to work order",
     "observationsAdded": "{count} observations added to work order",
+    "observationsAddFailed": "Couldn't add {count, plural, one {# observation} other {# observations}} to the work order. Try again.",
+    "observationsMayRemain": "Added to the work order, but the connection dropped. {count, plural, one {# observation} other {# observations}} may still be open on the vehicle.",
+    "observationsNotRemoved": "The work order was created, but the observations could not be removed from the vehicle.",
+    "workOrderFailed": "Couldn't create the work order. Try again.",
     "delete": "Delete",
     "delete": "Delete",
     "findingCreated": "Observation added",
     "findingCreated": "Observation added",
     "findingUpdated": "Observation updated",
     "findingUpdated": "Observation updated",

+ 3 - 1
messages/en/workBoard.json

@@ -81,7 +81,9 @@
     "linkedManagedOnTeam": "Managed on the Team page, along with their app access.",
     "linkedManagedOnTeam": "Managed on the Team page, along with their app access.",
     "skills": "Skills",
     "skills": "Skills",
     "skillsPlaceholder": "Diagnostics, EV certified",
     "skillsPlaceholder": "Diagnostics, EV certified",
-    "skillsHint": "Shown under the name where work is handed out. Optional."
+    "skillsHint": "Shown under the name where work is handed out. Optional.",
+    "saveFailed": "Couldn't save the technician. Try again.",
+    "deleteFailed": "Couldn't delete the technician. Try again."
   },
   },
   "jobDetail": {
   "jobDetail": {
     "viewDetails": "View Details",
     "viewDetails": "View Details",

+ 1 - 0
messages/es/aiChat.json

@@ -12,6 +12,7 @@
   "noChats": "No hay chats anteriores sobre este registro",
   "noChats": "No hay chats anteriores sobre este registro",
   "chatsPrivate": "Tus chats son privados y solo tú puedes verlos.",
   "chatsPrivate": "Tus chats son privados y solo tú puedes verlos.",
   "deleteChat": "Eliminar chat",
   "deleteChat": "Eliminar chat",
+  "deleteChatFailed": "No se pudo eliminar el chat. Inténtalo de nuevo.",
   "error": "Algo salió mal. Inténtalo de nuevo.",
   "error": "Algo salió mal. Inténtalo de nuevo.",
   "disclaimer": "Las respuestas se generan a partir de los registros que muestra la aplicación y pueden ser incorrectas. Compruébalas antes de actuar.",
   "disclaimer": "Las respuestas se generan a partir de los registros que muestra la aplicación y pueden ser incorrectas. Compruébalas antes de actuar.",
   "vehicleSuggestion1": "Resume el historial de servicio",
   "vehicleSuggestion1": "Resume el historial de servicio",

+ 2 - 1
messages/es/dashboard.json

@@ -75,7 +75,8 @@
     "dismissedOn": "Descartado el {date}",
     "dismissedOn": "Descartado el {date}",
     "restore": "Restaurar",
     "restore": "Restaurar",
     "settingsAriaLabel": "Ajustes de mantenimiento",
     "settingsAriaLabel": "Ajustes de mantenimiento",
-    "dismissAriaLabel": "Descartar"
+    "dismissAriaLabel": "Descartar",
+    "dismissError": "No se pudo descartar el vehículo. Inténtelo de nuevo."
   },
   },
   "reminders": {
   "reminders": {
     "title": "Recordatorios pendientes",
     "title": "Recordatorios pendientes",

+ 1 - 0
messages/es/onboarding.json

@@ -73,6 +73,7 @@
     "allDoneDescription": "Todos los pasos están completados y su taller ya está en marcha.",
     "allDoneDescription": "Todos los pasos están completados y su taller ya está en marcha.",
     "dismiss": "Ocultar tarjeta",
     "dismiss": "Ocultar tarjeta",
     "dismissAriaLabel": "Ocultar esta lista",
     "dismissAriaLabel": "Ocultar esta lista",
+    "dismissFailed": "No se pudo ocultar la lista. Inténtelo de nuevo.",
     "importTitle": "¿Ya tiene clientes y vehículos en algún otro sitio?",
     "importTitle": "¿Ya tiene clientes y vehículos en algún otro sitio?",
     "importDescription": "Impórtelos desde una hoja de cálculo o desde su sistema anterior"
     "importDescription": "Impórtelos desde una hoja de cálculo o desde su sistema anterior"
   },
   },

+ 1 - 0
messages/es/timeTracking.json

@@ -106,6 +106,7 @@
     "deleteBody": "Se eliminarán {duration} en {job} de {technician} de la hoja de horas. Esta acción no se puede deshacer.",
     "deleteBody": "Se eliminarán {duration} en {job} de {technician} de la hoja de horas. Esta acción no se puede deshacer.",
     "deleteConfirm": "Eliminar",
     "deleteConfirm": "Eliminar",
     "deleted": "Entrada eliminada",
     "deleted": "Entrada eliminada",
+    "deleteFailed": "No se pudo eliminar la entrada",
     "stoppedByManager": "Reloj detenido en {minutes}",
     "stoppedByManager": "Reloj detenido en {minutes}",
     "csv": {
     "csv": {
       "technician": "Técnico",
       "technician": "Técnico",

+ 5 - 0
messages/es/vehicles.json

@@ -97,6 +97,7 @@
     "severityRare": "Raro — reportes aislados pero crítico cuando ocurre",
     "severityRare": "Raro — reportes aislados pero crítico cuando ocurre",
     "openMenu": "Abrir menú",
     "openMenu": "Abrir menú",
     "dismissMaintenance": "Descartar",
     "dismissMaintenance": "Descartar",
+    "maintenanceChangeError": "No se pudo guardar el cambio. Inténtelo de nuevo.",
     "inspectionDue": "ITV el {date}",
     "inspectionDue": "ITV el {date}",
     "inspectionCheckedAt": "Comprobado en el registro el {date}",
     "inspectionCheckedAt": "Comprobado en el registro el {date}",
     "notRegistered": "Sin matricular",
     "notRegistered": "Sin matricular",
@@ -256,6 +257,10 @@
     "addToWorkOrder": "Agregar a la orden ({count})",
     "addToWorkOrder": "Agregar a la orden ({count})",
     "dismiss": "Descartar",
     "dismiss": "Descartar",
     "observationsAdded": "{count} observaciones agregadas a la orden",
     "observationsAdded": "{count} observaciones agregadas a la orden",
+    "observationsAddFailed": "{count, plural, one {No se pudo agregar # observación} other {No se pudieron agregar # observaciones}} a la orden de trabajo. Inténtelo de nuevo.",
+    "observationsMayRemain": "Se agregó a la orden de trabajo, pero se perdió la conexión. {count, plural, one {# observación puede seguir abierta} other {# observaciones pueden seguir abiertas}} en el vehículo.",
+    "observationsNotRemoved": "La orden de trabajo se creó, pero no se pudieron quitar las observaciones del vehículo.",
+    "workOrderFailed": "No se pudo crear la orden de trabajo. Inténtelo de nuevo.",
     "delete": "Eliminar",
     "delete": "Eliminar",
     "findingCreated": "Observación agregada",
     "findingCreated": "Observación agregada",
     "findingUpdated": "Observación actualizada",
     "findingUpdated": "Observación actualizada",

+ 3 - 1
messages/es/workBoard.json

@@ -81,7 +81,9 @@
     "linkedManagedOnTeam": "Se gestiona en la página de Equipo, junto con su acceso a la aplicación.",
     "linkedManagedOnTeam": "Se gestiona en la página de Equipo, junto con su acceso a la aplicación.",
     "skills": "Habilidades",
     "skills": "Habilidades",
     "skillsPlaceholder": "Diagnóstico, certificado en VE",
     "skillsPlaceholder": "Diagnóstico, certificado en VE",
-    "skillsHint": "Se muestra bajo el nombre donde se reparte el trabajo. Opcional."
+    "skillsHint": "Se muestra bajo el nombre donde se reparte el trabajo. Opcional.",
+    "saveFailed": "No se pudo guardar el técnico. Inténtelo de nuevo.",
+    "deleteFailed": "No se pudo eliminar el técnico. Inténtelo de nuevo."
   },
   },
   "jobDetail": {
   "jobDetail": {
     "viewDetails": "Ver Detalles",
     "viewDetails": "Ver Detalles",

+ 1 - 0
messages/fr/aiChat.json

@@ -12,6 +12,7 @@
   "noChats": "Aucune discussion précédente sur ce dossier",
   "noChats": "Aucune discussion précédente sur ce dossier",
   "chatsPrivate": "Vos discussions sont privées et visibles par vous seul.",
   "chatsPrivate": "Vos discussions sont privées et visibles par vous seul.",
   "deleteChat": "Supprimer la discussion",
   "deleteChat": "Supprimer la discussion",
+  "deleteChatFailed": "Impossible de supprimer la discussion. Veuillez réessayer.",
   "error": "Une erreur s'est produite. Veuillez réessayer.",
   "error": "Une erreur s'est produite. Veuillez réessayer.",
   "disclaimer": "Les réponses sont générées à partir des dossiers affichés dans l'application et peuvent être fausses. Vérifiez avant d'agir.",
   "disclaimer": "Les réponses sont générées à partir des dossiers affichés dans l'application et peuvent être fausses. Vérifiez avant d'agir.",
   "vehicleSuggestion1": "Résumer l'historique d'entretien",
   "vehicleSuggestion1": "Résumer l'historique d'entretien",

+ 2 - 1
messages/fr/dashboard.json

@@ -75,7 +75,8 @@
     "dismissedOn": "Ignoré le {date}",
     "dismissedOn": "Ignoré le {date}",
     "restore": "Restaurer",
     "restore": "Restaurer",
     "settingsAriaLabel": "Paramètres de maintenance",
     "settingsAriaLabel": "Paramètres de maintenance",
-    "dismissAriaLabel": "Ignorer"
+    "dismissAriaLabel": "Ignorer",
+    "dismissError": "Impossible d'ignorer le véhicule. Veuillez réessayer."
   },
   },
   "reminders": {
   "reminders": {
     "title": "Rappels à venir",
     "title": "Rappels à venir",

+ 1 - 0
messages/fr/onboarding.json

@@ -73,6 +73,7 @@
     "allDoneDescription": "Toutes les étapes sont terminées et votre atelier est opérationnel.",
     "allDoneDescription": "Toutes les étapes sont terminées et votre atelier est opérationnel.",
     "dismiss": "Masquer la carte",
     "dismiss": "Masquer la carte",
     "dismissAriaLabel": "Masquer cette liste",
     "dismissAriaLabel": "Masquer cette liste",
+    "dismissFailed": "Impossible de masquer la liste. Veuillez réessayer.",
     "importTitle": "Vous avez déjà des clients et des véhicules ailleurs ?",
     "importTitle": "Vous avez déjà des clients et des véhicules ailleurs ?",
     "importDescription": "Importez-les depuis un tableur ou votre ancien système"
     "importDescription": "Importez-les depuis un tableur ou votre ancien système"
   },
   },

+ 1 - 0
messages/fr/timeTracking.json

@@ -106,6 +106,7 @@
     "deleteBody": "{duration} sur {job} par {technician} seront retirées de la feuille de temps. Cette action est irréversible.",
     "deleteBody": "{duration} sur {job} par {technician} seront retirées de la feuille de temps. Cette action est irréversible.",
     "deleteConfirm": "Supprimer",
     "deleteConfirm": "Supprimer",
     "deleted": "Entrée supprimée",
     "deleted": "Entrée supprimée",
+    "deleteFailed": "Impossible de supprimer l'entrée",
     "stoppedByManager": "Pointage arrêté à {minutes}",
     "stoppedByManager": "Pointage arrêté à {minutes}",
     "csv": {
     "csv": {
       "technician": "Technicien",
       "technician": "Technicien",

+ 5 - 0
messages/fr/vehicles.json

@@ -97,6 +97,7 @@
     "severityRare": "Rare — signalements isolés mais critique lorsqu'il survient",
     "severityRare": "Rare — signalements isolés mais critique lorsqu'il survient",
     "openMenu": "Ouvrir le menu",
     "openMenu": "Ouvrir le menu",
     "dismissMaintenance": "Ignorer",
     "dismissMaintenance": "Ignorer",
+    "maintenanceChangeError": "Impossible d'enregistrer la modification. Veuillez réessayer.",
     "inspectionDue": "Contrôle le {date}",
     "inspectionDue": "Contrôle le {date}",
     "inspectionCheckedAt": "Vérifié auprès du registre le {date}",
     "inspectionCheckedAt": "Vérifié auprès du registre le {date}",
     "notRegistered": "Non immatriculé",
     "notRegistered": "Non immatriculé",
@@ -256,6 +257,10 @@
     "addToWorkOrder": "Ajouter à l'ordre de travail ({count})",
     "addToWorkOrder": "Ajouter à l'ordre de travail ({count})",
     "dismiss": "Ignorer",
     "dismiss": "Ignorer",
     "observationsAdded": "{count} observations ajoutées à l'ordre de travail",
     "observationsAdded": "{count} observations ajoutées à l'ordre de travail",
+    "observationsAddFailed": "Impossible d'ajouter {count, plural, one {# observation} other {# observations}} à l'ordre de travail. Veuillez réessayer.",
+    "observationsMayRemain": "Ajouté à l'ordre de travail, mais la connexion a été perdue. {count, plural, one {# observation est peut-être encore ouverte} other {# observations sont peut-être encore ouvertes}} sur le véhicule.",
+    "observationsNotRemoved": "L'ordre de travail a été créé, mais les observations n'ont pas pu être retirées du véhicule.",
+    "workOrderFailed": "Impossible de créer l'ordre de travail. Veuillez réessayer.",
     "delete": "Supprimer",
     "delete": "Supprimer",
     "findingCreated": "Observation ajoutée",
     "findingCreated": "Observation ajoutée",
     "findingUpdated": "Observation mise à jour",
     "findingUpdated": "Observation mise à jour",

+ 3 - 1
messages/fr/workBoard.json

@@ -81,7 +81,9 @@
     "linkedManagedOnTeam": "Géré depuis la page Équipe, avec son accès à l'application.",
     "linkedManagedOnTeam": "Géré depuis la page Équipe, avec son accès à l'application.",
     "skills": "Compétences",
     "skills": "Compétences",
     "skillsPlaceholder": "Diagnostic, habilité VE",
     "skillsPlaceholder": "Diagnostic, habilité VE",
-    "skillsHint": "Affiché sous le nom là où le travail est attribué. Facultatif."
+    "skillsHint": "Affiché sous le nom là où le travail est attribué. Facultatif.",
+    "saveFailed": "Impossible d'enregistrer le technicien. Veuillez réessayer.",
+    "deleteFailed": "Impossible de supprimer le technicien. Veuillez réessayer."
   },
   },
   "jobDetail": {
   "jobDetail": {
     "viewDetails": "Voir les Détails",
     "viewDetails": "Voir les Détails",

+ 1 - 0
messages/it/aiChat.json

@@ -12,6 +12,7 @@
   "noChats": "Nessuna chat precedente su questa scheda",
   "noChats": "Nessuna chat precedente su questa scheda",
   "chatsPrivate": "Le tue chat sono private e visibili solo a te.",
   "chatsPrivate": "Le tue chat sono private e visibili solo a te.",
   "deleteChat": "Elimina chat",
   "deleteChat": "Elimina chat",
+  "deleteChatFailed": "Impossibile eliminare la chat. Riprova.",
   "error": "Qualcosa è andato storto. Riprova.",
   "error": "Qualcosa è andato storto. Riprova.",
   "disclaimer": "Le risposte sono generate dai dati mostrati nell'app e possono essere errate. Verificale prima di agire.",
   "disclaimer": "Le risposte sono generate dai dati mostrati nell'app e possono essere errate. Verificale prima di agire.",
   "vehicleSuggestion1": "Riassumi la cronologia degli interventi",
   "vehicleSuggestion1": "Riassumi la cronologia degli interventi",

+ 2 - 1
messages/it/dashboard.json

@@ -75,7 +75,8 @@
     "dismissedOn": "Ignorato il {date}",
     "dismissedOn": "Ignorato il {date}",
     "restore": "Ripristina",
     "restore": "Ripristina",
     "settingsAriaLabel": "Impostazioni manutenzione",
     "settingsAriaLabel": "Impostazioni manutenzione",
-    "dismissAriaLabel": "Ignora"
+    "dismissAriaLabel": "Ignora",
+    "dismissError": "Impossibile ignorare il veicolo. Riprova."
   },
   },
   "reminders": {
   "reminders": {
     "title": "Promemoria in scadenza",
     "title": "Promemoria in scadenza",

+ 1 - 0
messages/it/onboarding.json

@@ -73,6 +73,7 @@
     "allDoneDescription": "Tutti i passaggi sono completati e la Sua officina è operativa.",
     "allDoneDescription": "Tutti i passaggi sono completati e la Sua officina è operativa.",
     "dismiss": "Nascondi scheda",
     "dismiss": "Nascondi scheda",
     "dismissAriaLabel": "Nascondi questa lista",
     "dismissAriaLabel": "Nascondi questa lista",
+    "dismissFailed": "Impossibile nascondere la lista. Riprovi.",
     "importTitle": "Ha già clienti e veicoli da qualche parte?",
     "importTitle": "Ha già clienti e veicoli da qualche parte?",
     "importDescription": "Li importi da un foglio di calcolo o dal Suo vecchio sistema"
     "importDescription": "Li importi da un foglio di calcolo o dal Suo vecchio sistema"
   },
   },

+ 1 - 0
messages/it/timeTracking.json

@@ -106,6 +106,7 @@
     "deleteBody": "{duration} su {job} di {technician} verranno rimossi dal foglio ore. L'operazione non può essere annullata.",
     "deleteBody": "{duration} su {job} di {technician} verranno rimossi dal foglio ore. L'operazione non può essere annullata.",
     "deleteConfirm": "Elimina",
     "deleteConfirm": "Elimina",
     "deleted": "Voce eliminata",
     "deleted": "Voce eliminata",
+    "deleteFailed": "Impossibile eliminare la voce",
     "stoppedByManager": "Timer fermato a {minutes}",
     "stoppedByManager": "Timer fermato a {minutes}",
     "csv": {
     "csv": {
       "technician": "Tecnico",
       "technician": "Tecnico",

+ 5 - 0
messages/it/vehicles.json

@@ -97,6 +97,7 @@
     "severityRare": "Raro — segnalazioni isolate ma critico quando si verifica",
     "severityRare": "Raro — segnalazioni isolate ma critico quando si verifica",
     "openMenu": "Apri menu",
     "openMenu": "Apri menu",
     "dismissMaintenance": "Ignora",
     "dismissMaintenance": "Ignora",
+    "maintenanceChangeError": "Impossibile salvare la modifica. Riprova.",
     "inspectionDue": "Revisione il {date}",
     "inspectionDue": "Revisione il {date}",
     "inspectionCheckedAt": "Verificato nel registro il {date}",
     "inspectionCheckedAt": "Verificato nel registro il {date}",
     "notRegistered": "Non immatricolato",
     "notRegistered": "Non immatricolato",
@@ -256,6 +257,10 @@
     "addToWorkOrder": "Aggiungi all'ordine di lavoro ({count})",
     "addToWorkOrder": "Aggiungi all'ordine di lavoro ({count})",
     "dismiss": "Ignora",
     "dismiss": "Ignora",
     "observationsAdded": "{count} osservazioni aggiunte all'ordine di lavoro",
     "observationsAdded": "{count} osservazioni aggiunte all'ordine di lavoro",
+    "observationsAddFailed": "Impossibile aggiungere {count, plural, one {# osservazione} other {# osservazioni}} all'ordine di lavoro. Riprova.",
+    "observationsMayRemain": "Aggiunto all'ordine di lavoro, ma la connessione si è interrotta. {count, plural, one {# osservazione potrebbe essere ancora aperta} other {# osservazioni potrebbero essere ancora aperte}} sul veicolo.",
+    "observationsNotRemoved": "L'ordine di lavoro è stato creato, ma non è stato possibile rimuovere le osservazioni dal veicolo.",
+    "workOrderFailed": "Impossibile creare l'ordine di lavoro. Riprova.",
     "delete": "Elimina",
     "delete": "Elimina",
     "findingCreated": "Osservazione aggiunta",
     "findingCreated": "Osservazione aggiunta",
     "findingUpdated": "Osservazione aggiornata",
     "findingUpdated": "Osservazione aggiornata",

+ 3 - 1
messages/it/workBoard.json

@@ -81,7 +81,9 @@
     "linkedManagedOnTeam": "Gestito dalla pagina Team, insieme all'accesso all'app.",
     "linkedManagedOnTeam": "Gestito dalla pagina Team, insieme all'accesso all'app.",
     "skills": "Competenze",
     "skills": "Competenze",
     "skillsPlaceholder": "Diagnosi, certificato veicoli elettrici",
     "skillsPlaceholder": "Diagnosi, certificato veicoli elettrici",
-    "skillsHint": "Mostrato sotto il nome dove si assegnano i lavori. Facoltativo."
+    "skillsHint": "Mostrato sotto il nome dove si assegnano i lavori. Facoltativo.",
+    "saveFailed": "Impossibile salvare il tecnico. Riprova.",
+    "deleteFailed": "Impossibile eliminare il tecnico. Riprova."
   },
   },
   "jobDetail": {
   "jobDetail": {
     "viewDetails": "Visualizza Dettagli",
     "viewDetails": "Visualizza Dettagli",

+ 1 - 0
messages/lt/aiChat.json

@@ -12,6 +12,7 @@
   "noChats": "Ankstesnių pokalbių apie šį įrašą nėra",
   "noChats": "Ankstesnių pokalbių apie šį įrašą nėra",
   "chatsPrivate": "Jūsų pokalbiai yra privatūs ir matomi tik jums.",
   "chatsPrivate": "Jūsų pokalbiai yra privatūs ir matomi tik jums.",
   "deleteChat": "Ištrinti pokalbį",
   "deleteChat": "Ištrinti pokalbį",
+  "deleteChatFailed": "Nepavyko ištrinti pokalbio. Bandykite dar kartą.",
   "error": "Kažkas nepavyko. Bandykite dar kartą.",
   "error": "Kažkas nepavyko. Bandykite dar kartą.",
   "disclaimer": "Atsakymai kuriami pagal programoje rodomus įrašus ir gali būti klaidingi. Patikrinkite prieš imdamiesi veiksmų.",
   "disclaimer": "Atsakymai kuriami pagal programoje rodomus įrašus ir gali būti klaidingi. Patikrinkite prieš imdamiesi veiksmų.",
   "vehicleSuggestion1": "Apibendrink aptarnavimo istoriją",
   "vehicleSuggestion1": "Apibendrink aptarnavimo istoriją",

+ 2 - 1
messages/lt/dashboard.json

@@ -75,7 +75,8 @@
     "dismissedOn": "Atmesta {date}",
     "dismissedOn": "Atmesta {date}",
     "restore": "Atkurti",
     "restore": "Atkurti",
     "settingsAriaLabel": "Priežiūros nustatymai",
     "settingsAriaLabel": "Priežiūros nustatymai",
-    "dismissAriaLabel": "Atmesti"
+    "dismissAriaLabel": "Atmesti",
+    "dismissError": "Nepavyko atmesti transporto priemonės. Bandykite dar kartą."
   },
   },
   "reminders": {
   "reminders": {
     "title": "Artėjantys priminimai",
     "title": "Artėjantys priminimai",

+ 1 - 0
messages/lt/onboarding.json

@@ -73,6 +73,7 @@
     "allDoneDescription": "Visi žingsniai atlikti ir jūsų dirbtuvės veikia.",
     "allDoneDescription": "Visi žingsniai atlikti ir jūsų dirbtuvės veikia.",
     "dismiss": "Slėpti kortelę",
     "dismiss": "Slėpti kortelę",
     "dismissAriaLabel": "Slėpti šį sąrašą",
     "dismissAriaLabel": "Slėpti šį sąrašą",
+    "dismissFailed": "Nepavyko paslėpti sąrašo. Bandykite dar kartą.",
     "importTitle": "Jau turite klientų ir transporto priemonių kitur?",
     "importTitle": "Jau turite klientų ir transporto priemonių kitur?",
     "importDescription": "Importuokite juos iš skaičiuoklės arba senos sistemos"
     "importDescription": "Importuokite juos iš skaičiuoklės arba senos sistemos"
   },
   },

+ 1 - 0
messages/lt/timeTracking.json

@@ -106,6 +106,7 @@
     "deleteBody": "{technician} {duration} darbe {job} bus pašalinta iš žiniaraščio. Šio veiksmo atšaukti negalima.",
     "deleteBody": "{technician} {duration} darbe {job} bus pašalinta iš žiniaraščio. Šio veiksmo atšaukti negalima.",
     "deleteConfirm": "Ištrinti",
     "deleteConfirm": "Ištrinti",
     "deleted": "Įrašas ištrintas",
     "deleted": "Įrašas ištrintas",
+    "deleteFailed": "Nepavyko ištrinti įrašo",
     "stoppedByManager": "Laikmatis sustabdytas ties {minutes}",
     "stoppedByManager": "Laikmatis sustabdytas ties {minutes}",
     "csv": {
     "csv": {
       "technician": "Technikas",
       "technician": "Technikas",

+ 5 - 0
messages/lt/vehicles.json

@@ -97,6 +97,7 @@
     "severityRare": "Reta — pavieniai pranešimai, bet kritinė kai pasireiškia",
     "severityRare": "Reta — pavieniai pranešimai, bet kritinė kai pasireiškia",
     "openMenu": "Atidaryti meniu",
     "openMenu": "Atidaryti meniu",
     "dismissMaintenance": "Atmesti",
     "dismissMaintenance": "Atmesti",
+    "maintenanceChangeError": "Nepavyko išsaugoti pakeitimo. Bandykite dar kartą.",
     "inspectionDue": "Apžiūra iki {date}",
     "inspectionDue": "Apžiūra iki {date}",
     "inspectionCheckedAt": "Patikrinta registre {date}",
     "inspectionCheckedAt": "Patikrinta registre {date}",
     "notRegistered": "Neregistruota",
     "notRegistered": "Neregistruota",
@@ -256,6 +257,10 @@
     "addToWorkOrder": "Pridėti prie darbo užsakymo ({count})",
     "addToWorkOrder": "Pridėti prie darbo užsakymo ({count})",
     "dismiss": "Atmesti",
     "dismiss": "Atmesti",
     "observationsAdded": "{count} pastebėjimų pridėta prie darbo užsakymo",
     "observationsAdded": "{count} pastebėjimų pridėta prie darbo užsakymo",
+    "observationsAddFailed": "Nepavyko pridėti {count, plural, one {# pastebėjimo} few {# pastebėjimų} many {# pastebėjimo} other {# pastebėjimų}} prie darbo užsakymo. Bandykite dar kartą.",
+    "observationsMayRemain": "Pridėta prie darbo užsakymo, bet nutrūko ryšys. Transporto priemonėje gali likti neuždarytų pastebėjimų: {count}.",
+    "observationsNotRemoved": "Darbo užsakymas sukurtas, bet pastebėjimų nepavyko pašalinti iš transporto priemonės.",
+    "workOrderFailed": "Nepavyko sukurti darbo užsakymo. Bandykite dar kartą.",
     "delete": "Ištrinti",
     "delete": "Ištrinti",
     "findingCreated": "Pastebėjimas pridėtas",
     "findingCreated": "Pastebėjimas pridėtas",
     "findingUpdated": "Pastebėjimas atnaujintas",
     "findingUpdated": "Pastebėjimas atnaujintas",

+ 3 - 1
messages/lt/workBoard.json

@@ -81,7 +81,9 @@
     "linkedManagedOnTeam": "Valdoma Komandos puslapyje kartu su prieiga prie programėlės.",
     "linkedManagedOnTeam": "Valdoma Komandos puslapyje kartu su prieiga prie programėlės.",
     "skills": "Įgūdžiai",
     "skills": "Įgūdžiai",
     "skillsPlaceholder": "Diagnostika, elektromobilių sertifikatas",
     "skillsPlaceholder": "Diagnostika, elektromobilių sertifikatas",
-    "skillsHint": "Rodoma po vardu ten, kur skirstomi darbai. Neprivaloma."
+    "skillsHint": "Rodoma po vardu ten, kur skirstomi darbai. Neprivaloma.",
+    "saveFailed": "Nepavyko išsaugoti techniko. Bandykite dar kartą.",
+    "deleteFailed": "Nepavyko ištrinti techniko. Bandykite dar kartą."
   },
   },
   "jobDetail": {
   "jobDetail": {
     "viewDetails": "Peržiūrėti informaciją",
     "viewDetails": "Peržiūrėti informaciją",

+ 1 - 0
messages/nb/aiChat.json

@@ -12,6 +12,7 @@
   "noChats": "Ingen tidligere samtaler om denne posten",
   "noChats": "Ingen tidligere samtaler om denne posten",
   "chatsPrivate": "Samtalene dine er private og bare synlige for deg.",
   "chatsPrivate": "Samtalene dine er private og bare synlige for deg.",
   "deleteChat": "Slett samtale",
   "deleteChat": "Slett samtale",
+  "deleteChatFailed": "Kunne ikke slette samtalen. Prøv igjen.",
   "error": "Noe gikk galt. Prøv igjen.",
   "error": "Noe gikk galt. Prøv igjen.",
   "disclaimer": "Svarene lages ut fra postene som vises i appen og kan være feil. Sjekk før du handler på dem.",
   "disclaimer": "Svarene lages ut fra postene som vises i appen og kan være feil. Sjekk før du handler på dem.",
   "vehicleSuggestion1": "Oppsummer servicehistorikken",
   "vehicleSuggestion1": "Oppsummer servicehistorikken",

+ 2 - 1
messages/nb/dashboard.json

@@ -75,7 +75,8 @@
     "dismissedOn": "Avvist {date}",
     "dismissedOn": "Avvist {date}",
     "restore": "Gjenopprett",
     "restore": "Gjenopprett",
     "settingsAriaLabel": "Innstillinger for vedlikehold",
     "settingsAriaLabel": "Innstillinger for vedlikehold",
-    "dismissAriaLabel": "Avvis"
+    "dismissAriaLabel": "Avvis",
+    "dismissError": "Kunne ikke avvise kjøretøyet. Prøv igjen."
   },
   },
   "reminders": {
   "reminders": {
     "title": "Kommende påminnelser",
     "title": "Kommende påminnelser",

+ 1 - 0
messages/nb/onboarding.json

@@ -73,6 +73,7 @@
     "allDoneDescription": "Alle stegene er fullført og verkstedet ditt er i gang.",
     "allDoneDescription": "Alle stegene er fullført og verkstedet ditt er i gang.",
     "dismiss": "Skjul kortet",
     "dismiss": "Skjul kortet",
     "dismissAriaLabel": "Skjul denne sjekklisten",
     "dismissAriaLabel": "Skjul denne sjekklisten",
+    "dismissFailed": "Kunne ikke skjule sjekklisten. Prøv igjen.",
     "importTitle": "Har du allerede kunder og kjøretøy et annet sted?",
     "importTitle": "Har du allerede kunder og kjøretøy et annet sted?",
     "importDescription": "Importer dem fra et regneark eller det gamle systemet ditt"
     "importDescription": "Importer dem fra et regneark eller det gamle systemet ditt"
   },
   },

+ 1 - 0
messages/nb/timeTracking.json

@@ -106,6 +106,7 @@
     "deleteBody": "{duration} på {job} av {technician} fjernes fra timelisten. Dette kan ikke angres.",
     "deleteBody": "{duration} på {job} av {technician} fjernes fra timelisten. Dette kan ikke angres.",
     "deleteConfirm": "Slett",
     "deleteConfirm": "Slett",
     "deleted": "Oppføring slettet",
     "deleted": "Oppføring slettet",
+    "deleteFailed": "Kunne ikke slette oppføringen",
     "stoppedByManager": "Klokken stoppet ved {minutes}",
     "stoppedByManager": "Klokken stoppet ved {minutes}",
     "csv": {
     "csv": {
       "technician": "Tekniker",
       "technician": "Tekniker",

+ 5 - 0
messages/nb/vehicles.json

@@ -97,6 +97,7 @@
     "severityRare": "Sjelden — isolerte rapporter, men kritisk når det oppstår",
     "severityRare": "Sjelden — isolerte rapporter, men kritisk når det oppstår",
     "openMenu": "Åpne meny",
     "openMenu": "Åpne meny",
     "dismissMaintenance": "Avvis",
     "dismissMaintenance": "Avvis",
+    "maintenanceChangeError": "Kunne ikke lagre endringen. Prøv igjen.",
     "inspectionDue": "EU-kontroll {date}",
     "inspectionDue": "EU-kontroll {date}",
     "inspectionCheckedAt": "Sjekket mot registeret {date}",
     "inspectionCheckedAt": "Sjekket mot registeret {date}",
     "notRegistered": "Avregistrert",
     "notRegistered": "Avregistrert",
@@ -256,6 +257,10 @@
     "addToWorkOrder": "Legg til i arbeidsordre ({count})",
     "addToWorkOrder": "Legg til i arbeidsordre ({count})",
     "dismiss": "Avvis",
     "dismiss": "Avvis",
     "observationsAdded": "{count} observasjoner lagt til i arbeidsordre",
     "observationsAdded": "{count} observasjoner lagt til i arbeidsordre",
+    "observationsAddFailed": "Kunne ikke legge til {count, plural, one {# observasjon} other {# observasjoner}} i arbeidsordren. Prøv igjen.",
+    "observationsMayRemain": "Lagt til i arbeidsordren, men tilkoblingen ble brutt. {count, plural, one {# observasjon kan fortsatt være åpen} other {# observasjoner kan fortsatt være åpne}} på kjøretøyet.",
+    "observationsNotRemoved": "Arbeidsordren ble opprettet, men observasjonene kunne ikke fjernes fra kjøretøyet.",
+    "workOrderFailed": "Kunne ikke opprette arbeidsordren. Prøv igjen.",
     "delete": "Slett",
     "delete": "Slett",
     "findingCreated": "Observasjon lagt til",
     "findingCreated": "Observasjon lagt til",
     "findingUpdated": "Observasjon oppdatert",
     "findingUpdated": "Observasjon oppdatert",

+ 3 - 1
messages/nb/workBoard.json

@@ -81,7 +81,9 @@
     "linkedManagedOnTeam": "Styres fra Team-siden, sammen med apptilgangen.",
     "linkedManagedOnTeam": "Styres fra Team-siden, sammen med apptilgangen.",
     "skills": "Kompetanse",
     "skills": "Kompetanse",
     "skillsPlaceholder": "Diagnostikk, elbilsertifisert",
     "skillsPlaceholder": "Diagnostikk, elbilsertifisert",
-    "skillsHint": "Vises under navnet der jobbene fordeles. Valgfritt."
+    "skillsHint": "Vises under navnet der jobbene fordeles. Valgfritt.",
+    "saveFailed": "Kunne ikke lagre teknikeren. Prøv igjen.",
+    "deleteFailed": "Kunne ikke slette teknikeren. Prøv igjen."
   },
   },
   "jobDetail": {
   "jobDetail": {
     "viewDetails": "Vis detaljer",
     "viewDetails": "Vis detaljer",

+ 1 - 0
messages/nl/aiChat.json

@@ -12,6 +12,7 @@
   "noChats": "Geen eerdere chats over dit record",
   "noChats": "Geen eerdere chats over dit record",
   "chatsPrivate": "Je chats zijn privé en alleen voor jou zichtbaar.",
   "chatsPrivate": "Je chats zijn privé en alleen voor jou zichtbaar.",
   "deleteChat": "Chat verwijderen",
   "deleteChat": "Chat verwijderen",
+  "deleteChatFailed": "Kon de chat niet verwijderen. Probeer het opnieuw.",
   "error": "Er ging iets mis. Probeer het opnieuw.",
   "error": "Er ging iets mis. Probeer het opnieuw.",
   "disclaimer": "Antwoorden worden gemaakt op basis van de gegevens in de app en kunnen fout zijn. Controleer ze voordat je ernaar handelt.",
   "disclaimer": "Antwoorden worden gemaakt op basis van de gegevens in de app en kunnen fout zijn. Controleer ze voordat je ernaar handelt.",
   "vehicleSuggestion1": "Vat de onderhoudshistorie samen",
   "vehicleSuggestion1": "Vat de onderhoudshistorie samen",

+ 2 - 1
messages/nl/dashboard.json

@@ -75,7 +75,8 @@
     "dismissedOn": "Genegeerd op {date}",
     "dismissedOn": "Genegeerd op {date}",
     "restore": "Herstellen",
     "restore": "Herstellen",
     "settingsAriaLabel": "Onderhoudsinstellingen",
     "settingsAriaLabel": "Onderhoudsinstellingen",
-    "dismissAriaLabel": "Negeren"
+    "dismissAriaLabel": "Negeren",
+    "dismissError": "Kon het voertuig niet negeren. Probeer het opnieuw."
   },
   },
   "reminders": {
   "reminders": {
     "title": "Aankomende herinneringen",
     "title": "Aankomende herinneringen",

+ 1 - 0
messages/nl/onboarding.json

@@ -73,6 +73,7 @@
     "allDoneDescription": "Alle stappen zijn afgerond en uw werkplaats draait.",
     "allDoneDescription": "Alle stappen zijn afgerond en uw werkplaats draait.",
     "dismiss": "Kaart verbergen",
     "dismiss": "Kaart verbergen",
     "dismissAriaLabel": "Deze checklist verbergen",
     "dismissAriaLabel": "Deze checklist verbergen",
+    "dismissFailed": "Kon de checklist niet verbergen. Probeer het opnieuw.",
     "importTitle": "Heeft u al ergens klanten en voertuigen?",
     "importTitle": "Heeft u al ergens klanten en voertuigen?",
     "importDescription": "Importeer ze uit een spreadsheet of uw oude systeem"
     "importDescription": "Importeer ze uit een spreadsheet of uw oude systeem"
   },
   },

+ 1 - 0
messages/nl/timeTracking.json

@@ -106,6 +106,7 @@
     "deleteBody": "{duration} op {job} door {technician} wordt van de urenstaat verwijderd. Dit kan niet ongedaan worden gemaakt.",
     "deleteBody": "{duration} op {job} door {technician} wordt van de urenstaat verwijderd. Dit kan niet ongedaan worden gemaakt.",
     "deleteConfirm": "Verwijderen",
     "deleteConfirm": "Verwijderen",
     "deleted": "Registratie verwijderd",
     "deleted": "Registratie verwijderd",
+    "deleteFailed": "Kon de registratie niet verwijderen",
     "stoppedByManager": "Klok gestopt op {minutes}",
     "stoppedByManager": "Klok gestopt op {minutes}",
     "csv": {
     "csv": {
       "technician": "Monteur",
       "technician": "Monteur",

+ 5 - 0
messages/nl/vehicles.json

@@ -97,6 +97,7 @@
     "severityRare": "Zeldzaam — geïsoleerde meldingen maar kritiek wanneer het optreedt",
     "severityRare": "Zeldzaam — geïsoleerde meldingen maar kritiek wanneer het optreedt",
     "openMenu": "Menu openen",
     "openMenu": "Menu openen",
     "dismissMaintenance": "Negeren",
     "dismissMaintenance": "Negeren",
+    "maintenanceChangeError": "Kon de wijziging niet opslaan. Probeer het opnieuw.",
     "inspectionDue": "APK vervalt {date}",
     "inspectionDue": "APK vervalt {date}",
     "inspectionCheckedAt": "Gecontroleerd bij het register op {date}",
     "inspectionCheckedAt": "Gecontroleerd bij het register op {date}",
     "notRegistered": "Niet geregistreerd",
     "notRegistered": "Niet geregistreerd",
@@ -256,6 +257,10 @@
     "addToWorkOrder": "Toevoegen aan werkorder ({count})",
     "addToWorkOrder": "Toevoegen aan werkorder ({count})",
     "dismiss": "Negeren",
     "dismiss": "Negeren",
     "observationsAdded": "{count} observaties toegevoegd aan werkorder",
     "observationsAdded": "{count} observaties toegevoegd aan werkorder",
+    "observationsAddFailed": "Kon {count, plural, one {# observatie} other {# observaties}} niet aan de werkorder toevoegen. Probeer het opnieuw.",
+    "observationsMayRemain": "Toegevoegd aan de werkorder, maar de verbinding viel weg. {count, plural, one {# observatie staat} other {# observaties staan}} mogelijk nog open op het voertuig.",
+    "observationsNotRemoved": "De werkorder is aangemaakt, maar de observaties konden niet van het voertuig worden verwijderd.",
+    "workOrderFailed": "Kon de werkorder niet aanmaken. Probeer het opnieuw.",
     "delete": "Verwijderen",
     "delete": "Verwijderen",
     "findingCreated": "Observatie toegevoegd",
     "findingCreated": "Observatie toegevoegd",
     "findingUpdated": "Observatie bijgewerkt",
     "findingUpdated": "Observatie bijgewerkt",

+ 3 - 1
messages/nl/workBoard.json

@@ -81,7 +81,9 @@
     "linkedManagedOnTeam": "Wordt beheerd op de Team-pagina, samen met de app-toegang.",
     "linkedManagedOnTeam": "Wordt beheerd op de Team-pagina, samen met de app-toegang.",
     "skills": "Vaardigheden",
     "skills": "Vaardigheden",
     "skillsPlaceholder": "Diagnose, EV-gecertificeerd",
     "skillsPlaceholder": "Diagnose, EV-gecertificeerd",
-    "skillsHint": "Wordt onder de naam getoond waar werk wordt verdeeld. Optioneel."
+    "skillsHint": "Wordt onder de naam getoond waar werk wordt verdeeld. Optioneel.",
+    "saveFailed": "Kon de technicus niet opslaan. Probeer het opnieuw.",
+    "deleteFailed": "Kon de technicus niet verwijderen. Probeer het opnieuw."
   },
   },
   "jobDetail": {
   "jobDetail": {
     "viewDetails": "Details weergeven",
     "viewDetails": "Details weergeven",

+ 1 - 0
messages/pl/aiChat.json

@@ -12,6 +12,7 @@
   "noChats": "Brak wcześniejszych czatów o tym rekordzie",
   "noChats": "Brak wcześniejszych czatów o tym rekordzie",
   "chatsPrivate": "Twoje czaty są prywatne i widoczne tylko dla Ciebie.",
   "chatsPrivate": "Twoje czaty są prywatne i widoczne tylko dla Ciebie.",
   "deleteChat": "Usuń czat",
   "deleteChat": "Usuń czat",
+  "deleteChatFailed": "Nie udało się usunąć czatu. Spróbuj ponownie.",
   "error": "Coś poszło nie tak. Spróbuj ponownie.",
   "error": "Coś poszło nie tak. Spróbuj ponownie.",
   "disclaimer": "Odpowiedzi są generowane na podstawie danych widocznych w aplikacji i mogą być błędne. Sprawdź je, zanim podejmiesz działania.",
   "disclaimer": "Odpowiedzi są generowane na podstawie danych widocznych w aplikacji i mogą być błędne. Sprawdź je, zanim podejmiesz działania.",
   "vehicleSuggestion1": "Podsumuj historię serwisową",
   "vehicleSuggestion1": "Podsumuj historię serwisową",

+ 2 - 1
messages/pl/dashboard.json

@@ -75,7 +75,8 @@
     "dismissedOn": "Odrzucono {date}",
     "dismissedOn": "Odrzucono {date}",
     "restore": "Przywróć",
     "restore": "Przywróć",
     "settingsAriaLabel": "Ustawienia konserwacji",
     "settingsAriaLabel": "Ustawienia konserwacji",
-    "dismissAriaLabel": "Odrzuć"
+    "dismissAriaLabel": "Odrzuć",
+    "dismissError": "Nie udało się odrzucić pojazdu. Spróbuj ponownie."
   },
   },
   "reminders": {
   "reminders": {
     "title": "Nadchodzące przypomnienia",
     "title": "Nadchodzące przypomnienia",

+ 1 - 0
messages/pl/onboarding.json

@@ -73,6 +73,7 @@
     "allDoneDescription": "Wszystkie kroki zostały wykonane i Twój warsztat działa.",
     "allDoneDescription": "Wszystkie kroki zostały wykonane i Twój warsztat działa.",
     "dismiss": "Ukryj kartę",
     "dismiss": "Ukryj kartę",
     "dismissAriaLabel": "Ukryj tę listę",
     "dismissAriaLabel": "Ukryj tę listę",
+    "dismissFailed": "Nie udało się ukryć listy. Spróbuj ponownie.",
     "importTitle": "Masz już gdzieś klientów i pojazdy?",
     "importTitle": "Masz już gdzieś klientów i pojazdy?",
     "importDescription": "Zaimportuj ich z arkusza kalkulacyjnego lub ze starego systemu"
     "importDescription": "Zaimportuj ich z arkusza kalkulacyjnego lub ze starego systemu"
   },
   },

+ 1 - 0
messages/pl/timeTracking.json

@@ -106,6 +106,7 @@
     "deleteBody": "{duration} w zleceniu {job} technika {technician} zostanie usunięte z ewidencji. Tej operacji nie można cofnąć.",
     "deleteBody": "{duration} w zleceniu {job} technika {technician} zostanie usunięte z ewidencji. Tej operacji nie można cofnąć.",
     "deleteConfirm": "Usuń",
     "deleteConfirm": "Usuń",
     "deleted": "Wpis usunięty",
     "deleted": "Wpis usunięty",
+    "deleteFailed": "Nie udało się usunąć wpisu",
     "stoppedByManager": "Zegar zatrzymany na {minutes}",
     "stoppedByManager": "Zegar zatrzymany na {minutes}",
     "csv": {
     "csv": {
       "technician": "Technik",
       "technician": "Technik",

+ 5 - 0
messages/pl/vehicles.json

@@ -97,6 +97,7 @@
     "severityRare": "Bardzo rzadkie — pojedyncze zgłoszenia, ale krytyczne gdy wystąpi",
     "severityRare": "Bardzo rzadkie — pojedyncze zgłoszenia, ale krytyczne gdy wystąpi",
     "openMenu": "Otwórz menu",
     "openMenu": "Otwórz menu",
     "dismissMaintenance": "Odrzuć",
     "dismissMaintenance": "Odrzuć",
+    "maintenanceChangeError": "Nie udało się zapisać zmiany. Spróbuj ponownie.",
     "inspectionDue": "Przegląd do {date}",
     "inspectionDue": "Przegląd do {date}",
     "inspectionCheckedAt": "Sprawdzono w rejestrze {date}",
     "inspectionCheckedAt": "Sprawdzono w rejestrze {date}",
     "notRegistered": "Niezarejestrowany",
     "notRegistered": "Niezarejestrowany",
@@ -256,6 +257,10 @@
     "addToWorkOrder": "Dodaj do zlecenia ({count})",
     "addToWorkOrder": "Dodaj do zlecenia ({count})",
     "dismiss": "Odrzuć",
     "dismiss": "Odrzuć",
     "observationsAdded": "{count} obserwacji dodanych do zlecenia",
     "observationsAdded": "{count} obserwacji dodanych do zlecenia",
+    "observationsAddFailed": "Nie udało się dodać {count, plural, one {# obserwacji} few {# obserwacji} many {# obserwacji} other {# obserwacji}} do zlecenia. Spróbuj ponownie.",
+    "observationsMayRemain": "Dodano do zlecenia, ale połączenie zostało przerwane. Liczba obserwacji, które mogą nadal być otwarte w pojeździe: {count}.",
+    "observationsNotRemoved": "Zlecenie zostało utworzone, ale nie udało się usunąć obserwacji z pojazdu.",
+    "workOrderFailed": "Nie udało się utworzyć zlecenia. Spróbuj ponownie.",
     "delete": "Usuń",
     "delete": "Usuń",
     "findingCreated": "Obserwacja dodana",
     "findingCreated": "Obserwacja dodana",
     "findingUpdated": "Obserwacja zaktualizowana",
     "findingUpdated": "Obserwacja zaktualizowana",

+ 3 - 1
messages/pl/workBoard.json

@@ -81,7 +81,9 @@
     "linkedManagedOnTeam": "Zarządzane na stronie Zespół, razem z dostępem do aplikacji.",
     "linkedManagedOnTeam": "Zarządzane na stronie Zespół, razem z dostępem do aplikacji.",
     "skills": "Umiejętności",
     "skills": "Umiejętności",
     "skillsPlaceholder": "Diagnostyka, certyfikat EV",
     "skillsPlaceholder": "Diagnostyka, certyfikat EV",
-    "skillsHint": "Wyświetlane pod nazwiskiem tam, gdzie przydziela się pracę. Opcjonalne."
+    "skillsHint": "Wyświetlane pod nazwiskiem tam, gdzie przydziela się pracę. Opcjonalne.",
+    "saveFailed": "Nie udało się zapisać technika. Spróbuj ponownie.",
+    "deleteFailed": "Nie udało się usunąć technika. Spróbuj ponownie."
   },
   },
   "jobDetail": {
   "jobDetail": {
     "viewDetails": "Wyświetl Szczegóły",
     "viewDetails": "Wyświetl Szczegóły",

+ 1 - 0
messages/pt-BR/aiChat.json

@@ -12,6 +12,7 @@
   "noChats": "Nenhuma conversa anterior sobre este registro",
   "noChats": "Nenhuma conversa anterior sobre este registro",
   "chatsPrivate": "Suas conversas são privadas e visíveis apenas para você.",
   "chatsPrivate": "Suas conversas são privadas e visíveis apenas para você.",
   "deleteChat": "Excluir conversa",
   "deleteChat": "Excluir conversa",
+  "deleteChatFailed": "Não foi possível excluir a conversa. Tente novamente.",
   "error": "Algo deu errado. Tente novamente.",
   "error": "Algo deu errado. Tente novamente.",
   "disclaimer": "As respostas são geradas a partir dos registros exibidos no app e podem estar erradas. Confira antes de agir.",
   "disclaimer": "As respostas são geradas a partir dos registros exibidos no app e podem estar erradas. Confira antes de agir.",
   "vehicleSuggestion1": "Resuma o histórico de serviços",
   "vehicleSuggestion1": "Resuma o histórico de serviços",

+ 2 - 1
messages/pt-BR/dashboard.json

@@ -75,7 +75,8 @@
     "dismissedOn": "Dispensado em {date}",
     "dismissedOn": "Dispensado em {date}",
     "restore": "Restaurar",
     "restore": "Restaurar",
     "settingsAriaLabel": "Configurações de manutenção",
     "settingsAriaLabel": "Configurações de manutenção",
-    "dismissAriaLabel": "Dispensar"
+    "dismissAriaLabel": "Dispensar",
+    "dismissError": "Não foi possível dispensar o veículo. Tente novamente."
   },
   },
   "reminders": {
   "reminders": {
     "title": "Lembretes Próximos",
     "title": "Lembretes Próximos",

+ 1 - 0
messages/pt-BR/onboarding.json

@@ -73,6 +73,7 @@
     "allDoneDescription": "Todos os passos foram concluídos e sua oficina está em funcionamento.",
     "allDoneDescription": "Todos os passos foram concluídos e sua oficina está em funcionamento.",
     "dismiss": "Ocultar cartão",
     "dismiss": "Ocultar cartão",
     "dismissAriaLabel": "Ocultar esta lista",
     "dismissAriaLabel": "Ocultar esta lista",
+    "dismissFailed": "Não foi possível ocultar a lista. Tente novamente.",
     "importTitle": "Já tem clientes e veículos em algum lugar?",
     "importTitle": "Já tem clientes e veículos em algum lugar?",
     "importDescription": "Importe-os de uma planilha ou do seu sistema antigo"
     "importDescription": "Importe-os de uma planilha ou do seu sistema antigo"
   },
   },

+ 1 - 0
messages/pt-BR/timeTracking.json

@@ -106,6 +106,7 @@
     "deleteBody": "{duration} em {job} por {technician} será removido da folha de ponto. Isso não pode ser desfeito.",
     "deleteBody": "{duration} em {job} por {technician} será removido da folha de ponto. Isso não pode ser desfeito.",
     "deleteConfirm": "Excluir",
     "deleteConfirm": "Excluir",
     "deleted": "Registro excluído",
     "deleted": "Registro excluído",
+    "deleteFailed": "Não foi possível excluir o registro",
     "stoppedByManager": "Relógio parado em {minutes}",
     "stoppedByManager": "Relógio parado em {minutes}",
     "csv": {
     "csv": {
       "technician": "Técnico",
       "technician": "Técnico",

+ 5 - 0
messages/pt-BR/vehicles.json

@@ -97,6 +97,7 @@
     "severityRare": "Raro: relatos isolados, mas crítico quando ocorre",
     "severityRare": "Raro: relatos isolados, mas crítico quando ocorre",
     "openMenu": "Abrir menu",
     "openMenu": "Abrir menu",
     "dismissMaintenance": "Dispensar",
     "dismissMaintenance": "Dispensar",
+    "maintenanceChangeError": "Não foi possível salvar a alteração. Tente novamente.",
     "inspectionDue": "Inspeção em {date}",
     "inspectionDue": "Inspeção em {date}",
     "inspectionCheckedAt": "Verificado no registro em {date}",
     "inspectionCheckedAt": "Verificado no registro em {date}",
     "notRegistered": "Não registrado",
     "notRegistered": "Não registrado",
@@ -256,6 +257,10 @@
     "addToWorkOrder": "Adicionar à ordem de serviço ({count})",
     "addToWorkOrder": "Adicionar à ordem de serviço ({count})",
     "dismiss": "Dispensar",
     "dismiss": "Dispensar",
     "observationsAdded": "{count} observações adicionadas à ordem de serviço",
     "observationsAdded": "{count} observações adicionadas à ordem de serviço",
+    "observationsAddFailed": "Não foi possível adicionar {count, plural, one {# observação} other {# observações}} à ordem de serviço. Tente novamente.",
+    "observationsMayRemain": "Adicionado à ordem de serviço, mas a conexão caiu. {count, plural, one {# observação pode continuar aberta} other {# observações podem continuar abertas}} no veículo.",
+    "observationsNotRemoved": "A ordem de serviço foi criada, mas não foi possível remover as observações do veículo.",
+    "workOrderFailed": "Não foi possível criar a ordem de serviço. Tente novamente.",
     "delete": "Excluir",
     "delete": "Excluir",
     "findingCreated": "Observação adicionada",
     "findingCreated": "Observação adicionada",
     "findingUpdated": "Observação atualizada",
     "findingUpdated": "Observação atualizada",

+ 3 - 1
messages/pt-BR/workBoard.json

@@ -81,7 +81,9 @@
     "linkedManagedOnTeam": "Gerenciado na página Equipe, junto com o acesso ao aplicativo.",
     "linkedManagedOnTeam": "Gerenciado na página Equipe, junto com o acesso ao aplicativo.",
     "skills": "Habilidades",
     "skills": "Habilidades",
     "skillsPlaceholder": "Diagnóstico, certificado em veículos elétricos",
     "skillsPlaceholder": "Diagnóstico, certificado em veículos elétricos",
-    "skillsHint": "Aparece abaixo do nome onde o trabalho é distribuído. Opcional."
+    "skillsHint": "Aparece abaixo do nome onde o trabalho é distribuído. Opcional.",
+    "saveFailed": "Não foi possível salvar o técnico. Tente novamente.",
+    "deleteFailed": "Não foi possível excluir o técnico. Tente novamente."
   },
   },
   "jobDetail": {
   "jobDetail": {
     "viewDetails": "Ver Detalhes",
     "viewDetails": "Ver Detalhes",

+ 1 - 0
messages/pt-PT/aiChat.json

@@ -12,6 +12,7 @@
   "noChats": "Não há conversas anteriores sobre este registo",
   "noChats": "Não há conversas anteriores sobre este registo",
   "chatsPrivate": "As suas conversas são privadas e só são visíveis para si.",
   "chatsPrivate": "As suas conversas são privadas e só são visíveis para si.",
   "deleteChat": "Eliminar conversa",
   "deleteChat": "Eliminar conversa",
+  "deleteChatFailed": "Não foi possível eliminar a conversa. Tente novamente.",
   "error": "Algo correu mal. Tente novamente.",
   "error": "Algo correu mal. Tente novamente.",
   "disclaimer": "As respostas são geradas a partir dos registos apresentados na aplicação e podem estar erradas. Confirme antes de agir com base nelas.",
   "disclaimer": "As respostas são geradas a partir dos registos apresentados na aplicação e podem estar erradas. Confirme antes de agir com base nelas.",
   "vehicleSuggestion1": "Resuma o histórico de serviços",
   "vehicleSuggestion1": "Resuma o histórico de serviços",

+ 2 - 1
messages/pt-PT/dashboard.json

@@ -75,7 +75,8 @@
     "dismissedOn": "Dispensada em {date}",
     "dismissedOn": "Dispensada em {date}",
     "restore": "Restaurar",
     "restore": "Restaurar",
     "settingsAriaLabel": "Definições de manutenção",
     "settingsAriaLabel": "Definições de manutenção",
-    "dismissAriaLabel": "Dispensar"
+    "dismissAriaLabel": "Dispensar",
+    "dismissError": "Não foi possível dispensar a viatura. Tente novamente."
   },
   },
   "reminders": {
   "reminders": {
     "title": "Próximos Lembretes",
     "title": "Próximos Lembretes",

+ 1 - 0
messages/pt-PT/onboarding.json

@@ -73,6 +73,7 @@
     "allDoneDescription": "Todos os passos foram concluídos e a sua oficina já está a funcionar.",
     "allDoneDescription": "Todos os passos foram concluídos e a sua oficina já está a funcionar.",
     "dismiss": "Ocultar cartão",
     "dismiss": "Ocultar cartão",
     "dismissAriaLabel": "Ocultar esta lista",
     "dismissAriaLabel": "Ocultar esta lista",
+    "dismissFailed": "Não foi possível ocultar a lista. Tente novamente.",
     "importTitle": "Já tem clientes e viaturas noutro sítio?",
     "importTitle": "Já tem clientes e viaturas noutro sítio?",
     "importDescription": "Importe-os de uma folha de cálculo ou do seu sistema antigo"
     "importDescription": "Importe-os de uma folha de cálculo ou do seu sistema antigo"
   },
   },

+ 1 - 0
messages/pt-PT/timeTracking.json

@@ -106,6 +106,7 @@
     "deleteBody": "{duration} em {job} por {technician} será removido da folha de horas. Esta ação não pode ser anulada.",
     "deleteBody": "{duration} em {job} por {technician} será removido da folha de horas. Esta ação não pode ser anulada.",
     "deleteConfirm": "Eliminar",
     "deleteConfirm": "Eliminar",
     "deleted": "Registo eliminado",
     "deleted": "Registo eliminado",
+    "deleteFailed": "Não foi possível eliminar o registo",
     "stoppedByManager": "Relógio parado aos {minutes}",
     "stoppedByManager": "Relógio parado aos {minutes}",
     "csv": {
     "csv": {
       "technician": "Técnico",
       "technician": "Técnico",

+ 5 - 0
messages/pt-PT/vehicles.json

@@ -97,6 +97,7 @@
     "severityRare": "Raro: casos isolados, mas crítico quando ocorre",
     "severityRare": "Raro: casos isolados, mas crítico quando ocorre",
     "openMenu": "Abrir menu",
     "openMenu": "Abrir menu",
     "dismissMaintenance": "Ignorar",
     "dismissMaintenance": "Ignorar",
+    "maintenanceChangeError": "Não foi possível guardar a alteração. Tente novamente.",
     "inspectionDue": "Inspeção a {date}",
     "inspectionDue": "Inspeção a {date}",
     "inspectionCheckedAt": "Verificado no registo automóvel a {date}",
     "inspectionCheckedAt": "Verificado no registo automóvel a {date}",
     "notRegistered": "Não registada",
     "notRegistered": "Não registada",
@@ -256,6 +257,10 @@
     "addToWorkOrder": "Adicionar à ordem de serviço ({count})",
     "addToWorkOrder": "Adicionar à ordem de serviço ({count})",
     "dismiss": "Ignorar",
     "dismiss": "Ignorar",
     "observationsAdded": "{count} observações adicionadas à ordem de serviço",
     "observationsAdded": "{count} observações adicionadas à ordem de serviço",
+    "observationsAddFailed": "Não foi possível adicionar {count, plural, one {# observação} other {# observações}} à ordem de serviço. Tente novamente.",
+    "observationsMayRemain": "Adicionado à ordem de serviço, mas a ligação falhou. {count, plural, one {# observação pode continuar aberta} other {# observações podem continuar abertas}} na viatura.",
+    "observationsNotRemoved": "A ordem de serviço foi criada, mas não foi possível remover as observações da viatura.",
+    "workOrderFailed": "Não foi possível criar a ordem de serviço. Tente novamente.",
     "delete": "Eliminar",
     "delete": "Eliminar",
     "findingCreated": "Observação adicionada",
     "findingCreated": "Observação adicionada",
     "findingUpdated": "Observação atualizada",
     "findingUpdated": "Observação atualizada",

+ 3 - 1
messages/pt-PT/workBoard.json

@@ -81,7 +81,9 @@
     "linkedManagedOnTeam": "Gerido na página Equipa, juntamente com o acesso à aplicação.",
     "linkedManagedOnTeam": "Gerido na página Equipa, juntamente com o acesso à aplicação.",
     "skills": "Competências",
     "skills": "Competências",
     "skillsPlaceholder": "Diagnóstico, certificado em veículos elétricos",
     "skillsPlaceholder": "Diagnóstico, certificado em veículos elétricos",
-    "skillsHint": "Aparece por baixo do nome onde o trabalho é atribuído. Opcional."
+    "skillsHint": "Aparece por baixo do nome onde o trabalho é atribuído. Opcional.",
+    "saveFailed": "Não foi possível guardar o técnico. Tente novamente.",
+    "deleteFailed": "Não foi possível eliminar o técnico. Tente novamente."
   },
   },
   "jobDetail": {
   "jobDetail": {
     "viewDetails": "Ver Detalhes",
     "viewDetails": "Ver Detalhes",

+ 1 - 0
messages/ru/aiChat.json

@@ -12,6 +12,7 @@
   "noChats": "Предыдущих чатов по этой записи нет",
   "noChats": "Предыдущих чатов по этой записи нет",
   "chatsPrivate": "Ваши чаты приватны и видны только вам.",
   "chatsPrivate": "Ваши чаты приватны и видны только вам.",
   "deleteChat": "Удалить чат",
   "deleteChat": "Удалить чат",
+  "deleteChatFailed": "Не удалось удалить чат. Попробуйте ещё раз.",
   "error": "Что-то пошло не так. Попробуйте ещё раз.",
   "error": "Что-то пошло не так. Попробуйте ещё раз.",
   "disclaimer": "Ответы формируются по данным, показанным в приложении, и могут быть ошибочными. Проверяйте их, прежде чем действовать.",
   "disclaimer": "Ответы формируются по данным, показанным в приложении, и могут быть ошибочными. Проверяйте их, прежде чем действовать.",
   "vehicleSuggestion1": "Кратко изложи историю обслуживания",
   "vehicleSuggestion1": "Кратко изложи историю обслуживания",

+ 2 - 1
messages/ru/dashboard.json

@@ -75,7 +75,8 @@
     "dismissedOn": "Отклонено {date}",
     "dismissedOn": "Отклонено {date}",
     "restore": "Восстановить",
     "restore": "Восстановить",
     "settingsAriaLabel": "Настройки техобслуживания",
     "settingsAriaLabel": "Настройки техобслуживания",
-    "dismissAriaLabel": "Отклонить"
+    "dismissAriaLabel": "Отклонить",
+    "dismissError": "Не удалось отклонить транспортное средство. Попробуйте ещё раз."
   },
   },
   "reminders": {
   "reminders": {
     "title": "Предстоящие напоминания",
     "title": "Предстоящие напоминания",

+ 1 - 0
messages/ru/onboarding.json

@@ -73,6 +73,7 @@
     "allDoneDescription": "Все шаги выполнены, ваша мастерская работает.",
     "allDoneDescription": "Все шаги выполнены, ваша мастерская работает.",
     "dismiss": "Скрыть карточку",
     "dismiss": "Скрыть карточку",
     "dismissAriaLabel": "Скрыть этот список",
     "dismissAriaLabel": "Скрыть этот список",
+    "dismissFailed": "Не удалось скрыть список. Попробуйте ещё раз.",
     "importTitle": "Уже ведёте учёт клиентов и автомобилей где-то ещё?",
     "importTitle": "Уже ведёте учёт клиентов и автомобилей где-то ещё?",
     "importDescription": "Импортируйте их из таблицы или вашей старой системы"
     "importDescription": "Импортируйте их из таблицы или вашей старой системы"
   },
   },

+ 1 - 0
messages/ru/timeTracking.json

@@ -106,6 +106,7 @@
     "deleteBody": "{duration} по заказу {job} ({technician}) будет удалено из табеля. Это нельзя отменить.",
     "deleteBody": "{duration} по заказу {job} ({technician}) будет удалено из табеля. Это нельзя отменить.",
     "deleteConfirm": "Удалить",
     "deleteConfirm": "Удалить",
     "deleted": "Запись удалена",
     "deleted": "Запись удалена",
+    "deleteFailed": "Не удалось удалить запись",
     "stoppedByManager": "Таймер остановлен на {minutes}",
     "stoppedByManager": "Таймер остановлен на {minutes}",
     "csv": {
     "csv": {
       "technician": "Техник",
       "technician": "Техник",

+ 5 - 0
messages/ru/vehicles.json

@@ -97,6 +97,7 @@
     "severityRare": "Редко — единичные случаи, но критично при возникновении",
     "severityRare": "Редко — единичные случаи, но критично при возникновении",
     "openMenu": "Открыть меню",
     "openMenu": "Открыть меню",
     "dismissMaintenance": "Отклонить",
     "dismissMaintenance": "Отклонить",
+    "maintenanceChangeError": "Не удалось сохранить изменение. Попробуйте ещё раз.",
     "inspectionDue": "Техосмотр до {date}",
     "inspectionDue": "Техосмотр до {date}",
     "inspectionCheckedAt": "Проверено в реестре {date}",
     "inspectionCheckedAt": "Проверено в реестре {date}",
     "notRegistered": "Не зарегистрирован",
     "notRegistered": "Не зарегистрирован",
@@ -256,6 +257,10 @@
     "addToWorkOrder": "Добавить в заказ-наряд ({count})",
     "addToWorkOrder": "Добавить в заказ-наряд ({count})",
     "dismiss": "Скрыть",
     "dismiss": "Скрыть",
     "observationsAdded": "{count} наблюдений добавлено в заказ-наряд",
     "observationsAdded": "{count} наблюдений добавлено в заказ-наряд",
+    "observationsAddFailed": "Не удалось добавить {count, plural, one {# наблюдение} few {# наблюдения} many {# наблюдений} other {# наблюдения}} в заказ-наряд. Попробуйте ещё раз.",
+    "observationsMayRemain": "Добавлено в заказ-наряд, но соединение прервалось. Наблюдений, которые могут остаться открытыми на транспортном средстве: {count}.",
+    "observationsNotRemoved": "Заказ-наряд создан, но не удалось убрать наблюдения с транспортного средства.",
+    "workOrderFailed": "Не удалось создать заказ-наряд. Попробуйте ещё раз.",
     "delete": "Удалить",
     "delete": "Удалить",
     "findingCreated": "Наблюдение добавлено",
     "findingCreated": "Наблюдение добавлено",
     "findingUpdated": "Наблюдение обновлено",
     "findingUpdated": "Наблюдение обновлено",

+ 3 - 1
messages/ru/workBoard.json

@@ -81,7 +81,9 @@
     "linkedManagedOnTeam": "Управляется на странице «Команда» вместе с доступом к приложению.",
     "linkedManagedOnTeam": "Управляется на странице «Команда» вместе с доступом к приложению.",
     "skills": "Навыки",
     "skills": "Навыки",
     "skillsPlaceholder": "Диагностика, допуск к электромобилям",
     "skillsPlaceholder": "Диагностика, допуск к электромобилям",
-    "skillsHint": "Показывается под именем там, где распределяется работа. Необязательно."
+    "skillsHint": "Показывается под именем там, где распределяется работа. Необязательно.",
+    "saveFailed": "Не удалось сохранить механика. Попробуйте ещё раз.",
+    "deleteFailed": "Не удалось удалить механика. Попробуйте ещё раз."
   },
   },
   "jobDetail": {
   "jobDetail": {
     "viewDetails": "Просмотреть детали",
     "viewDetails": "Просмотреть детали",

+ 1 - 0
messages/tr/aiChat.json

@@ -12,6 +12,7 @@
   "noChats": "Bu kayıtla ilgili önceki sohbet yok",
   "noChats": "Bu kayıtla ilgili önceki sohbet yok",
   "chatsPrivate": "Sohbetleriniz gizlidir ve yalnızca siz görebilirsiniz.",
   "chatsPrivate": "Sohbetleriniz gizlidir ve yalnızca siz görebilirsiniz.",
   "deleteChat": "Sohbeti sil",
   "deleteChat": "Sohbeti sil",
+  "deleteChatFailed": "Sohbet silinemedi. Tekrar deneyin.",
   "error": "Bir şeyler ters gitti. Lütfen tekrar deneyin.",
   "error": "Bir şeyler ters gitti. Lütfen tekrar deneyin.",
   "disclaimer": "Yanıtlar uygulamada gösterilen kayıtlardan üretilir ve hatalı olabilir. Harekete geçmeden önce kontrol edin.",
   "disclaimer": "Yanıtlar uygulamada gösterilen kayıtlardan üretilir ve hatalı olabilir. Harekete geçmeden önce kontrol edin.",
   "vehicleSuggestion1": "Servis geçmişini özetle",
   "vehicleSuggestion1": "Servis geçmişini özetle",

+ 2 - 1
messages/tr/dashboard.json

@@ -75,7 +75,8 @@
     "dismissedOn": "{date} tarihinde kapatıldı",
     "dismissedOn": "{date} tarihinde kapatıldı",
     "restore": "Geri Yükle",
     "restore": "Geri Yükle",
     "settingsAriaLabel": "Bakım ayarları",
     "settingsAriaLabel": "Bakım ayarları",
-    "dismissAriaLabel": "Kapat"
+    "dismissAriaLabel": "Kapat",
+    "dismissError": "Araç kapatılamadı. Tekrar deneyin."
   },
   },
   "reminders": {
   "reminders": {
     "title": "Yaklaşan Hatırlatmalar",
     "title": "Yaklaşan Hatırlatmalar",

+ 1 - 0
messages/tr/onboarding.json

@@ -73,6 +73,7 @@
     "allDoneDescription": "Tüm adımlar tamamlandı ve atölyeniz çalışıyor.",
     "allDoneDescription": "Tüm adımlar tamamlandı ve atölyeniz çalışıyor.",
     "dismiss": "Kartı gizle",
     "dismiss": "Kartı gizle",
     "dismissAriaLabel": "Bu listeyi gizle",
     "dismissAriaLabel": "Bu listeyi gizle",
+    "dismissFailed": "Liste gizlenemedi. Tekrar deneyin.",
     "importTitle": "Müşterileriniz ve araçlarınız zaten bir yerde kayıtlı mı?",
     "importTitle": "Müşterileriniz ve araçlarınız zaten bir yerde kayıtlı mı?",
     "importDescription": "Bunları bir hesap tablosundan veya eski sisteminizden içe aktarın"
     "importDescription": "Bunları bir hesap tablosundan veya eski sisteminizden içe aktarın"
   },
   },

+ 1 - 0
messages/tr/timeTracking.json

@@ -106,6 +106,7 @@
     "deleteBody": "{technician} tarafından {job} işindeki {duration} zaman çizelgesinden kaldırılacak. Bu geri alınamaz.",
     "deleteBody": "{technician} tarafından {job} işindeki {duration} zaman çizelgesinden kaldırılacak. Bu geri alınamaz.",
     "deleteConfirm": "Sil",
     "deleteConfirm": "Sil",
     "deleted": "Kayıt silindi",
     "deleted": "Kayıt silindi",
+    "deleteFailed": "Kayıt silinemedi",
     "stoppedByManager": "Sayaç {minutes} sürede durduruldu",
     "stoppedByManager": "Sayaç {minutes} sürede durduruldu",
     "csv": {
     "csv": {
       "technician": "Teknisyen",
       "technician": "Teknisyen",

+ 5 - 0
messages/tr/vehicles.json

@@ -97,6 +97,7 @@
     "severityRare": "Çok nadir — tek tük bildirimler ancak gerçekleştiğinde kritik",
     "severityRare": "Çok nadir — tek tük bildirimler ancak gerçekleştiğinde kritik",
     "openMenu": "Menüyü aç",
     "openMenu": "Menüyü aç",
     "dismissMaintenance": "Kapat",
     "dismissMaintenance": "Kapat",
+    "maintenanceChangeError": "Değişiklik kaydedilemedi. Tekrar deneyin.",
     "inspectionDue": "Muayene {date}",
     "inspectionDue": "Muayene {date}",
     "inspectionCheckedAt": "Sicilde {date} tarihinde kontrol edildi",
     "inspectionCheckedAt": "Sicilde {date} tarihinde kontrol edildi",
     "notRegistered": "Kayıtlı değil",
     "notRegistered": "Kayıtlı değil",
@@ -256,6 +257,10 @@
     "addToWorkOrder": "İş emrine ekle ({count})",
     "addToWorkOrder": "İş emrine ekle ({count})",
     "dismiss": "Kapat",
     "dismiss": "Kapat",
     "observationsAdded": "{count} gözlem iş emrine eklendi",
     "observationsAdded": "{count} gözlem iş emrine eklendi",
+    "observationsAddFailed": "{count, plural, one {# gözlem} other {# gözlem}} iş emrine eklenemedi. Tekrar deneyin.",
+    "observationsMayRemain": "İş emrine eklendi, ancak bağlantı kesildi. {count} gözlem araçta hâlâ açık olabilir.",
+    "observationsNotRemoved": "İş emri oluşturuldu, ancak gözlemler araçtan kaldırılamadı.",
+    "workOrderFailed": "İş emri oluşturulamadı. Tekrar deneyin.",
     "delete": "Sil",
     "delete": "Sil",
     "findingCreated": "Gözlem eklendi",
     "findingCreated": "Gözlem eklendi",
     "findingUpdated": "Gözlem güncellendi",
     "findingUpdated": "Gözlem güncellendi",

+ 3 - 1
messages/tr/workBoard.json

@@ -81,7 +81,9 @@
     "linkedManagedOnTeam": "Uygulama erişimiyle birlikte Ekip sayfasından yönetilir.",
     "linkedManagedOnTeam": "Uygulama erişimiyle birlikte Ekip sayfasından yönetilir.",
     "skills": "Uzmanlıklar",
     "skills": "Uzmanlıklar",
     "skillsPlaceholder": "Arıza tespiti, elektrikli araç sertifikalı",
     "skillsPlaceholder": "Arıza tespiti, elektrikli araç sertifikalı",
-    "skillsHint": "İşlerin dağıtıldığı yerde adın altında gösterilir. İsteğe bağlı."
+    "skillsHint": "İşlerin dağıtıldığı yerde adın altında gösterilir. İsteğe bağlı.",
+    "saveFailed": "Teknisyen kaydedilemedi. Tekrar deneyin.",
+    "deleteFailed": "Teknisyen silinemedi. Tekrar deneyin."
   },
   },
   "jobDetail": {
   "jobDetail": {
     "viewDetails": "Ayrıntıları Görüntüle",
     "viewDetails": "Ayrıntıları Görüntüle",

+ 128 - 0
src/__tests__/app/settings/team-revoke-app-failure.test.tsx

@@ -0,0 +1,128 @@
+/**
+ * Signing somebody out of the technician app on the team page. That is two
+ * calls: one takes the app off their phone, the next takes them off the
+ * board. The first one's answer used to be ignored, so a refusal or a dropped
+ * connection there still went on to say they had been signed out, while
+ * their phone kept working.
+ */
+import { act, fireEvent, render, screen, within } from '@testing-library/react'
+import { toast } from 'sonner'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { ConfirmProvider } from '@/components/confirm-dialog'
+import { GlassModal, useGlassModal } from '@/components/glass-modal'
+import { TooltipProvider } from '@/components/ui/tooltip'
+
+const removeTechnicianAccess = vi.fn()
+const setMemberTechnician = vi.fn()
+const router = { refresh: vi.fn(), push: vi.fn() }
+
+vi.mock('next/navigation', () => ({ useRouter: () => router }))
+vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
+vi.mock('@/features/team/Actions/removeTechnicianAccess', () => ({
+  removeTechnicianAccess: (input: unknown) => removeTechnicianAccess(input),
+}))
+vi.mock('@/features/team/Actions/setMemberTechnician', () => ({
+  setMemberTechnician: (input: unknown) => setMemberTechnician(input),
+}))
+vi.mock('@/features/team/Actions/teamActions', () => ({
+  createOrganization: vi.fn(),
+  removeMember: vi.fn(),
+}))
+vi.mock('@/features/team/Actions/cancelInvitation', () => ({ cancelInvitation: vi.fn() }))
+vi.mock('@/features/team/Actions/resendInvitation', () => ({ resendInvitation: vi.fn() }))
+vi.mock('@/features/team/Actions/createRole', () => ({ createRole: vi.fn() }))
+vi.mock('@/features/team/Actions/updateRole', () => ({ updateRole: vi.fn() }))
+vi.mock('@/features/team/Actions/deleteRole', () => ({ deleteRole: vi.fn() }))
+vi.mock('@/features/team/Actions/assignRole', () => ({ assignRole: vi.fn() }))
+vi.mock('@/features/team/Actions/createDefaultRoles', () => ({ createDefaultRoles: vi.fn() }))
+vi.mock('@/features/workboard/Actions/technicianActions', () => ({ deleteTechnician: vi.fn() }))
+vi.mock('@/components/upgrade-gate', () => ({ handleGated: () => false }))
+// Dialogs of their own; closed throughout.
+vi.mock('@/features/team/Components/AddPersonDialog', () => ({ AddPersonDialog: () => null }))
+vi.mock('@/features/team/Components/AppSetupCodeDialog', () => ({
+  AppSetupCodeDialog: () => null,
+}))
+vi.mock('@/features/team/Components/GiveAppDialog', () => ({ GiveAppDialog: () => null }))
+
+import { TeamSettings } from '@/app/(authenticated)/settings/team/team-settings'
+
+const MECHANIC = {
+  id: 'm2',
+  role: 'member',
+  roleId: null,
+  customRoleName: null,
+  user: { id: 'u2', name: 'Kari Mekaniker', email: 'kari@example.com' },
+}
+
+function renderPage() {
+  render(
+    <TooltipProvider>
+      <ConfirmProvider>
+        <TeamSettings
+          organization={{ id: 'org1', name: 'Berg Motor', members: [MECHANIC] }}
+          currentRole="owner"
+          technicianUserIds={['u2']}
+        />
+        <GlassModal />
+      </ConfirmProvider>
+    </TooltipProvider>
+  )
+}
+
+async function signOutOfApp() {
+  fireEvent.click(screen.getByRole('button', { name: 'Sign them out of the app?' }))
+  const question = await screen.findByRole('alertdialog')
+  await act(async () => {
+    fireEvent.click(within(question).getByRole('button', { name: 'Sign them out' }))
+  })
+}
+
+describe('signing a mechanic out of the app', () => {
+  beforeEach(() => {
+    removeTechnicianAccess.mockReset()
+    setMemberTechnician.mockReset()
+    setMemberTechnician.mockResolvedValue({ success: true, data: {} })
+    vi.mocked(toast.success).mockClear()
+    router.refresh.mockClear()
+    act(() => useGlassModal.getState().close())
+  })
+
+  it('says they are signed out once both steps went through', async () => {
+    removeTechnicianAccess.mockResolvedValue({ success: true, data: {} })
+    renderPage()
+
+    await signOutOfApp()
+
+    expect(setMemberTechnician).toHaveBeenCalledWith({ userId: 'u2', enabled: false })
+    expect(toast.success).toHaveBeenCalledWith('Kari Mekaniker has been signed out of the app')
+  })
+
+  it('stops and says why when the server refuses to take the app back', async () => {
+    removeTechnicianAccess.mockResolvedValue({
+      success: false,
+      error: 'That technician is not part of this workshop.',
+    })
+    renderPage()
+
+    await signOutOfApp()
+
+    expect(
+      await screen.findByText('That technician is not part of this workshop.')
+    ).toBeInTheDocument()
+    expect(setMemberTechnician).not.toHaveBeenCalled()
+    expect(toast.success).not.toHaveBeenCalled()
+  })
+
+  it('stops and says it could not when the connection drops', async () => {
+    removeTechnicianAccess.mockImplementation(async () => {
+      throw new TypeError('Failed to fetch')
+    })
+    renderPage()
+
+    await signOutOfApp()
+
+    expect(await screen.findByText('Could not change that')).toBeInTheDocument()
+    expect(setMemberTechnician).not.toHaveBeenCalled()
+    expect(toast.success).not.toHaveBeenCalled()
+  })
+})

+ 128 - 0
src/__tests__/app/settings/webhooks-dropped-connection.test.tsx

@@ -0,0 +1,128 @@
+/**
+ * The webhooks page says so when an action does not reach the server, and
+ * stays on screen. An action that cannot reach the server throws instead of
+ * answering; awaited inside a transition, that throw went to the error
+ * boundary and the whole settings page was replaced by the error page.
+ */
+import { Component, type ReactNode } from 'react'
+import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
+import { toast } from 'sonner'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+/** What a dropped connection does to a server action: it throws. */
+const offline = async (..._args: unknown[]) => {
+  throw new TypeError('Failed to fetch')
+}
+
+const toggleWebhook = vi.fn(offline)
+const sendTestWebhook = vi.fn(offline)
+const deleteWebhook = vi.fn(offline)
+const createWebhook = vi.fn(offline)
+const router = { refresh: vi.fn(), push: vi.fn() }
+
+vi.mock('next/navigation', () => ({ useRouter: () => router }))
+vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
+vi.mock('@/features/webhooks/Actions/webhookActions', () => ({
+  createWebhook: (input: unknown) => createWebhook(input),
+  updateWebhook: vi.fn(),
+  toggleWebhook: (id: string) => toggleWebhook(id),
+  deleteWebhook: (id: string) => deleteWebhook(id),
+  rotateWebhookSecret: vi.fn(),
+  sendTestWebhook: (id: string) => sendTestWebhook(id),
+  getWebhookDeliveries: vi.fn(),
+  retryWebhookDelivery: vi.fn(),
+  getDeliveryPayload: vi.fn(),
+}))
+
+import { WebhooksSettings } from '@/app/(authenticated)/settings/webhooks/webhooks-settings'
+
+/** Stands in for the app's error page, so losing the page is visible. */
+class ErrorPage extends Component<{ children: ReactNode }, { failed: boolean }> {
+  state = { failed: false }
+  static getDerivedStateFromError() {
+    return { failed: true }
+  }
+  render() {
+    return this.state.failed ? <p>Something went wrong</p> : this.props.children
+  }
+}
+
+const WEBHOOK = {
+  id: 'w1',
+  name: 'Accounting sync',
+  url: 'https://example.com/hooks/torqvoice',
+  description: null,
+  events: ['*'],
+  isActive: true,
+  autoDisabled: false,
+  lastTriggeredAt: null,
+  lastSuccessAt: null,
+  lastFailureAt: null,
+  failureCount: 0,
+  deliveryCount: 0,
+  createdAt: new Date('2026-11-01T10:00:00Z'),
+}
+
+function renderPage() {
+  render(
+    <ErrorPage>
+      <WebhooksSettings webhooks={[WEBHOOK]} />
+    </ErrorPage>
+  )
+}
+
+function expectPageStillThere() {
+  expect(screen.queryByText('Something went wrong')).not.toBeInTheDocument()
+  expect(screen.getByRole('heading', { name: 'Webhooks', level: 1 })).toBeInTheDocument()
+}
+
+describe('webhooks page on a dropped connection', () => {
+  beforeEach(() => {
+    vi.mocked(toast.error).mockClear()
+    router.refresh.mockClear()
+    vi.spyOn(console, 'error').mockImplementation(() => undefined)
+  })
+
+  it('says the test event failed and keeps the page', async () => {
+    renderPage()
+
+    fireEvent.click(screen.getByRole('button', { name: 'Send test' }))
+
+    await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Failed to send test event'))
+    expect(sendTestWebhook).toHaveBeenCalledWith('w1')
+    expectPageStillThere()
+    expect(screen.getByRole('button', { name: 'Send test' })).toBeEnabled()
+  })
+
+  it('says pausing failed and keeps the page', async () => {
+    renderPage()
+
+    fireEvent.click(screen.getByRole('switch', { name: 'Enable or disable this webhook' }))
+
+    await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Toggle failed'))
+    expectPageStillThere()
+    expect(router.refresh).not.toHaveBeenCalled()
+  })
+
+  it('says saving a new webhook failed and keeps what was typed', async () => {
+    renderPage()
+
+    fireEvent.click(screen.getByRole('button', { name: 'Add webhook' }))
+    const dialog = await screen.findByRole('dialog')
+    fireEvent.change(within(dialog).getByLabelText('Name'), {
+      target: { value: 'Parts feed' },
+    })
+    fireEvent.change(within(dialog).getByLabelText('Endpoint URL'), {
+      target: { value: 'https://example.com/parts' },
+    })
+    fireEvent.click(within(dialog).getByRole('switch'))
+    await act(async () => {
+      fireEvent.click(within(dialog).getByRole('button', { name: 'Add webhook' }))
+    })
+
+    await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Save failed'))
+    expect(createWebhook).toHaveBeenCalled()
+    expect(screen.queryByText('Something went wrong')).not.toBeInTheDocument()
+    expect(within(screen.getByRole('dialog')).getByLabelText('Name')).toHaveValue('Parts feed')
+  })
+})

+ 267 - 0
src/__tests__/app/vehicles/vehicle-detail-dropped-connection.test.tsx

@@ -0,0 +1,267 @@
+/**
+ * The vehicle page when an action does not reach the server.
+ *
+ * Dismissing the predicted maintenance awaited the action inside a
+ * transition, so a dropped connection went to the error boundary and the
+ * whole vehicle page was replaced by the error page.
+ *
+ * Making a work order from observations deleted the observations after the
+ * work order was made and never read the answers: one the server kept stayed
+ * open on the vehicle with nothing said, and a work order that could not be
+ * made on a dropped connection left the button busy.
+ */
+import { Component, type ReactNode } from 'react'
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { toast } from 'sonner'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { GlassModal, useGlassModal } from '@/components/glass-modal'
+
+/** What a dropped connection does to a server action: it throws. */
+const offline = async () => {
+  throw new TypeError('Failed to fetch')
+}
+
+const dismissMaintenance = vi.fn()
+const createServiceRecord = vi.fn()
+const deleteFinding = vi.fn()
+const router = { push: vi.fn(), refresh: vi.fn(), replace: vi.fn() }
+
+vi.mock('next/navigation', () => ({
+  useRouter: () => router,
+  useSearchParams: () => new URLSearchParams('tab=findings'),
+  usePathname: () => '/vehicles/v1',
+}))
+vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
+vi.mock('@/features/vehicles/Actions/dismissMaintenance', () => ({
+  dismissMaintenance: (id: string) => dismissMaintenance(id),
+  undismissMaintenance: vi.fn(),
+}))
+vi.mock('@/features/vehicles/Actions/serviceActions', () => ({
+  createServiceRecord: (input: unknown) => createServiceRecord(input),
+}))
+vi.mock('@/features/vehicles/Actions/findingActions', () => ({
+  deleteFinding: (id: string) => deleteFinding(id),
+}))
+vi.mock('@/features/vehicles/Actions/noteActions', () => ({
+  deleteNote: vi.fn(),
+  toggleNotePin: vi.fn(),
+}))
+vi.mock('@/features/vehicles/Actions/reminderActions', () => ({
+  toggleReminder: vi.fn(),
+  deleteReminder: vi.fn(),
+}))
+vi.mock('@/features/vehicles/Actions/unarchiveVehicle', () => ({ unarchiveVehicle: vi.fn() }))
+vi.mock('@/features/vehicles/Actions/deleteVehicle', () => ({ deleteVehicle: vi.fn() }))
+vi.mock('@/features/ai/Actions/aiActions', () => ({
+  aiSummarizeVehicleHistory: vi.fn(),
+  aiGetCommonIssues: vi.fn(),
+  aiClearMessage: vi.fn(),
+}))
+// Screens and dialogs of their own; the findings list is reduced to the one
+// button that asks for a work order from the open observations.
+vi.mock('@/features/vehicles/Components/VehicleForm', () => ({ VehicleForm: () => null }))
+vi.mock('@/features/vehicles/Components/NoteForm', () => ({ NoteForm: () => null }))
+vi.mock('@/features/vehicles/Components/ReminderForm', () => ({ ReminderForm: () => null }))
+vi.mock('@/features/vehicles/Components/FindingForm', () => ({ FindingForm: () => null }))
+vi.mock('@/features/vehicles/Components/VehicleSafetyPanel', () => ({
+  VehicleSafetyPanel: () => null,
+}))
+vi.mock('@/features/vehicles/Components/ArchiveVehicleDialog', () => ({
+  ArchiveVehicleDialog: () => null,
+}))
+vi.mock('@/features/tire-hotel/Components/VehicleTireSets', () => ({ VehicleTireSets: () => null }))
+vi.mock('@/features/ai/Components/AskAiSheet', () => ({ AskAiSheet: () => null }))
+vi.mock('@/features/inspections/Components/NewInspectionDialog', () => ({
+  NewInspectionDialog: () => null,
+}))
+vi.mock('@/features/quotes/Components/NewQuoteDialog', () => ({ NewQuoteDialog: () => null }))
+vi.mock('@/app/(authenticated)/vehicles/[id]/service-records-table', () => ({
+  ServiceRecordsTable: () => null,
+}))
+vi.mock('@/app/(authenticated)/vehicles/[id]/notes-table', () => ({ NotesTable: () => null }))
+vi.mock('@/app/(authenticated)/vehicles/[id]/findings-table', () => ({
+  FindingsTable: ({
+    onCreateWorkOrder,
+    isCreatingWorkOrder,
+  }: {
+    onCreateWorkOrder: (ids: string[]) => void
+    isCreatingWorkOrder: boolean
+  }) => (
+    <button
+      type="button"
+      disabled={isCreatingWorkOrder}
+      onClick={() => onCreateWorkOrder(['f1', 'f2'])}
+    >
+      Create Work Order (2)
+    </button>
+  ),
+}))
+
+import { VehicleDetailClient } from '@/app/(authenticated)/vehicles/[id]/vehicle-detail-client'
+
+/** Stands in for the app's error page, so losing the page is visible. */
+class ErrorPage extends Component<{ children: ReactNode }, { failed: boolean }> {
+  state = { failed: false }
+  static getDerivedStateFromError() {
+    return { failed: true }
+  }
+  render() {
+    return this.state.failed ? <p>Something went wrong</p> : this.props.children
+  }
+}
+
+const VEHICLE = {
+  id: 'v1',
+  make: 'Volvo',
+  model: 'V70',
+  year: 2019,
+  vin: null,
+  licensePlate: 'EL12345',
+  color: null,
+  mileage: 120000,
+  fuelType: null,
+  transmission: null,
+  engineSize: null,
+  engineCode: null,
+  hsn: null,
+  tsn: null,
+  purchaseDate: null,
+  purchasePrice: null,
+  imageUrl: null,
+  isArchived: false,
+  archiveReason: null,
+  customerId: null,
+  customer: null,
+  serviceRecords: [],
+  reminders: [],
+  aiMessages: [],
+  _count: { serviceRecords: 0, notes: 0, reminders: 0, findings: 2 },
+}
+
+const finding = (id: string, description: string) => ({
+  id,
+  description,
+  severity: 'needs_work',
+  status: 'open',
+  notes: null,
+  imageUrls: [],
+  createdAt: new Date('2026-11-01T09:00:00Z'),
+  serviceRecord: null,
+  resolvedServiceRecord: null,
+})
+
+const empty = { records: [], total: 0, page: 1, pageSize: 20, totalPages: 0 }
+
+function renderPage() {
+  render(
+    <ErrorPage>
+      <VehicleDetailClient
+        vehicle={VEHICLE}
+        paginatedServices={empty}
+        paginatedNotes={empty}
+        paginatedFindings={{
+          records: [finding('f1', 'Front pads worn'), finding('f2', 'Wiper blades torn')],
+          total: 2,
+          page: 1,
+          pageSize: 20,
+          totalPages: 1,
+        }}
+        serviceSearch=""
+        serviceRecordType=""
+        unitSystem="metric"
+        predictionData={{
+          predictedMileage: 131000,
+          avgPerDay: 40,
+          lastServiceMileage: 105000,
+          serviceInterval: 15000,
+          mileageSinceLastService: 15000,
+          status: 'overdue',
+          maintenanceDismissed: false,
+          confidencePercent: 80,
+        }}
+      />
+      <GlassModal />
+    </ErrorPage>
+  )
+}
+
+describe('vehicle page on a dropped connection', () => {
+  beforeEach(() => {
+    dismissMaintenance.mockReset()
+    createServiceRecord.mockReset()
+    deleteFinding.mockReset()
+    router.push.mockClear()
+    vi.mocked(toast.error).mockClear()
+    act(() => useGlassModal.getState().close())
+    vi.spyOn(console, 'error').mockImplementation(() => undefined)
+  })
+
+  it('says dismissing the predicted maintenance failed and keeps the page', async () => {
+    dismissMaintenance.mockImplementation(offline)
+    renderPage()
+
+    await act(async () => {
+      fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }))
+    })
+
+    await waitFor(() =>
+      expect(toast.error).toHaveBeenCalledWith("Couldn't save the change. Try again.")
+    )
+    expect(dismissMaintenance).toHaveBeenCalledWith('v1')
+    expect(screen.queryByText('Something went wrong')).not.toBeInTheDocument()
+    expect(screen.getByRole('button', { name: 'Dismiss' })).toBeEnabled()
+  })
+
+  it('opens the new work order and says the observations it took are still open', async () => {
+    createServiceRecord.mockResolvedValue({ success: true, data: { id: 'job1' } })
+    deleteFinding.mockImplementation(async (id: string) =>
+      id === 'f1' ? { success: true, data: { findingId: id } } : offline()
+    )
+    renderPage()
+
+    await act(async () => {
+      fireEvent.click(screen.getByRole('button', { name: 'Create Work Order (2)' }))
+    })
+
+    expect(deleteFinding).toHaveBeenCalledWith('f1')
+    expect(deleteFinding).toHaveBeenCalledWith('f2')
+    expect(toast.error).toHaveBeenCalledWith(
+      'The work order was created, but the observations could not be removed from the vehicle.'
+    )
+    expect(router.push).toHaveBeenCalledWith('/vehicles/v1/service/job1')
+  })
+
+  it('opens the new work order without a word when every observation was removed', async () => {
+    createServiceRecord.mockResolvedValue({ success: true, data: { id: 'job1' } })
+    deleteFinding.mockImplementation(async (id: string) => ({
+      success: true,
+      data: { findingId: id },
+    }))
+    renderPage()
+
+    await act(async () => {
+      fireEvent.click(screen.getByRole('button', { name: 'Create Work Order (2)' }))
+    })
+
+    expect(router.push).toHaveBeenCalledWith('/vehicles/v1/service/job1')
+    expect(toast.error).not.toHaveBeenCalled()
+  })
+
+  it('says the work order could not be made and leaves the button usable', async () => {
+    createServiceRecord.mockImplementation(offline)
+    renderPage()
+
+    await act(async () => {
+      fireEvent.click(screen.getByRole('button', { name: 'Create Work Order (2)' }))
+    })
+
+    expect(
+      await screen.findByText("Couldn't create the work order. Try again.")
+    ).toBeInTheDocument()
+    expect(deleteFinding).not.toHaveBeenCalled()
+    expect(router.push).not.toHaveBeenCalled()
+    expect(
+      screen.getByRole('button', { name: 'Create Work Order (2)', hidden: true })
+    ).toBeEnabled()
+  })
+})

+ 86 - 0
src/__tests__/features/ai/ask-ai-sheet-delete-chat.test.tsx

@@ -0,0 +1,86 @@
+/**
+ * Deleting an earlier chat under "Previous chats" in Ask AI. The list used to
+ * drop the chat whatever the server answered, so a refused delete looked done
+ * and the chat came back on the next opening; a dropped connection threw
+ * past the handler with nothing said on the sheet.
+ */
+import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
+import { toast } from 'sonner'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const listAskAiChats = vi.fn()
+const deleteAskAiChat = vi.fn()
+
+vi.mock('next/navigation', () => ({ useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }) }))
+vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
+vi.mock('@/features/ai/Actions/aiChatActions', () => ({
+  listAskAiChats: (subject: unknown) => listAskAiChats(subject),
+  loadAskAiChat: vi.fn(),
+  deleteAskAiChat: (id: string) => deleteAskAiChat(id),
+}))
+
+import { AskAiSheet } from '@/features/ai/Components/AskAiSheet'
+
+const CHATS = [
+  { id: 'chat1', title: 'Brake noise history', updatedAt: new Date('2026-11-10T09:00:00Z') },
+]
+
+/** Opens the sheet and its list of earlier chats. */
+async function openPreviousChats() {
+  render(<AskAiSheet subject={{ type: 'vehicle', id: 'v1' }} title="2019 Volvo V70" />)
+  fireEvent.click(screen.getByRole('button', { name: 'Ask AI' }))
+  await waitFor(() => expect(listAskAiChats).toHaveBeenCalled())
+  fireEvent.keyDown(screen.getByRole('button', { name: 'Previous chats' }), { key: 'Enter' })
+  return screen.findByRole('menu')
+}
+
+async function pressDelete(menu: HTMLElement) {
+  await act(async () => {
+    fireEvent.click(within(menu).getByRole('button', { name: 'Delete chat' }))
+  })
+}
+
+describe('deleting an earlier Ask AI chat', () => {
+  beforeEach(() => {
+    listAskAiChats.mockReset()
+    listAskAiChats.mockResolvedValue({ success: true, data: CHATS })
+    deleteAskAiChat.mockReset()
+    vi.mocked(toast.error).mockClear()
+  })
+
+  it('takes the chat off the list once the server has deleted it', async () => {
+    deleteAskAiChat.mockResolvedValue({ success: true, data: true })
+    const menu = await openPreviousChats()
+    await within(menu).findByText('Brake noise history')
+
+    await pressDelete(menu)
+
+    expect(deleteAskAiChat).toHaveBeenCalledWith('chat1')
+    expect(within(menu).queryByText('Brake noise history')).not.toBeInTheDocument()
+    expect(toast.error).not.toHaveBeenCalled()
+  })
+
+  it('keeps the chat listed and says why when the server refuses', async () => {
+    deleteAskAiChat.mockResolvedValue({ success: false, error: 'Insufficient permissions' })
+    const menu = await openPreviousChats()
+    await within(menu).findByText('Brake noise history')
+
+    await pressDelete(menu)
+
+    expect(toast.error).toHaveBeenCalledWith('Insufficient permissions')
+    expect(within(menu).getByText('Brake noise history')).toBeInTheDocument()
+  })
+
+  it('keeps the chat listed and says the delete failed when the connection drops', async () => {
+    deleteAskAiChat.mockImplementation(async () => {
+      throw new TypeError('Failed to fetch')
+    })
+    const menu = await openPreviousChats()
+    await within(menu).findByText('Brake noise history')
+
+    await pressDelete(menu)
+
+    expect(toast.error).toHaveBeenCalledWith("Couldn't delete the chat. Try again.")
+    expect(within(menu).getByText('Brake noise history')).toBeInTheDocument()
+  })
+})

+ 216 - 0
src/__tests__/features/condition-map/mark-change-failure.test.tsx

@@ -0,0 +1,216 @@
+/**
+ * A change to a mark on the condition map shows at once. When it does not go
+ * through, the mark goes back to what it was and the card says so. A request
+ * that never reached the server used to throw inside the card's transition,
+ * which replaced the whole page with the error page.
+ *
+ * Changes that overlap are settled together: two that both fail leave the
+ * mark as it was before the first, whichever answers first, and one that
+ * fails beside another that went through has the card read back from the
+ * server rather than undo the other.
+ */
+import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const updateConditionMark = vi.fn()
+const carryConditionMarks = vi.fn()
+const listVehicleConditionMarks = vi.fn()
+const refresh = vi.fn()
+const toastError = vi.fn()
+
+vi.mock('next/navigation', () => ({ useRouter: () => ({ refresh, push: vi.fn() }) }))
+vi.mock('sonner', () => ({
+  toast: { success: vi.fn(), error: (...args: unknown[]) => toastError(...args) },
+}))
+vi.mock('@/features/condition-map/Actions/conditionMarkActions', () => ({
+  addConditionMark: vi.fn(),
+  addConditionMarkPhotos: vi.fn(),
+  carryConditionMarks: (input: unknown) => carryConditionMarks(input),
+  discardConditionMarkUploads: vi.fn(),
+  listVehicleConditionMarks: (vehicleId: string) => listVehicleConditionMarks(vehicleId),
+  removeConditionMark: vi.fn(),
+  removeConditionMarkPhoto: vi.fn(),
+  resolveConditionMark: vi.fn(),
+  setVehicleBodyType: vi.fn(),
+  updateConditionMark: (id: string, patch: unknown) => updateConditionMark(id, patch),
+}))
+// Asks the browser what kind of device this is; a desk, here.
+vi.mock('@/hooks/use-native-camera', () => ({ useNativeCamera: () => false }))
+
+import { ConditionMapCard } from '@/features/condition-map/Components/ConditionMapCard'
+import { builtinMarkTypes } from '@/features/condition-map/Lib/markTypes'
+import type { ConditionMarkData } from '@/features/condition-map/Lib/marks'
+
+const TYPES = builtinMarkTypes({ dent: 'Dent', scratch: 'Scratch' })
+
+const mark = (overrides: Partial<ConditionMarkData>): ConditionMarkData => ({
+  id: 'm1',
+  vehicleId: 'v1',
+  inspectionId: null,
+  inspectionItemId: null,
+  serviceRecordId: 'job1',
+  bodyType: 'sedan',
+  view: 'top',
+  panel: 'hood',
+  x: 0.5,
+  y: 0.2,
+  kind: 'dent',
+  severity: 'minor',
+  note: null,
+  imageUrls: [],
+  recordedAt: '2026-11-09T09:00:00.000Z',
+  resolvedAt: null,
+  ...overrides,
+})
+
+/** A dent drawn on this job. */
+const OWN = mark({})
+/** A dent from an earlier visit, still open. */
+const EARLIER = mark({
+  id: 'm0',
+  serviceRecordId: 'old-job',
+  panel: 'roof',
+  recordedAt: '2026-10-01T09:00:00.000Z',
+})
+
+const renderCard = (marks: ConditionMarkData[]) =>
+  render(
+    <ConditionMapCard
+      vehicle={{ id: 'v1', bodyType: 'sedan' }}
+      scope={{ serviceRecordId: 'job1' }}
+      types={TYPES}
+      initialMarks={marks}
+    />
+  )
+
+const legend = () => within(screen.getByRole('region', { name: 'Marks' }))
+const openMark = () => fireEvent.click(legend().getByRole('button', { name: /^Mark 1: Dent/ }))
+const severity = (name: 'Minor' | 'Major') => screen.getByRole('button', { name })
+const note = () => screen.getByLabelText('Note')
+
+const gate = () => {
+  let open!: () => void
+  const opened = new Promise<void>((resolve) => {
+    open = resolve
+  })
+  return { opened, open }
+}
+
+describe('a change to the condition map that does not go through', () => {
+  beforeEach(() => {
+    updateConditionMark.mockReset()
+    carryConditionMarks.mockReset()
+    listVehicleConditionMarks.mockReset()
+    refresh.mockClear()
+    toastError.mockClear()
+  })
+
+  it('puts the severity back and says so when the change never reaches the server', async () => {
+    const answer = gate()
+    updateConditionMark.mockImplementation(async () => {
+      await answer.opened
+      throw new TypeError('Failed to fetch')
+    })
+    renderCard([OWN])
+
+    openMark()
+    act(() => {
+      fireEvent.click(severity('Major'))
+    })
+    expect(severity('Major')).toHaveAttribute('aria-pressed', 'true')
+
+    await act(async () => answer.open())
+
+    await waitFor(() => expect(severity('Minor')).toHaveAttribute('aria-pressed', 'true'))
+    expect(updateConditionMark).toHaveBeenCalledWith('m1', { severity: 'major' })
+    expect(toastError).toHaveBeenCalledWith('Could not save the mark')
+  })
+
+  it('puts the earlier marks back when Confirm all never reaches the server', async () => {
+    const answer = gate()
+    carryConditionMarks.mockImplementation(async () => {
+      await answer.opened
+      throw new TypeError('Failed to fetch')
+    })
+    renderCard([EARLIER])
+
+    act(() => {
+      fireEvent.click(screen.getByRole('button', { name: 'Confirm all as still there' }))
+    })
+    expect(
+      screen.queryByRole('region', { name: 'Recorded on an earlier visit' })
+    ).not.toBeInTheDocument()
+
+    await act(async () => answer.open())
+
+    const earlier = await screen.findByRole('region', { name: 'Recorded on an earlier visit' })
+    expect(within(earlier).getByRole('button', { name: /^Mark 1: Dent on the Roof/ })).toBeVisible()
+    expect(legend().getByText(/^No marks yet/)).toBeInTheDocument()
+    expect(toastError).toHaveBeenCalledWith('Could not save the mark')
+  })
+
+  it('leaves the mark as it was when two changes both fail, the first answering first', async () => {
+    const first = gate()
+    const second = gate()
+    updateConditionMark
+      .mockImplementationOnce(async () => {
+        await first.opened
+        return { success: false, error: 'Refused' }
+      })
+      .mockImplementationOnce(async () => {
+        await second.opened
+        return { success: false, error: 'Refused' }
+      })
+    renderCard([OWN])
+
+    openMark()
+    act(() => {
+      fireEvent.click(severity('Major'))
+    })
+    // The note is not held while a change is out; typed and left, it saves.
+    fireEvent.change(note(), { target: { value: 'Deep, down to the metal' } })
+    act(() => {
+      fireEvent.blur(note())
+    })
+    expect(updateConditionMark).toHaveBeenCalledTimes(2)
+
+    await act(async () => first.open())
+    await act(async () => second.open())
+
+    await waitFor(() => expect(severity('Minor')).toHaveAttribute('aria-pressed', 'true'))
+    expect(note()).toHaveValue('')
+    expect(listVehicleConditionMarks).not.toHaveBeenCalled()
+  })
+
+  it('reads the marks back rather than undo a change that went through beside one that failed', async () => {
+    const first = gate()
+    updateConditionMark
+      .mockImplementationOnce(async () => {
+        await first.opened
+        return { success: false, error: 'Refused' }
+      })
+      .mockImplementationOnce(async () => ({ success: true }))
+    // What the server holds: the refused severity, the saved note.
+    listVehicleConditionMarks.mockResolvedValue({
+      success: true,
+      data: [{ ...OWN, note: 'Deep, down to the metal' }],
+    })
+    renderCard([OWN])
+
+    openMark()
+    act(() => {
+      fireEvent.click(severity('Major'))
+    })
+    fireEvent.change(note(), { target: { value: 'Deep, down to the metal' } })
+    await act(async () => {
+      fireEvent.blur(note())
+    })
+
+    await act(async () => first.open())
+
+    await waitFor(() => expect(listVehicleConditionMarks).toHaveBeenCalledWith('v1'))
+    await waitFor(() => expect(severity('Minor')).toHaveAttribute('aria-pressed', 'true'))
+    expect(note()).toHaveValue('Deep, down to the metal')
+    expect(toastError).toHaveBeenCalledWith('Refused')
+  })
+})

+ 134 - 0
src/__tests__/features/inspections/template-list-dropped-connection.test.tsx

@@ -0,0 +1,134 @@
+/**
+ * The checklist list says so when an action does not reach the server, and
+ * stays on screen. An action that cannot reach the server throws instead of
+ * answering; awaited inside a transition, that throw went to the error
+ * boundary and the whole page was replaced by the error page.
+ */
+import { Component, type ReactNode } from 'react'
+import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
+import { toast } from 'sonner'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+/** What a dropped connection does to a server action: it throws. */
+const offline = async (..._args: unknown[]) => {
+  throw new TypeError('Failed to fetch')
+}
+
+const translateChecklists = vi.fn(offline)
+const restoreMissingPresets = vi.fn(offline)
+const deleteTemplate = vi.fn(offline)
+const duplicateTemplate = vi.fn(offline)
+const router = { refresh: vi.fn(), push: vi.fn() }
+
+vi.mock('next/navigation', () => ({ useRouter: () => router }))
+vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
+vi.mock('@/features/inspections/Actions/templateActions', () => ({
+  createTemplate: vi.fn(),
+  updateTemplate: vi.fn(),
+  createTemplateFromPreset: vi.fn(),
+  translateChecklists: (locale: string) => translateChecklists(locale),
+  restoreMissingPresets: () => restoreMissingPresets(),
+  deleteTemplate: (id: string) => deleteTemplate(id),
+  duplicateTemplate: (id: string) => duplicateTemplate(id),
+}))
+vi.mock('@/features/inspections/Actions/packageActions', () => ({
+  exportTemplatePackage: vi.fn(),
+  importTemplatePackage: vi.fn(),
+  previewTemplatePackage: vi.fn(),
+}))
+
+import { TemplateListClient } from '@/features/inspections/Components/TemplateListClient'
+
+/** Stands in for the app's error page, so losing the page is visible. */
+class ErrorPage extends Component<{ children: ReactNode }, { failed: boolean }> {
+  state = { failed: false }
+  static getDerivedStateFromError() {
+    return { failed: true }
+  }
+  render() {
+    return this.state.failed ? <p>Something went wrong</p> : this.props.children
+  }
+}
+
+const TEMPLATE = {
+  id: 't1',
+  name: 'Annual service',
+  description: null,
+  isDefault: false,
+  packageId: null,
+  sections: [],
+}
+
+function renderList(props: Partial<Parameters<typeof TemplateListClient>[0]> = {}) {
+  render(
+    <ErrorPage>
+      <TemplateListClient templates={[TEMPLATE]} checklistLanguage={null} {...props} />
+    </ErrorPage>
+  )
+}
+
+function expectPageStillThere() {
+  expect(screen.queryByText('Something went wrong')).not.toBeInTheDocument()
+  expect(screen.getByRole('heading', { name: 'Inspection checklists' })).toBeInTheDocument()
+}
+
+describe('checklist list on a dropped connection', () => {
+  beforeEach(() => {
+    vi.mocked(toast.error).mockClear()
+    router.refresh.mockClear()
+    vi.spyOn(console, 'error').mockImplementation(() => undefined)
+  })
+
+  it('says the translation failed and keeps the page', async () => {
+    renderList({ checklistLanguage: 'nb' })
+
+    fireEvent.click(screen.getByRole('button', { name: 'Translate' }))
+
+    await waitFor(() =>
+      expect(toast.error).toHaveBeenCalledWith('Could not translate the checklists')
+    )
+    expectPageStillThere()
+    expect(screen.getByRole('button', { name: 'Translate' })).toBeEnabled()
+    expect(router.refresh).not.toHaveBeenCalled()
+  })
+
+  it('says restoring the checklists failed and keeps the page', async () => {
+    renderList({ templates: [] })
+
+    fireEvent.click(screen.getByRole('button', { name: 'Restore checklists' }))
+
+    await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Failed to add the checklists'))
+    expectPageStillThere()
+    expect(screen.getByRole('button', { name: 'Restore checklists' })).toBeEnabled()
+  })
+
+  it('says the delete failed and keeps the page', async () => {
+    renderList()
+
+    fireEvent.keyDown(screen.getByRole('button', { name: 'Actions for Annual service' }), {
+      key: 'Enter',
+    })
+    fireEvent.click(await screen.findByRole('menuitem', { name: 'Delete' }))
+    const question = await screen.findByRole('alertdialog')
+    await act(async () => {
+      fireEvent.click(within(question).getByRole('button', { name: 'Delete' }))
+    })
+
+    await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Failed to delete checklist'))
+    expect(deleteTemplate).toHaveBeenCalledWith('t1')
+    expectPageStillThere()
+    expect(screen.getByText('Annual service')).toBeInTheDocument()
+  })
+
+  it('says the copy failed and keeps the page', async () => {
+    renderList()
+
+    fireEvent.keyDown(screen.getByRole('button', { name: 'Actions for Annual service' }), {
+      key: 'Enter',
+    })
+    fireEvent.click(await screen.findByRole('menuitem', { name: 'Duplicate' }))
+
+    await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Failed to duplicate checklist'))
+    expectPageStillThere()
+  })
+})

+ 155 - 0
src/__tests__/features/onboarding/onboarding-cards-dropped-connection.test.tsx

@@ -0,0 +1,155 @@
+/**
+ * The getting-started card and the sample data card when an action does not
+ * reach the server. An action that cannot reach the server throws instead of
+ * answering. Hiding the checklist awaited it inside a transition, so the
+ * throw went to the error boundary and the dashboard was replaced by the
+ * error page; a refusal hid nothing and said nothing. Removing the sample
+ * data left its button spinning until the page was reloaded.
+ */
+import { Component, type ReactNode } from 'react'
+import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
+import { toast } from 'sonner'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { ConfirmProvider } from '@/components/confirm-dialog'
+import { TooltipProvider } from '@/components/ui/tooltip'
+
+/** What a dropped connection does to a server action: it throws. */
+const offline = async () => {
+  throw new TypeError('Failed to fetch')
+}
+
+const dismissOnboardingChecklist = vi.fn(offline)
+const removeSampleData = vi.fn(offline)
+const router = { refresh: vi.fn(), push: vi.fn() }
+
+vi.mock('next/navigation', () => ({ useRouter: () => router }))
+vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
+vi.mock('@/features/onboarding/Actions/checklistActions', () => ({
+  dismissOnboardingChecklist: () => dismissOnboardingChecklist(),
+  removeSampleData: () => removeSampleData(),
+}))
+
+import { GettingStartedCard } from '@/features/onboarding/Components/GettingStartedCard'
+import { SampleDataCard } from '@/features/onboarding/Components/SampleDataCard'
+
+/** Stands in for the app's error page, so losing the page is visible. */
+class ErrorPage extends Component<{ children: ReactNode }, { failed: boolean }> {
+  state = { failed: false }
+  static getDerivedStateFromError() {
+    return { failed: true }
+  }
+  render() {
+    return this.state.failed ? <p>Something went wrong</p> : this.props.children
+  }
+}
+
+const CHECKLIST = {
+  steps: { workOrder: true, company: false, invoice: false },
+  allDone: false,
+  hasSampleData: true,
+  workshopName: 'Berg Motor',
+  invoiceHref: '/work-orders',
+}
+
+function renderChecklist() {
+  render(
+    <ErrorPage>
+      <TooltipProvider>
+        <ConfirmProvider>
+          <GettingStartedCard data={CHECKLIST} />
+        </ConfirmProvider>
+      </TooltipProvider>
+    </ErrorPage>
+  )
+}
+
+/** Answers the question on screen with its destructive button. */
+async function confirmRemove() {
+  const question = await screen.findByRole('alertdialog')
+  await act(async () => {
+    fireEvent.click(within(question).getByRole('button', { name: 'Remove sample data' }))
+  })
+}
+
+describe('getting-started card on a dropped connection', () => {
+  beforeEach(() => {
+    dismissOnboardingChecklist.mockReset()
+    dismissOnboardingChecklist.mockImplementation(offline)
+    removeSampleData.mockReset()
+    removeSampleData.mockImplementation(offline)
+    vi.mocked(toast.error).mockClear()
+    vi.mocked(toast.success).mockClear()
+    router.refresh.mockClear()
+    vi.spyOn(console, 'error').mockImplementation(() => undefined)
+  })
+
+  it('says hiding the checklist failed and keeps the dashboard', async () => {
+    renderChecklist()
+
+    await act(async () => {
+      fireEvent.click(screen.getByRole('button', { name: 'Hide this checklist' }))
+    })
+
+    await waitFor(() =>
+      expect(toast.error).toHaveBeenCalledWith("Couldn't hide the checklist. Try again.")
+    )
+    expect(dismissOnboardingChecklist).toHaveBeenCalled()
+    expect(screen.queryByText('Something went wrong')).not.toBeInTheDocument()
+    expect(screen.getByText('Getting started')).toBeInTheDocument()
+    expect(router.refresh).not.toHaveBeenCalled()
+  })
+
+  it('says so when the server refuses to hide the checklist', async () => {
+    dismissOnboardingChecklist.mockImplementation(async () => {
+      return { success: false, error: 'You do not have permission to do that' } as never
+    })
+    renderChecklist()
+
+    await act(async () => {
+      fireEvent.click(screen.getByRole('button', { name: 'Hide this checklist' }))
+    })
+
+    await waitFor(() =>
+      expect(toast.error).toHaveBeenCalledWith('You do not have permission to do that')
+    )
+    expect(router.refresh).not.toHaveBeenCalled()
+  })
+
+  it('says removing the sample data failed and leaves its button usable', async () => {
+    renderChecklist()
+
+    fireEvent.click(screen.getByRole('button', { name: 'Remove sample data' }))
+    await confirmRemove()
+
+    await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Could not remove sample data'))
+    expect(removeSampleData).toHaveBeenCalled()
+    expect(toast.success).not.toHaveBeenCalled()
+    expect(screen.getByRole('button', { name: 'Remove sample data' })).toBeEnabled()
+  })
+})
+
+describe('sample data card on a dropped connection', () => {
+  beforeEach(() => {
+    removeSampleData.mockReset()
+    removeSampleData.mockImplementation(offline)
+    vi.mocked(toast.error).mockClear()
+    vi.mocked(toast.success).mockClear()
+    router.refresh.mockClear()
+  })
+
+  it('says removing the sample data failed and leaves the button usable', async () => {
+    render(
+      <ConfirmProvider>
+        <SampleDataCard />
+      </ConfirmProvider>
+    )
+
+    fireEvent.click(screen.getByRole('button', { name: 'Remove sample data' }))
+    await confirmRemove()
+
+    await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Could not remove sample data'))
+    expect(toast.success).not.toHaveBeenCalled()
+    expect(router.refresh).not.toHaveBeenCalled()
+    expect(screen.getByRole('button', { name: 'Remove sample data' })).toBeEnabled()
+  })
+})

+ 156 - 0
src/__tests__/features/sms/sms-conversation-dropped-connection.test.tsx

@@ -0,0 +1,156 @@
+/**
+ * An SMS thread on a dropped connection. A server action that cannot reach
+ * the server throws instead of answering. The send ran in a transition, so
+ * the throw went to the error boundary: the messages page was replaced by
+ * the error page and the text, already cleared from the box, was gone. A
+ * delete that threw left the dialog's Delete busy until the page was
+ * reloaded, and said nothing.
+ */
+import { Component, type ReactNode } from 'react'
+import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
+import { toast } from 'sonner'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+/** What a dropped connection does to a server action: it throws. */
+const offline = async () => {
+  throw new TypeError('Failed to fetch')
+}
+
+const sendSmsToCustomer = vi.fn()
+const getConversation = vi.fn()
+const deleteSmsMessage = vi.fn()
+
+vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
+vi.mock('@/features/sms/Actions/smsActions', () => ({
+  sendSmsToCustomer: (input: unknown) => sendSmsToCustomer(input),
+  getConversation: (...args: unknown[]) => getConversation(...args),
+  deleteSmsMessage: (id: string) => deleteSmsMessage(id),
+}))
+// Reads the workshop's saved texts on its own; not what is under test.
+vi.mock('@/features/sms/Components/SmsTemplateMenu', () => ({ SmsTemplateMenu: () => null }))
+
+import { SmsConversation } from '@/features/sms/Components/SmsConversation'
+
+/** Stands in for the app's error page, so losing the page is visible. */
+class ErrorPage extends Component<{ children: ReactNode }, { failed: boolean }> {
+  state = { failed: false }
+  static getDerivedStateFromError() {
+    return { failed: true }
+  }
+  render() {
+    return this.state.failed ? <p>Something went wrong</p> : this.props.children
+  }
+}
+
+const SENT = {
+  id: 'm1',
+  direction: 'outbound',
+  body: 'Your car is booked for Monday',
+  status: 'delivered',
+  createdAt: '2026-11-10T09:00:00.000Z',
+  fromNumber: '+4790000001',
+  toNumber: '+4790000000',
+}
+
+function renderThread(initialMessages = [SENT]) {
+  render(
+    <ErrorPage>
+      <SmsConversation
+        customerId="c1"
+        customerName="Ada Berg"
+        customerPhone="+4790000000"
+        initialMessages={initialMessages}
+        initialNextCursor={null}
+      />
+    </ErrorPage>
+  )
+}
+
+const box = () => screen.getByPlaceholderText('Message Ada Berg...')
+const sendButton = () => screen.getByRole('button', { name: 'Send message' })
+
+async function typeAndSend(text: string) {
+  fireEvent.change(box(), { target: { value: text } })
+  await act(async () => {
+    fireEvent.click(sendButton())
+  })
+}
+
+describe('SMS thread on a dropped connection', () => {
+  beforeEach(() => {
+    sendSmsToCustomer.mockReset()
+    getConversation.mockReset()
+    deleteSmsMessage.mockReset()
+    vi.mocked(toast.error).mockClear()
+    vi.mocked(toast.success).mockClear()
+    vi.spyOn(console, 'error').mockImplementation(() => undefined)
+    // jsdom has no scrolling; a new message scrolls the thread to the end.
+    Element.prototype.scrollTo = vi.fn()
+  })
+
+  it('says the message was not sent, keeps the text, and sends it on the next try', async () => {
+    sendSmsToCustomer.mockImplementation(offline)
+    renderThread()
+
+    await typeAndSend('Your car is ready')
+
+    await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Failed to send SMS'))
+    expect(screen.queryByText('Something went wrong')).not.toBeInTheDocument()
+    expect(box()).toHaveValue('Your car is ready')
+    expect(sendButton()).toBeEnabled()
+
+    // The connection is back.
+    sendSmsToCustomer.mockResolvedValue({ success: true })
+    getConversation.mockResolvedValue({
+      success: true,
+      data: {
+        messages: [SENT, { ...SENT, id: 'm2', body: 'Your car is ready' }],
+        nextCursor: null,
+      },
+    })
+    await act(async () => {
+      fireEvent.click(sendButton())
+    })
+
+    expect(sendSmsToCustomer).toHaveBeenCalledTimes(2)
+    expect(sendSmsToCustomer).toHaveBeenLastCalledWith({
+      customerId: 'c1',
+      body: 'Your car is ready',
+    })
+    await waitFor(() => expect(box()).toHaveValue(''))
+    expect(screen.getByText('Your car is ready')).toBeInTheDocument()
+  })
+
+  it('keeps the page when the message went but the thread cannot be read back', async () => {
+    sendSmsToCustomer.mockResolvedValue({ success: true })
+    getConversation.mockImplementation(offline)
+    renderThread()
+
+    await typeAndSend('Your car is ready')
+
+    await waitFor(() => expect(getConversation).toHaveBeenCalled())
+    expect(screen.queryByText('Something went wrong')).not.toBeInTheDocument()
+    expect(box()).toHaveValue('')
+    expect(toast.error).not.toHaveBeenCalled()
+  })
+
+  it('says the delete failed and leaves the message there to delete again', async () => {
+    deleteSmsMessage.mockImplementation(offline)
+    renderThread()
+
+    fireEvent.click(screen.getByTitle('Delete message'))
+    const question = await screen.findByRole('alertdialog')
+    await act(async () => {
+      fireEvent.click(within(question).getByRole('button', { name: 'Delete' }))
+    })
+
+    expect(deleteSmsMessage).toHaveBeenCalledWith('m1')
+    await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Failed to delete message'))
+    expect(screen.getByText('Your car is booked for Monday')).toBeInTheDocument()
+
+    // Not left busy: asking again offers a Delete that can be pressed.
+    fireEvent.click(screen.getByTitle('Delete message'))
+    const again = await screen.findByRole('alertdialog')
+    expect(within(again).getByRole('button', { name: 'Delete' })).toBeEnabled()
+  })
+})

+ 67 - 0
src/__tests__/features/team/app-setup-code-quiet-revoke.test.tsx

@@ -0,0 +1,67 @@
+/**
+ * Closing the setup code dialog revokes the code in the background. Nobody
+ * asked for that, and the code expires on its own, so a revoke that cannot
+ * reach the server must not be left unhandled: an unhandled network failure
+ * right after a click is what raises the "Couldn't reach Torqvoice" notice,
+ * which would then claim the person's own change was lost.
+ */
+import { act, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+/** Who was asked to revoke; the answer itself comes from the action below. */
+const revokeAppSetupCode = vi.fn()
+
+vi.mock('@/features/team/Actions/createAppSetupCode', () => ({
+  // Still on its way; the dialog is closed before the code arrives.
+  createAppSetupCode: () => new Promise(() => undefined),
+  // A promise of its own, not one a mock returns: a mock watches its own
+  // promises to record how they settled, and that would count as handling
+  // the failure.
+  revokeAppSetupCode: async (input: unknown) => {
+    revokeAppSetupCode(input)
+    throw new TypeError('Failed to fetch')
+  },
+}))
+vi.mock('@/features/team/hooks/useTechnicianConnected', () => ({
+  useTechnicianConnected: vi.fn(),
+}))
+// The code and its QR; not what this is about.
+vi.mock('@/features/team/Components/SetupCodeHandoff', () => ({ SetupCodeHandoff: () => null }))
+
+import { AppSetupCodeDialog } from '@/features/team/Components/AppSetupCodeDialog'
+
+describe('closing the setup code dialog on a dropped connection', () => {
+  const unhandled = vi.fn()
+
+  beforeEach(() => {
+    unhandled.mockClear()
+    revokeAppSetupCode.mockClear()
+    process.on('unhandledRejection', unhandled)
+  })
+
+  afterEach(() => {
+    process.off('unhandledRejection', unhandled)
+  })
+
+  it('closes, and leaves nothing behind to raise the lost-connection notice', async () => {
+    const onClose = vi.fn()
+    render(
+      <AppSetupCodeDialog
+        userId="u2"
+        memberName="Kari Mekaniker"
+        workshopUrl="https://app.example.com"
+        onClose={onClose}
+      />
+    )
+
+    await act(async () => {
+      fireEvent.click(screen.getByRole('button', { name: 'Done' }))
+      // Long enough for an unhandled rejection to be reported.
+      await new Promise((resolve) => setTimeout(resolve, 20))
+    })
+
+    expect(revokeAppSetupCode).toHaveBeenCalledWith({ userId: 'u2' })
+    expect(onClose).toHaveBeenCalled()
+    expect(unhandled).not.toHaveBeenCalled()
+  })
+})

+ 111 - 0
src/__tests__/features/telegram/telegram-thread-switch.test.tsx

@@ -0,0 +1,111 @@
+/**
+ * Picking another customer in the Telegram inbox. The previous customer's
+ * thread stayed on screen when the new one could not be loaded, under the
+ * new customer's selection, with a composer that sends to the new customer.
+ * An answer for an earlier pick that arrives late is dropped as well.
+ */
+import { act, fireEvent, render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const getTelegramConversation = vi.fn()
+
+vi.mock('next/navigation', () => ({
+  useRouter: () => ({ replace: vi.fn(), refresh: vi.fn(), push: vi.fn() }),
+  useSearchParams: () => new URLSearchParams(),
+}))
+vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
+vi.mock('@/features/telegram/Actions/telegramActions', () => ({
+  getTelegramConversation: async (id: string) => getTelegramConversation(id),
+  deleteTelegramConversation: vi.fn(),
+}))
+vi.mock('@/features/telegram/Actions/telegramThreadActions', () => ({
+  getRecentTelegramThreads: vi.fn(),
+}))
+// The thread list and the composer have their own tests; here they only show
+// which customer is picked and whose thread is open.
+vi.mock('@/features/telegram/Components/TelegramThreadList', () => ({
+  TelegramThreadList: ({
+    threads,
+    onSelectThread,
+  }: {
+    threads: { customerId: string; customerName: string }[]
+    onSelectThread: (id: string) => void
+  }) => (
+    <div>
+      {threads.map((th) => (
+        <button key={th.customerId} type="button" onClick={() => onSelectThread(th.customerId)}>
+          {th.customerName}
+        </button>
+      ))}
+    </div>
+  ),
+}))
+vi.mock('@/features/telegram/Components/TelegramConversation', () => ({
+  TelegramConversation: ({
+    customerId,
+    customerName,
+  }: {
+    customerId: string
+    customerName: string
+  }) => <p>{`Thread of ${customerName}, replies go to ${customerId}`}</p>,
+}))
+
+import { TelegramMessagesClient } from '@/features/telegram/Components/TelegramMessagesClient'
+
+const thread = (customerId: string, customerName: string) => ({
+  customerId,
+  customerName,
+  telegramChatId: `chat-${customerId}`,
+  lastMessage: { id: `m-${customerId}`, body: 'Hi', direction: 'in', createdAt: new Date() },
+})
+
+const THREADS = [thread('a', 'Ada'), thread('b', 'Ben')]
+const ANSWER = { success: true, data: { messages: [], nextCursor: null } }
+
+async function pick(name: string) {
+  await act(async () => {
+    fireEvent.click(screen.getByRole('button', { name }))
+  })
+}
+
+describe('switching customers in the Telegram inbox', () => {
+  beforeEach(() => {
+    getTelegramConversation.mockReset()
+  })
+
+  it("does not leave the previous customer's thread open when the next one cannot be loaded", async () => {
+    getTelegramConversation.mockImplementation(async (id: string) =>
+      id === 'a' ? ANSWER : { success: false, error: 'Forbidden' }
+    )
+    render(<TelegramMessagesClient initialThreads={THREADS} />)
+
+    await pick('Ada')
+    expect(screen.getByText('Thread of Ada, replies go to a')).toBeInTheDocument()
+
+    await pick('Ben')
+    expect(screen.queryByText(/Thread of Ada/)).not.toBeInTheDocument()
+    expect(screen.queryByText(/replies go to b/)).not.toBeInTheDocument()
+  })
+
+  it('drops the answer for an earlier pick that arrives after the next one', async () => {
+    let answerAda!: (value: unknown) => void
+    getTelegramConversation.mockImplementation((id: string) =>
+      id === 'a'
+        ? new Promise((resolve) => {
+            answerAda = resolve
+          })
+        : Promise.resolve(ANSWER)
+    )
+    render(<TelegramMessagesClient initialThreads={THREADS} />)
+
+    await pick('Ada')
+    await pick('Ben')
+    expect(screen.getByText('Thread of Ben, replies go to b')).toBeInTheDocument()
+
+    await act(async () => {
+      answerAda(ANSWER)
+    })
+    expect(screen.getByText('Thread of Ben, replies go to b')).toBeInTheDocument()
+    expect(screen.queryByText(/Thread of Ada/)).not.toBeInTheDocument()
+  })
+})

+ 307 - 0
src/__tests__/features/vehicles/add-observations-to-work-order.test.tsx

@@ -0,0 +1,307 @@
+/**
+ * "Add to Work Order" on a work order: open observations from earlier visits
+ * become lines on this job and leave the vehicle's list. The deletes used to
+ * go unread, so an observation the server kept was both a line here and still
+ * open on the vehicle, under a toast that said all of them were added. The
+ * lines still go on first, so an observation whose removal went through
+ * unanswered is never lost; only a definite refusal takes its line back.
+ *
+ * The page is large; everything around the observations is stood in for, and
+ * the job's lines are drawn from the form state the page hands its details.
+ */
+import { useState } from 'react'
+import { act, fireEvent, render, screen } from '@testing-library/react'
+import { toast } from 'sonner'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const deleteFinding = vi.fn()
+const router = { push: vi.fn(), refresh: vi.fn(), replace: vi.fn() }
+
+vi.mock('next/navigation', () => ({ useRouter: () => router }))
+vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
+vi.mock('@/features/vehicles/Actions/findingActions', () => ({
+  deleteFinding: (id: string) => deleteFinding(id),
+}))
+vi.mock('@/features/vehicles/Actions/serviceActions', () => ({
+  updateServiceRecordTitle: vi.fn(),
+  updateServiceStatus: vi.fn(),
+  updateServiceRecord: vi.fn(),
+}))
+vi.mock('@/features/email/Actions/emailActions', () => ({ sendInvoiceEmail: vi.fn() }))
+vi.mock('@/features/settings/Actions/documentLockActions', () => ({
+  setInvoiceEditUnlocked: vi.fn(),
+}))
+vi.mock('@/features/inventory/Actions/lookupPartByBarcode', () => ({
+  lookupPartByBarcode: vi.fn(),
+}))
+vi.mock('@/features/sms/Actions/smsActions', () => ({ getSmsTemplates: vi.fn() }))
+vi.mock('@/lib/analytics', () => ({ track: vi.fn() }))
+vi.mock('@/components/confirm-dialog', () => ({ useConfirm: () => vi.fn() }))
+vi.mock('@/hooks/use-hardware-scanner', () => ({ useHardwareScanner: () => undefined }))
+vi.mock('@/hooks/use-save-shortcut', () => ({ useSaveShortcut: () => undefined }))
+vi.mock('@/features/realtime/hooks', () => ({ useLiveRecord: () => undefined }))
+
+const { passThrough } = vi.hoisted(() => ({
+  passThrough: ({ children }: { children?: React.ReactNode }) => children,
+}))
+vi.mock('@/features/settings/Components/ShopFeeContext', () => ({ ShopFeeProvider: passThrough }))
+vi.mock('@/features/settings/Components/LineTaxContext', () => ({ LineTaxProvider: passThrough }))
+vi.mock('@/features/settings/Components/WorkshopTaxOffer', () => ({
+  useTaxOfferValue: () => null,
+  WorkshopTaxOfferProvider: passThrough,
+}))
+vi.mock('@/features/fault-codes/Components/FaultCodesOnJob', () => ({
+  FaultCodesJobProvider: passThrough,
+}))
+vi.mock('@/components/document-lock-banner', () => ({ DocumentLockBanner: () => null }))
+vi.mock('@/features/vehicles/Components/service-page/InvoiceDesignMenu', () => ({
+  InvoiceDesignMenu: () => null,
+}))
+vi.mock('@/features/vehicles/Components/service-page/PayCodeButton', () => ({
+  PayCodeButton: () => null,
+}))
+vi.mock('@/features/email/Components/SendEmailDialog', () => ({ SendEmailDialog: () => null }))
+vi.mock('@/features/vehicles/Components/service-detail/ImageCarousel', () => ({
+  ImageCarousel: () => null,
+}))
+vi.mock('@/features/vehicles/Components/service-detail/ShareDialog', () => ({
+  ShareDialog: () => null,
+}))
+vi.mock('@/components/notify-customer-dialog', () => ({ NotifyCustomerDialog: () => null }))
+vi.mock('@/components/pdf-preview-dialog', () => ({ PdfPreviewDialog: () => null }))
+vi.mock('@/features/vehicles/Components/service-edit/InventoryPickerDialog', () => ({
+  InventoryPickerDialog: () => null,
+}))
+vi.mock('@/components/barcode-scanner-dialog', () => ({ BarcodeScannerDialog: () => null }))
+vi.mock('@/features/labor-presets/Components/LaborPresetPickerDialog', () => ({
+  LaborPresetPickerDialog: () => null,
+}))
+vi.mock('@/features/status-reports/Components/StatusReportList', () => ({
+  StatusReportList: () => null,
+}))
+vi.mock('@/features/vehicles/Components/service-page/ServiceHeaderActions', () => ({
+  ServiceHeaderActions: () => null,
+}))
+vi.mock('@/features/vehicles/Components/service-page/LaborAddedBanner', () => ({
+  LaborAddedBanner: () => null,
+}))
+vi.mock('@/features/vehicles/Components/service-page/LeaveUnsavedDialog', () => ({
+  LeaveUnsavedDialog: () => null,
+}))
+vi.mock('@/features/realtime/Components/PresenceChips', () => ({ PresenceChips: () => null }))
+vi.mock('@/features/vehicles/Components/service-page/modern/ModernHero', () => ({
+  ModernHero: () => null,
+}))
+
+type Line = { description: string }
+
+/** The job's lines, as the details would list them. */
+vi.mock('@/features/vehicles/Components/service-page/modern/ModernDetails', () => ({
+  ModernDetails: ({ formState }: { formState: { laborItems: Line[] } }) => (
+    <ul aria-label="Labor">
+      {formState.laborItems.map((line) => (
+        <li key={line.description}>{line.description}</li>
+      ))}
+    </ul>
+  ),
+}))
+
+/** The vehicle's open observations, all ticked, and the button that adds them. */
+vi.mock('@/features/vehicles/Components/service-page/ObservationsManager', () => ({
+  ObservationsManager: ({
+    openObservations,
+    onAddObservations,
+    addingObservations,
+  }: {
+    openObservations: { id: string }[]
+    onAddObservations: (ids: string[]) => Promise<void>
+    addingObservations: boolean
+  }) => (
+    <button
+      type="button"
+      disabled={addingObservations}
+      onClick={() => onAddObservations(openObservations.map((o) => o.id))}
+    >
+      Add to Work Order ({openObservations.length})
+    </button>
+  ),
+}))
+
+/** The form state, with real lines and nothing else the page needs here. */
+vi.mock('@/features/vehicles/Components/service-page/useServiceFormState', () => ({
+  useServiceFormState: () => {
+    const [laborItems, setLaborItems] = useState<Line[]>([])
+    return {
+      laborItems,
+      dirtySetLaborItems: setLaborItems,
+      dirtySetPartItems: vi.fn(),
+      dirtySetType: vi.fn(),
+      concerns: [],
+      status: 'pending',
+      paymentStatus: 'unpaid',
+      taxRate: 0,
+      type: 'repair',
+      warranty: null,
+      vehicleName: '2019 Volvo V70',
+      hasUnsavedChanges: false,
+      isDirty: () => false,
+      loading: false,
+      showSaved: false,
+      balanceDue: 0,
+      formRef: { current: null },
+      blockedLeave: null,
+      laborAddedElsewhere: [],
+      partsAddedElsewhere: [],
+      imageAttachments: [],
+      showPresetPicker: false,
+      showInventoryPicker: false,
+      showBarcodeScanner: false,
+      setShowPresetPicker: vi.fn(),
+      setShowInventoryPicker: vi.fn(),
+      setShowBarcodeScanner: vi.fn(),
+      setStatus: vi.fn(),
+      markDirty: vi.fn(),
+      flashSaved: vi.fn(),
+      saveNow: vi.fn(),
+      stayOnPage: vi.fn(),
+      leaveWithoutSaving: vi.fn(),
+      applyLaborAddedElsewhere: vi.fn(),
+      applyPartsAddedElsewhere: vi.fn(),
+    }
+  },
+}))
+vi.mock('@/features/vehicles/Components/service-page/useServiceActions', () => ({
+  useServiceActions: () => ({
+    carouselIndex: null,
+    setCarouselIndex: vi.fn(),
+    downloading: false,
+    handleDelete: vi.fn(),
+    handleDownloadPDF: vi.fn(),
+    handleSubmit: vi.fn(),
+    saveNow: vi.fn(),
+    paymentNotifyMessage: '',
+    showPaymentNotifyDialog: false,
+    setShowPaymentNotifyDialog: vi.fn(),
+    showEmailDialog: false,
+    setShowEmailDialog: vi.fn(),
+    showShareDialog: false,
+    setShowShareDialog: vi.fn(),
+  }),
+}))
+
+import { ServicePageClient } from '@/features/vehicles/Components/service-page/ServicePageClient'
+
+const observation = (id: string, description: string) => ({
+  id,
+  description,
+  severity: 'needs_work',
+  notes: null,
+  serviceRecordId: 'earlier-job',
+})
+
+function renderPage() {
+  const props = {
+    record: {
+      id: 'job1',
+      title: 'Annual service',
+      customer: null,
+      vehicle: { year: 2019, make: 'Volvo', model: 'V70', customer: null },
+      publicToken: null,
+    },
+    vehicleId: 'v1',
+    organizationId: 'org1',
+    lockState: { locked: false, unlockedAt: null },
+    canUnlock: false,
+    currencyCode: 'NOK',
+    unitSystem: 'metric',
+    initialData: { invoiceDate: null, invoiceDueDate: null },
+    openObservations: [
+      observation('o1', 'Front pads worn'),
+      observation('o2', 'Wiper blades torn'),
+    ],
+  }
+  // Only what this part of the page reads is given.
+  render(<ServicePageClient {...(props as unknown as Parameters<typeof ServicePageClient>[0])} />)
+}
+
+const addAll = () =>
+  act(async () => {
+    fireEvent.click(screen.getByRole('button', { name: 'Add to Work Order (2)' }))
+  })
+
+const lines = () =>
+  Array.from(screen.getByRole('list', { name: 'Labor' }).querySelectorAll('li')).map(
+    (li) => li.textContent
+  )
+
+describe('adding open observations to a work order', () => {
+  beforeEach(() => {
+    deleteFinding.mockReset()
+    router.refresh.mockClear()
+    vi.mocked(toast.success).mockClear()
+    vi.mocked(toast.error).mockClear()
+  })
+
+  it('adds every observation the vehicle let go of', async () => {
+    deleteFinding.mockImplementation(async (id: string) => ({
+      success: true,
+      data: { findingId: id },
+    }))
+    renderPage()
+
+    await addAll()
+
+    expect(lines()).toEqual(['Front pads worn', 'Wiper blades torn'])
+    expect(toast.success).toHaveBeenCalledWith('2 observations added to work order')
+    expect(toast.error).not.toHaveBeenCalled()
+  })
+
+  it('keeps a line whose removal was never answered, and says it may still be open', async () => {
+    deleteFinding.mockImplementation(async (id: string) => {
+      if (id === 'o1') return { success: true, data: { findingId: id } }
+      throw new TypeError('Failed to fetch')
+    })
+    renderPage()
+
+    await addAll()
+
+    expect(lines()).toEqual(['Front pads worn', 'Wiper blades torn'])
+    expect(toast.success).toHaveBeenCalledWith('2 observations added to work order')
+    expect(toast.error).toHaveBeenCalledWith(
+      'Added to the work order, but the connection dropped. 1 observation may still be open on the vehicle.'
+    )
+  })
+
+  it('takes back the line of an observation the server kept', async () => {
+    deleteFinding.mockImplementation(async (id: string) =>
+      id === 'o1'
+        ? { success: true, data: { findingId: id } }
+        : { success: false, error: 'Insufficient permissions' }
+    )
+    renderPage()
+
+    await addAll()
+
+    expect(lines()).toEqual(['Front pads worn'])
+    expect(toast.success).toHaveBeenCalledWith('1 observations added to work order')
+    expect(toast.error).toHaveBeenCalledWith(
+      "Couldn't add 1 observation to the work order. Try again.",
+      { description: 'Insufficient permissions' }
+    )
+  })
+
+  it('adds nothing and says why when the server refuses them all', async () => {
+    deleteFinding.mockResolvedValue({ success: false, error: 'Insufficient permissions' })
+    renderPage()
+
+    await addAll()
+
+    expect(lines()).toEqual([])
+    expect(toast.success).not.toHaveBeenCalled()
+    expect(toast.error).toHaveBeenCalledWith(
+      "Couldn't add 2 observations to the work order. Try again.",
+      { description: 'Insufficient permissions' }
+    )
+    expect(screen.getByRole('button', { name: 'Add to Work Order (2)' })).toBeEnabled()
+  })
+})

+ 93 - 0
src/__tests__/features/vehicles/archive-vehicle-dropped-connection.test.tsx

@@ -0,0 +1,93 @@
+/**
+ * Archiving a vehicle on a dropped connection. The action throws instead of
+ * answering when it cannot reach the server, and the dialog only read the
+ * answer: its error was never shown and both its buttons stayed disabled
+ * until the page was reloaded.
+ */
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { toast } from 'sonner'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+/** What a dropped connection does to a server action: it throws. */
+const offline = async () => {
+  throw new TypeError('Failed to fetch')
+}
+
+const archiveVehicle = vi.fn()
+const router = { push: vi.fn(), refresh: vi.fn() }
+
+vi.mock('next/navigation', () => ({ useRouter: () => router }))
+vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
+vi.mock('@/features/vehicles/Actions/archiveVehicle', () => ({
+  archiveVehicle: (id: string, reason?: string) => archiveVehicle(id, reason),
+}))
+
+import { ArchiveVehicleDialog } from '@/features/vehicles/Components/ArchiveVehicleDialog'
+
+function renderDialog() {
+  const onOpenChange = vi.fn()
+  render(
+    <ArchiveVehicleDialog
+      open
+      onOpenChange={onOpenChange}
+      vehicleId="v1"
+      vehicleName="Volvo XC60"
+    />
+  )
+  return onOpenChange
+}
+
+const archiveButton = () => screen.getByRole('button', { name: 'Archive Vehicle' })
+
+async function archiveWithReason(reason: string) {
+  fireEvent.change(screen.getByLabelText('Reason (optional)'), { target: { value: reason } })
+  await act(async () => {
+    fireEvent.click(archiveButton())
+  })
+}
+
+describe('archiving a vehicle on a dropped connection', () => {
+  beforeEach(() => {
+    archiveVehicle.mockReset()
+    router.refresh.mockClear()
+    vi.mocked(toast.error).mockClear()
+    vi.mocked(toast.success).mockClear()
+  })
+
+  it('says the archive failed and leaves the dialog ready to try again', async () => {
+    archiveVehicle.mockImplementation(offline)
+    const onOpenChange = renderDialog()
+
+    await archiveWithReason('Sold')
+
+    expect(archiveVehicle).toHaveBeenCalledWith('v1', 'Sold')
+    await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Failed to archive vehicle'))
+    expect(onOpenChange).not.toHaveBeenCalled()
+    expect(router.refresh).not.toHaveBeenCalled()
+    // Still open, with the reason as typed and both buttons usable.
+    expect(screen.getByLabelText('Reason (optional)')).toHaveValue('Sold')
+    expect(archiveButton()).toBeEnabled()
+    expect(screen.getByRole('button', { name: 'Cancel' })).toBeEnabled()
+
+    // The connection is back.
+    archiveVehicle.mockResolvedValue({ success: true })
+    await act(async () => {
+      fireEvent.click(archiveButton())
+    })
+
+    expect(archiveVehicle).toHaveBeenCalledTimes(2)
+    expect(toast.success).toHaveBeenCalledWith('Volvo XC60 archived')
+    expect(onOpenChange).toHaveBeenCalledWith(false)
+    expect(router.refresh).toHaveBeenCalled()
+  })
+
+  it('shows what the server said when it refuses', async () => {
+    archiveVehicle.mockResolvedValue({ success: false, error: 'Vehicle has an open work order' })
+    renderDialog()
+
+    await archiveWithReason('')
+
+    expect(toast.error).toHaveBeenCalledWith('Vehicle has an open work order')
+    expect(archiveButton()).toBeEnabled()
+  })
+})

+ 153 - 0
src/__tests__/features/vehicles/media-grid-change-failure.test.tsx

@@ -0,0 +1,153 @@
+/**
+ * Showing a file to the customer, hiding it, or giving it a caption shows on
+ * the tile at once. When the change never reaches the server the tile goes
+ * back to what it was and says the update failed. A dropped connection used
+ * to leave the tile showing a change the server never made, and a caption
+ * that was refused went unmentioned.
+ *
+ * A tile takes one change at a time, so a failure never puts back a value a
+ * newer change on the same tile has already replaced.
+ */
+import { act, fireEvent, render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const toastError = vi.fn()
+
+vi.mock('next/navigation', () => ({ useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }) }))
+vi.mock('sonner', () => ({
+  toast: {
+    success: vi.fn(),
+    warning: vi.fn(),
+    error: (...args: unknown[]) => toastError(...args),
+  },
+}))
+vi.mock('@/features/vehicles/Actions/addServiceAttachment', () => ({
+  addServiceAttachment: vi.fn(),
+}))
+vi.mock('@/features/vehicles/Actions/updateServiceAttachment', () => ({
+  updateServiceAttachment: vi.fn(),
+}))
+vi.mock('@/features/vehicles/Actions/serviceActions', () => ({
+  deleteServiceAttachment: vi.fn(),
+}))
+vi.mock('@/features/settings/Actions/timezoneActions', () => ({ adoptDetectedTimezone: vi.fn() }))
+vi.mock('@/features/whatsapp/Components/SendPhotoToWhatsapp', () => ({
+  SendPhotoToWhatsapp: () => null,
+}))
+
+import {
+  MediaGrid,
+  type MediaStore,
+} from '@/features/vehicles/Components/service-page/modern/MediaGrid'
+
+const FILE = {
+  id: 'f1',
+  fileName: 'alignment-report.pdf',
+  fileUrl: '/uploads/alignment-report.pdf',
+  fileType: 'application/pdf',
+  fileSize: 120_000,
+  category: 'document',
+  description: null,
+  includeInInvoice: true,
+  createdAt: new Date('2026-11-09T09:00:00.000Z'),
+}
+
+const update = vi.fn()
+const store: MediaStore = {
+  add: vi.fn(),
+  update: (input) => update(input),
+  remove: vi.fn(),
+}
+
+const renderGrid = () =>
+  render(<MediaGrid kind="document" serviceRecordId="job1" store={store} files={[FILE]} />)
+
+const gate = () => {
+  let open!: () => void
+  const opened = new Promise<void>((resolve) => {
+    open = resolve
+  })
+  return { opened, open }
+}
+
+describe('changing a file tile when the change does not go through', () => {
+  beforeEach(() => {
+    update.mockReset()
+    toastError.mockClear()
+  })
+
+  it('shows the file to the customer again when hiding it never reaches the server', async () => {
+    const answer = gate()
+    update.mockImplementation(async () => {
+      await answer.opened
+      throw new TypeError('Failed to fetch')
+    })
+    renderGrid()
+    expect(screen.getByText('Customer can see')).toBeInTheDocument()
+
+    act(() => {
+      fireEvent.click(screen.getByRole('button', { name: 'Hide from the customer' }))
+    })
+    expect(screen.queryByText('Customer can see')).not.toBeInTheDocument()
+
+    await act(async () => answer.open())
+
+    expect(update).toHaveBeenCalledWith({ id: 'f1', includeInInvoice: false })
+    expect(screen.getByText('Customer can see')).toBeInTheDocument()
+    expect(screen.getByRole('button', { name: 'Hide from the customer' })).toBeEnabled()
+    expect(toastError).toHaveBeenCalledWith('Failed to update')
+  })
+
+  it('takes one change at a time on a tile', async () => {
+    const answer = gate()
+    update.mockImplementation(async () => {
+      await answer.opened
+      return { success: true }
+    })
+    renderGrid()
+
+    act(() => {
+      fireEvent.click(screen.getByRole('button', { name: 'Hide from the customer' }))
+    })
+    const show = screen.getByRole('button', { name: 'Show to the customer' })
+    expect(show).toBeDisabled()
+    expect(screen.getByRole('textbox', { name: 'Description...' })).toHaveAttribute('readonly')
+
+    await act(async () => answer.open())
+
+    expect(show).toBeEnabled()
+    expect(update).toHaveBeenCalledTimes(1)
+  })
+
+  it('puts the caption back when saving it never reaches the server', async () => {
+    update.mockImplementation(async () => {
+      throw new TypeError('Failed to fetch')
+    })
+    renderGrid()
+    const caption = screen.getByRole('textbox', { name: 'Description...' })
+
+    fireEvent.change(caption, { target: { value: 'Front left toe out of spec' } })
+    await act(async () => {
+      fireEvent.blur(caption)
+    })
+
+    expect(update).toHaveBeenCalledWith({ id: 'f1', description: 'Front left toe out of spec' })
+    expect(caption).toHaveValue('')
+    expect(caption).not.toHaveAttribute('readonly')
+    expect(toastError).toHaveBeenCalledWith('Failed to update')
+  })
+
+  it('says so when the server refuses a caption', async () => {
+    update.mockResolvedValue({ success: false, error: 'Not allowed' })
+    renderGrid()
+    const caption = screen.getByRole('textbox', { name: 'Description...' })
+
+    fireEvent.change(caption, { target: { value: 'Front left toe out of spec' } })
+    await act(async () => {
+      fireEvent.blur(caption)
+    })
+
+    expect(caption).toHaveValue('')
+    expect(toastError).toHaveBeenCalledWith('Not allowed')
+  })
+})

+ 159 - 0
src/__tests__/features/vehicles/work-order-payments-dropped-connection.test.ts

@@ -0,0 +1,159 @@
+/**
+ * Payments and attachments on a work order, on a dropped connection. The
+ * actions throw instead of answering when they cannot reach the server, and
+ * the work order only read the answer: no error was shown, and the payment
+ * buttons stayed busy until the page was reloaded.
+ */
+import { act, renderHook } from '@testing-library/react'
+import { toast } from 'sonner'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { useGlassModal } from '@/components/glass-modal'
+
+/** What a dropped connection does to a server action: it throws. */
+const offline = async () => {
+  throw new TypeError('Failed to fetch')
+}
+
+const createPayment = vi.fn()
+const deletePayment = vi.fn()
+const toggleManuallyPaid = vi.fn()
+const deleteServiceAttachment = vi.fn()
+const getSmsTemplates = vi.fn()
+const router = { push: vi.fn(), refresh: vi.fn() }
+
+vi.mock('next/navigation', () => ({ useRouter: () => router }))
+vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
+// The question is answered with Delete.
+vi.mock('@/components/confirm-dialog', () => ({ useConfirm: () => async () => true }))
+vi.mock('@/features/vehicles/Actions/serviceActions', () => ({
+  updateServiceRecord: vi.fn(),
+  deleteServiceRecord: vi.fn(),
+  deleteServiceAttachment: (id: string) => deleteServiceAttachment(id),
+  toggleManuallyPaid: (id: string) => toggleManuallyPaid(id),
+}))
+vi.mock('@/features/payments/Actions/paymentActions', () => ({
+  createPayment: (input: unknown) => createPayment(input),
+  deletePayment: (id: string) => deletePayment(id),
+}))
+vi.mock('@/features/sms/Actions/smsActions', () => ({
+  getSmsTemplates: () => getSmsTemplates(),
+}))
+
+import { useServiceActions } from '@/features/vehicles/Components/service-page/useServiceActions'
+
+type Options = Parameters<typeof useServiceActions>[0]
+
+const PAYMENT = { amount: 500, date: '2026-11-10', method: 'card' }
+
+function renderActions() {
+  // Only what these handlers touch; the rest of the form's state is not read.
+  const form = {
+    isSavingRef: { current: false },
+    autosaveTimer: { current: null },
+    setHasUnsavedChanges: vi.fn(),
+    setLocalManuallyPaid: vi.fn(),
+  }
+  const hook = renderHook(() =>
+    useServiceActions({
+      record: {
+        id: 'job1',
+        invoiceNumber: 'INV-1001',
+        customer: { name: 'Ada Berg' },
+        vehicle: null,
+      } as unknown as Options['record'],
+      vehicleId: 'v1',
+      currencyCode: 'USD',
+      formState: form as unknown as Options['formState'],
+    })
+  )
+  return { form, hook }
+}
+
+const modalMessage = () => {
+  const state = useGlassModal.getState()
+  return state.isOpen && state.type === 'error' ? state.message : null
+}
+
+describe('work order payments on a dropped connection', () => {
+  beforeEach(() => {
+    for (const action of [
+      createPayment,
+      deletePayment,
+      toggleManuallyPaid,
+      deleteServiceAttachment,
+      getSmsTemplates,
+    ]) {
+      action.mockReset()
+    }
+    router.refresh.mockClear()
+    vi.mocked(toast.success).mockClear()
+    act(() => useGlassModal.getState().close())
+  })
+
+  it('says a payment was not recorded and frees the payment buttons', async () => {
+    createPayment.mockImplementation(offline)
+    const { hook } = renderActions()
+
+    let recorded: boolean | undefined
+    await act(async () => {
+      recorded = await hook.result.current.handleCreatePayment(PAYMENT)
+    })
+
+    // False keeps the payment form open with what was typed.
+    expect(recorded).toBe(false)
+    expect(modalMessage()).toBe('Failed to record payment')
+    expect(hook.result.current.paymentLoading).toBe(false)
+    expect(router.refresh).not.toHaveBeenCalled()
+  })
+
+  it('offers the payment message in the default wording when the saved texts cannot be read', async () => {
+    createPayment.mockResolvedValue({ success: true })
+    getSmsTemplates.mockImplementation(offline)
+    const { hook } = renderActions()
+
+    let recorded: boolean | undefined
+    await act(async () => {
+      recorded = await hook.result.current.handleCreatePayment(PAYMENT)
+    })
+
+    expect(recorded).toBe(true)
+    expect(toast.success).toHaveBeenCalledWith('Payment recorded')
+    expect(hook.result.current.showPaymentNotifyDialog).toBe(true)
+    expect(hook.result.current.paymentNotifyMessage).toMatch(
+      /^Payment of .*500.* received for invoice INV-1001\. Thank you!$/
+    )
+  })
+
+  it('says marking paid failed and frees the payment buttons', async () => {
+    toggleManuallyPaid.mockImplementation(offline)
+    const { form, hook } = renderActions()
+
+    await act(() => hook.result.current.handleTogglePaid())
+
+    expect(modalMessage()).toBe('Failed to update payment status')
+    expect(hook.result.current.paymentLoading).toBe(false)
+    expect(form.setLocalManuallyPaid).not.toHaveBeenCalled()
+  })
+
+  it('says a payment was not deleted and frees its delete button', async () => {
+    deletePayment.mockImplementation(offline)
+    const { hook } = renderActions()
+
+    await act(() => hook.result.current.handleDeletePayment('pay1'))
+
+    expect(deletePayment).toHaveBeenCalledWith('pay1')
+    expect(modalMessage()).toBe('Failed to delete payment')
+    expect(hook.result.current.deletingPayment).toBeNull()
+  })
+
+  it('says an attachment was not deleted and frees its delete button', async () => {
+    deleteServiceAttachment.mockImplementation(offline)
+    const { hook } = renderActions()
+
+    await act(() => hook.result.current.handleDeleteAttachment('file1'))
+
+    expect(deleteServiceAttachment).toHaveBeenCalledWith('file1')
+    expect(modalMessage()).toBe('Failed to delete')
+    expect(hook.result.current.deletingAttachment).toBeNull()
+  })
+})

+ 116 - 0
src/__tests__/features/whatsapp/whatsapp-conversation-dropped-connection.test.tsx

@@ -0,0 +1,116 @@
+/**
+ * A WhatsApp thread on a dropped connection. Sending awaits two things in one
+ * transition, the send and then reading the thread back, and either can throw
+ * when the server cannot be reached. A throw from either went to the error
+ * boundary and the whole messages page was replaced by the error page.
+ */
+import { Component, type ReactNode } from 'react'
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { toast } from 'sonner'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+/** What a dropped connection does to a server action: it throws. */
+const offline = async () => {
+  throw new TypeError('Failed to fetch')
+}
+
+const getWhatsappConversation = vi.fn()
+const getWhatsappWindowState = vi.fn()
+const sendWhatsappToCustomer = vi.fn()
+const router = { refresh: vi.fn(), push: vi.fn() }
+
+vi.mock('next/navigation', () => ({ useRouter: () => router }))
+vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }))
+vi.mock('@/features/whatsapp/Actions/whatsappActions', () => ({
+  getWhatsappConversation: (id: string) => getWhatsappConversation(id),
+  getWhatsappConversationByPhone: vi.fn(),
+  getWhatsappWindowState: (id: string) => getWhatsappWindowState(id),
+  sendWhatsappToCustomer: (input: unknown) => sendWhatsappToCustomer(input),
+  deleteWhatsappMessage: vi.fn(),
+}))
+
+import { WhatsappConversation } from '@/features/whatsapp/Components/WhatsappConversation'
+
+/** Stands in for the app's error page, so losing the page is visible. */
+class ErrorPage extends Component<{ children: ReactNode }, { failed: boolean }> {
+  state = { failed: false }
+  static getDerivedStateFromError() {
+    return { failed: true }
+  }
+  render() {
+    return this.state.failed ? <p>Something went wrong</p> : this.props.children
+  }
+}
+
+const THREAD = { key: 'c1', customerId: 'c1', name: 'Ada Berg', phone: '+4790000000' }
+const OPEN_WINDOW = {
+  success: true,
+  data: { open: true, hasTemplate: true, hasMediaTemplate: true },
+}
+
+async function renderThread(onSent = vi.fn()) {
+  render(
+    <ErrorPage>
+      <WhatsappConversation thread={THREAD} onSent={onSent} />
+    </ErrorPage>
+  )
+  await screen.findByText('Nothing here yet.')
+  return onSent
+}
+
+async function typeAndSend(text: string) {
+  fireEvent.change(screen.getByPlaceholderText('Write a message'), { target: { value: text } })
+  await act(async () => {
+    fireEvent.click(screen.getByRole('button', { name: 'Send' }))
+  })
+}
+
+describe('WhatsApp thread on a dropped connection', () => {
+  beforeEach(() => {
+    getWhatsappConversation.mockReset()
+    getWhatsappWindowState.mockReset()
+    sendWhatsappToCustomer.mockReset()
+    vi.mocked(toast.error).mockClear()
+    router.refresh.mockClear()
+    getWhatsappConversation.mockResolvedValue({ success: true, data: [] })
+    getWhatsappWindowState.mockResolvedValue(OPEN_WINDOW)
+    vi.spyOn(console, 'error').mockImplementation(() => undefined)
+    // jsdom has no scrolling; a sent message scrolls the thread to the end.
+    Element.prototype.scrollIntoView = vi.fn()
+  })
+
+  it('says the message was not sent and keeps what was typed', async () => {
+    sendWhatsappToCustomer.mockImplementation(offline)
+    const onSent = await renderThread()
+
+    await typeAndSend('Your car is ready')
+
+    await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Could not send the message'))
+    expect(screen.queryByText('Something went wrong')).not.toBeInTheDocument()
+    expect(screen.getByPlaceholderText('Write a message')).toHaveValue('Your car is ready')
+    expect(onSent).not.toHaveBeenCalled()
+  })
+
+  it('keeps the page when the message went but the thread cannot be read back', async () => {
+    sendWhatsappToCustomer.mockResolvedValue({ success: true, data: { usedTemplate: false } })
+    const onSent = await renderThread()
+    getWhatsappConversation.mockImplementation(offline)
+
+    await typeAndSend('Your car is ready')
+
+    await waitFor(() => expect(onSent).toHaveBeenCalled())
+    expect(getWhatsappConversation).toHaveBeenCalledTimes(2)
+    expect(screen.queryByText('Something went wrong')).not.toBeInTheDocument()
+    expect(screen.getByPlaceholderText('Write a message')).toHaveValue('')
+    expect(toast.error).not.toHaveBeenCalled()
+  })
+
+  it('stops loading when the thread cannot be read at all', async () => {
+    getWhatsappConversation.mockImplementation(offline)
+    getWhatsappWindowState.mockImplementation(offline)
+
+    await renderThread()
+
+    expect(screen.getByPlaceholderText('Write a message')).toBeInTheDocument()
+  })
+})

+ 340 - 0
src/__tests__/features/workboard/board-change-failure.test.tsx

@@ -0,0 +1,340 @@
+/**
+ * A job moved on the work board is drawn in its new place before the server
+ * has it. When the move never reaches the server the job goes back where it
+ * was and the board says so; a job dragged out of the unassigned list goes
+ * back into the list. Both used to stay where they were dropped, showing a
+ * change the server never made.
+ *
+ * Moves that overlap are settled together: two that both fail leave the job
+ * where it started, whichever answers first, and one that fails after a later
+ * one went through does not undo it.
+ *
+ * Dragging in jsdom is not practical, so the timeline is a stand-in that
+ * draws the jobs it is handed and keeps the board's own `onSchedule`, and the
+ * drag context keeps the board's own `onDragEnd`. Those handlers are the ones
+ * the real timeline and drag context call.
+ */
+import type { DragEndEvent } from '@dnd-kit/core'
+import { act, render, screen, waitFor, within } from '@testing-library/react'
+import type { ReactNode } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { WorkBoardJob, WorkBoardSettings } from '@/features/workboard/Actions/boardActions'
+import type { WeekScheduleChange } from '@/features/workboard/Components/WeekTimeline'
+import type { Technician } from '@/features/workboard/store/workboardStore'
+
+const toastError = vi.fn()
+const scheduleJob = vi.fn()
+const getBoardJobs = vi.fn()
+const getUnassignedJobs = vi.fn()
+/** The board's own handlers, as the timeline and the drag context receive them. */
+const board: {
+  jobs: WorkBoardJob[]
+  onSchedule?: (change: WeekScheduleChange) => unknown
+  onDragEnd?: (event: DragEndEvent) => unknown
+} = { jobs: [] }
+
+vi.mock('next/navigation', () => ({
+  useRouter: () => ({ replace: vi.fn(), refresh: vi.fn(), push: vi.fn() }),
+  useSearchParams: () => new URLSearchParams(),
+  usePathname: () => '/work-board',
+}))
+vi.mock('sonner', () => ({
+  toast: {
+    success: vi.fn(),
+    warning: vi.fn(),
+    error: (...args: unknown[]) => toastError(...args),
+  },
+}))
+vi.mock('@/features/workboard/Actions/boardActions', () => ({
+  getBoardJobs: (...args: unknown[]) => getBoardJobs(...args),
+  getUnassignedJobs: (...args: unknown[]) => getUnassignedJobs(...args),
+  scheduleJob: (...args: unknown[]) => scheduleJob(...args),
+  assignTechnician: vi.fn(),
+  moveJob: vi.fn(),
+  unassignJob: vi.fn(),
+  updateServiceTimes: vi.fn(),
+}))
+vi.mock('@/components/date-settings-context', () => ({
+  useDateSettings: () => ({
+    dateFormat: 'yyyy-MM-dd',
+    timeFormat: '24h',
+    timezone: 'UTC',
+    weekStartDay: 1,
+  }),
+}))
+vi.mock('@/features/workboard/hooks/useWorkBoardWebSocket', () => ({
+  useWorkBoardWebSocket: () => undefined,
+}))
+vi.mock('@/features/workboard/hooks/useBoardCalendar', () => ({
+  awayKey: (laneId: string, date: string) => `${laneId}|${date}`,
+  useBoardCalendar: () => ({
+    calendar: null,
+    enabled: false,
+    closedByDay: {},
+    awayByLaneDay: {},
+    reload: vi.fn(),
+  }),
+}))
+vi.mock('@dnd-kit/core', async (importOriginal) => ({
+  ...(await importOriginal<typeof import('@dnd-kit/core')>()),
+  DndContext: (props: { onDragEnd: (event: DragEndEvent) => unknown; children: ReactNode }) => {
+    board.onDragEnd = props.onDragEnd
+    return <>{props.children}</>
+  },
+  DragOverlay: () => null,
+}))
+vi.mock('@/features/workboard/Components/WeekTimeline', () => ({
+  WeekTimeline: (props: {
+    jobs: WorkBoardJob[]
+    onSchedule: (change: WeekScheduleChange) => unknown
+  }) => {
+    board.jobs = props.jobs
+    board.onSchedule = props.onSchedule
+    return (
+      <ul aria-label="Board">
+        {props.jobs.map((job) => (
+          <li key={job.id}>{`${job.title}, ${job.technicianId}, ${job.startDateTime}`}</li>
+        ))}
+      </ul>
+    )
+  },
+}))
+vi.mock('@/features/workboard/Components/UnassignedJobsPanel', async () => {
+  const { useWorkBoardStore } = await import('@/features/workboard/store/workboardStore')
+  return {
+    UnassignedJobsPanel: () => {
+      const pool = useWorkBoardStore((state) => state.unassignedServiceRecords)
+      return (
+        <ul aria-label="Unassigned">
+          {pool.map((job) => (
+            <li key={job.id}>{job.title}</li>
+          ))}
+        </ul>
+      )
+    },
+    UnassignedJobsList: () => null,
+  }
+})
+vi.mock('@/features/workboard/Components/WorkBoardToolbar', () => ({
+  WorkBoardToolbar: () => null,
+}))
+vi.mock('@/features/workboard/Components/WeekCardGrid', () => ({ WeekCardGrid: () => null }))
+vi.mock('@/features/workboard/Components/TechnicianDialog', () => ({
+  TechnicianDialog: () => null,
+}))
+vi.mock('@/features/workboard/Components/WorkBayDialog', () => ({ WorkBayDialog: () => null }))
+vi.mock('@/features/workboard/Components/JobDetailPopover', () => ({
+  JobDetailPopover: () => null,
+}))
+vi.mock('@/features/workshop-calendar/Components/TimeOffDialog', () => ({
+  TimeOffDialog: () => null,
+}))
+vi.mock('@/features/workshop-calendar/Components/TimeOffManagerDialog', () => ({
+  TimeOffManagerDialog: () => null,
+}))
+vi.mock('@/components/vehicle-picker-dialog', () => ({ VehiclePickerDialog: () => null }))
+vi.mock('@/components/ui/drawer', () => ({
+  Drawer: () => null,
+  DrawerContent: () => null,
+  DrawerHeader: () => null,
+  DrawerTitle: () => null,
+  DrawerTrigger: () => null,
+}))
+
+import { WorkBoardClient } from '@/features/workboard/Components/WorkBoardClient'
+
+const WEEK = '2026-11-09'
+
+const technician = (id: string, name: string, sortOrder: number): Technician => ({
+  id,
+  name,
+  color: '#3b82f6',
+  isActive: true,
+  sortOrder,
+  dailyCapacity: 480,
+  userId: null,
+  organizationId: 'org-a',
+})
+
+const BRAKES: WorkBoardJob = {
+  id: 'brakes',
+  type: 'serviceRecord',
+  technicianId: 'ada',
+  helperIds: [],
+  workBayId: null,
+  sortOrder: 0,
+  title: 'Brake job',
+  status: 'pending',
+  startDateTime: '2026-11-10T09:00:00.000Z',
+  endDateTime: '2026-11-10T10:00:00.000Z',
+  promisedAt: null,
+  vehicle: null,
+  customerName: null,
+}
+
+const SETTINGS: WorkBoardSettings = { weekStartDay: 1, workDayStart: '08:00', workDayEnd: '16:00' }
+
+const OIL_CHANGE = {
+  id: 'oil',
+  title: 'Oil change',
+  status: 'pending',
+  vehicle: { id: 'v1', make: 'Volvo', model: 'V70', year: 2012, licensePlate: 'AB12345' },
+}
+
+const renderBoard = () =>
+  render(
+    <WorkBoardClient
+      initialTechnicians={[
+        technician('ada', 'Ada', 0),
+        technician('grace', 'Grace', 1),
+        technician('lin', 'Lin', 2),
+      ]}
+      initialWorkBays={[]}
+      initialAssignments={[BRAKES]}
+      initialUnassigned={{ serviceRecords: [OIL_CHANGE], inspections: [] }}
+      initialWeekStart={WEEK}
+      boardSettings={SETTINGS}
+    />
+  )
+
+const onBoard = () => within(screen.getByRole('list', { name: 'Board' }))
+const brakeJob = () => onBoard().getByText(/^Brake job,/).textContent
+const inPool = () => within(screen.getByRole('list', { name: 'Unassigned' }))
+
+/**
+ * Drags the brake job, as the timeline has it drawn now, into another
+ * technician's lane an hour later. What the timeline does on a drop.
+ */
+const moveBrakeJob = (laneId: string) => {
+  const drawn = board.jobs.find((job) => job.id === BRAKES.id)
+  if (!drawn) throw new Error('The brake job is not on the board')
+  return board.onSchedule?.({
+    job: drawn,
+    laneId,
+    start: new Date('2026-11-10T10:00:00'),
+    end: new Date('2026-11-10T11:00:00'),
+  })
+}
+
+const gate = () => {
+  let open!: () => void
+  const opened = new Promise<void>((resolve) => {
+    open = resolve
+  })
+  return { opened, open }
+}
+
+const unreachable = async () => {
+  throw new TypeError('Failed to fetch')
+}
+
+describe('a change on the work board that does not go through', () => {
+  beforeEach(() => {
+    scheduleJob.mockReset()
+    getBoardJobs.mockReset()
+    getUnassignedJobs.mockReset()
+    toastError.mockClear()
+    getUnassignedJobs.mockResolvedValue({
+      success: true,
+      data: { serviceRecords: [], inspections: [] },
+    })
+  })
+
+  it('puts a moved job back where it was when the move never reaches the server', async () => {
+    const answer = gate()
+    scheduleJob.mockImplementation(async () => {
+      await answer.opened
+      throw new TypeError('Failed to fetch')
+    })
+    renderBoard()
+    const before = brakeJob()
+    expect(before).toContain(', ada,')
+
+    act(() => void moveBrakeJob('grace'))
+    expect(brakeJob()).toContain(', grace,')
+
+    await act(async () => answer.open())
+
+    await waitFor(() => expect(brakeJob()).toBe(before))
+    expect(toastError).toHaveBeenCalledWith('Failed to move assignment', {
+      description: undefined,
+    })
+  })
+
+  it('puts a job dragged from the unassigned list back in the list when the assignment never reaches the server', async () => {
+    scheduleJob.mockImplementation(unreachable)
+    renderBoard()
+    expect(inPool().getByText('Oil change')).toBeInTheDocument()
+
+    await act(async () => {
+      await board.onDragEnd?.({
+        active: { id: 'oil', data: { current: { job: OIL_CHANGE, type: 'serviceRecord' } } },
+        over: {
+          id: 'ada|2026-11-10',
+          data: { current: { laneId: 'ada', date: '2026-11-10', week: true } },
+        },
+        activatorEvent: new Event('pointerdown'),
+        delta: { x: 0, y: 0 },
+      } as unknown as DragEndEvent)
+    })
+
+    expect(scheduleJob).toHaveBeenCalledWith(
+      expect.objectContaining({ id: 'oil', technicianId: 'ada' })
+    )
+    expect(inPool().getByText('Oil change')).toBeInTheDocument()
+    expect(onBoard().queryByText(/^Oil change/)).not.toBeInTheDocument()
+    expect(toastError).toHaveBeenCalledWith('Failed to assign job', { description: undefined })
+  })
+
+  it('leaves the job where it started when two moves both fail, the first answering first', async () => {
+    const first = gate()
+    const second = gate()
+    scheduleJob
+      .mockImplementationOnce(async () => {
+        await first.opened
+        return { success: false, error: 'Refused' }
+      })
+      .mockImplementationOnce(async () => {
+        await second.opened
+        return { success: false, error: 'Refused' }
+      })
+    renderBoard()
+    const before = brakeJob()
+
+    act(() => void moveBrakeJob('grace'))
+    act(() => void moveBrakeJob('lin'))
+    expect(brakeJob()).toContain(', lin,')
+
+    await act(async () => first.open())
+    await act(async () => second.open())
+
+    await waitFor(() => expect(brakeJob()).toBe(before))
+    expect(toastError).toHaveBeenCalledTimes(2)
+  })
+
+  it('does not undo a later move that went through when an earlier one fails, and reads the board back', async () => {
+    const first = gate()
+    const onLin = { ...BRAKES, technicianId: 'lin' }
+    scheduleJob
+      .mockImplementationOnce(async () => {
+        await first.opened
+        return { success: false, error: 'Refused' }
+      })
+      .mockImplementationOnce(async () => ({ success: true, data: onLin }))
+    getBoardJobs.mockResolvedValue({ success: true, data: [onLin] })
+    renderBoard()
+
+    act(() => void moveBrakeJob('grace'))
+    await act(async () => {
+      await moveBrakeJob('lin')
+    })
+    expect(brakeJob()).toContain(', lin,')
+    await act(async () => first.open())
+
+    await waitFor(() => expect(getBoardJobs).toHaveBeenCalledWith(WEEK))
+    await waitFor(() => expect(brakeJob()).toContain(', lin,'))
+    expect(toastError).toHaveBeenCalledWith('Failed to move assignment', {
+      description: 'Refused',
+    })
+  })
+})

+ 109 - 0
src/__tests__/features/workboard/technician-dialog-failure.test.tsx

@@ -0,0 +1,109 @@
+/**
+ * The work board's technician dialog when the server says no or cannot be
+ * reached. A refused delete or save used to do nothing at all: no message,
+ * the dialog simply stayed. A dropped connection threw past the handler and
+ * left the button spinning until the page was reloaded.
+ */
+import { act, fireEvent, render, screen, within } from '@testing-library/react'
+import { toast } from 'sonner'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { ConfirmProvider } from '@/components/confirm-dialog'
+
+/** What a dropped connection does to a server action: it throws. */
+const offline = async () => {
+  throw new TypeError('Failed to fetch')
+}
+
+const deleteTechnician = vi.fn()
+const updateTechnician = vi.fn()
+
+vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
+vi.mock('@/features/workboard/Actions/technicianActions', () => ({
+  createTechnician: vi.fn(),
+  updateTechnician: (input: unknown) => updateTechnician(input),
+  deleteTechnician: (id: string) => deleteTechnician(id),
+  getOrgMembers: async () => ({ success: true, data: [] }),
+}))
+
+import { TechnicianDialog } from '@/features/workboard/Components/TechnicianDialog'
+import { useWorkBoardStore, type Technician } from '@/features/workboard/store/workboardStore'
+
+const TECH: Technician = {
+  id: 't1',
+  name: 'Ola Nordmann',
+  color: '#3b82f6',
+  isActive: true,
+  sortOrder: 0,
+  dailyCapacity: 8,
+  skills: null,
+  userId: null,
+  organizationId: 'org1',
+}
+
+const onOpenChange = vi.fn()
+
+function renderDialog() {
+  render(
+    <ConfirmProvider>
+      <TechnicianDialog open onOpenChange={onOpenChange} technician={TECH} />
+    </ConfirmProvider>
+  )
+}
+
+async function deleteAndConfirm() {
+  fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
+  const question = await screen.findByRole('alertdialog')
+  await act(async () => {
+    fireEvent.click(within(question).getByRole('button', { name: 'Delete' }))
+  })
+}
+
+describe('technician dialog failures', () => {
+  beforeEach(() => {
+    deleteTechnician.mockReset()
+    updateTechnician.mockReset()
+    onOpenChange.mockClear()
+    vi.mocked(toast.error).mockClear()
+    useWorkBoardStore.getState().setTechnicians([TECH])
+  })
+
+  it('says the delete was refused and keeps the technician on the board', async () => {
+    deleteTechnician.mockResolvedValue({ success: false, error: 'Insufficient permissions' })
+    renderDialog()
+
+    await deleteAndConfirm()
+
+    expect(deleteTechnician).toHaveBeenCalledWith('t1')
+    expect(toast.error).toHaveBeenCalledWith('Insufficient permissions')
+    expect(onOpenChange).not.toHaveBeenCalledWith(false)
+    expect(useWorkBoardStore.getState().technicians.map((t) => t.id)).toEqual(['t1'])
+  })
+
+  it('says the delete failed when the connection drops, and leaves Delete usable', async () => {
+    deleteTechnician.mockImplementation(offline)
+    renderDialog()
+
+    await deleteAndConfirm()
+
+    expect(toast.error).toHaveBeenCalledWith("Couldn't delete the technician. Try again.")
+    expect(useWorkBoardStore.getState().technicians.map((t) => t.id)).toEqual(['t1'])
+    expect(screen.getByRole('button', { name: 'Delete' })).toBeEnabled()
+  })
+
+  it('says saving failed when the connection drops, and keeps what was typed', async () => {
+    updateTechnician.mockImplementation(offline)
+    renderDialog()
+
+    fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Ola N.' } })
+    await act(async () => {
+      fireEvent.click(screen.getByRole('button', { name: 'Save' }))
+    })
+
+    expect(updateTechnician).toHaveBeenCalled()
+    expect(toast.error).toHaveBeenCalledWith("Couldn't save the technician. Try again.")
+    expect(onOpenChange).not.toHaveBeenCalledWith(false)
+    expect(screen.getByLabelText('Name')).toHaveValue('Ola N.')
+    expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled()
+    expect(useWorkBoardStore.getState().technicians[0].name).toBe('Ola Nordmann')
+  })
+})

+ 113 - 0
src/__tests__/lib/use-overlapping-saves.test.ts

@@ -0,0 +1,113 @@
+/**
+ * Overlapping changes on a screen that draws them first (the work board, the
+ * condition map). When some went through and some did not, the screen is read
+ * back from the server. A read that a newer change overtook must not be put
+ * on screen, even when that change has already answered by the time the read
+ * does, or the older state covers the newer change with nothing left to
+ * correct it.
+ */
+import { act, renderHook } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import { useOverlappingSaves } from '@/lib/use-overlapping-saves'
+
+type Result = { success: boolean }
+
+function deferred<T>() {
+  let resolve!: (value: T) => void
+  const promise = new Promise<T>((r) => {
+    resolve = r
+  })
+  return { promise, resolve }
+}
+
+function setup() {
+  const reads: { idle: () => boolean; answer: (took: boolean) => void }[] = []
+  const readBack = vi.fn(
+    (idle: () => boolean) =>
+      new Promise<boolean>((answer) => {
+        reads.push({ idle, answer })
+      })
+  )
+  const { result } = renderHook(() => useOverlappingSaves(readBack))
+  return { save: result.current, readBack, reads }
+}
+
+/** Two overlapping changes, the first refused and the second saved. */
+async function mixedPair(save: ReturnType<typeof setup>['save'], undo: () => void) {
+  const refused = deferred<Result>()
+  const saved = deferred<Result>()
+  let first!: Promise<Result | null>
+  let second!: Promise<Result | null>
+  act(() => {
+    first = save(() => refused.promise, undo)
+    second = save(() => saved.promise)
+  })
+  await act(async () => {
+    saved.resolve({ success: true })
+    await second
+    refused.resolve({ success: false })
+    await first
+  })
+}
+
+describe('useOverlappingSaves', () => {
+  it('reads back instead of undoing when overlapping changes partly failed', async () => {
+    const { save, readBack, reads } = setup()
+    const undo = vi.fn()
+
+    await mixedPair(save, undo)
+
+    expect(undo).not.toHaveBeenCalled()
+    expect(readBack).toHaveBeenCalledTimes(1)
+    expect(reads[0].idle()).toBe(true)
+  })
+
+  it('reads again when a change started and finished while the read was out', async () => {
+    const { save, readBack, reads } = setup()
+    await mixedPair(save, vi.fn())
+
+    // Another drag saves while the board is being read.
+    await act(async () => {
+      await save(async () => ({ success: true }))
+    })
+
+    // What the first read brought is older than that drag.
+    expect(reads[0].idle()).toBe(false)
+    await act(async () => {
+      reads[0].answer(false)
+    })
+
+    expect(readBack).toHaveBeenCalledTimes(2)
+    expect(reads[1].idle()).toBe(true)
+  })
+
+  it('tries a failed read again once the next change has answered', async () => {
+    const { save, readBack, reads } = setup()
+    await mixedPair(save, vi.fn())
+
+    // Nothing changed meanwhile; the read itself failed.
+    await act(async () => {
+      reads[0].answer(false)
+    })
+    expect(readBack).toHaveBeenCalledTimes(1)
+
+    await act(async () => {
+      await save(async () => ({ success: true }))
+    })
+    expect(readBack).toHaveBeenCalledTimes(2)
+  })
+
+  it('undoes a lone failure at once, without reading back', async () => {
+    const { save, readBack } = setup()
+    const undo = vi.fn()
+
+    await act(async () => {
+      await save(async () => {
+        throw new TypeError('Failed to fetch')
+      }, undo)
+    })
+
+    expect(undo).toHaveBeenCalledTimes(1)
+    expect(readBack).not.toHaveBeenCalled()
+  })
+})

+ 5 - 2
src/app/(authenticated)/dashboard-client.tsx

@@ -533,9 +533,12 @@ export function DashboardClient({
 
 
   const handleDismiss = async (vehicleId: string) => {
   const handleDismiss = async (vehicleId: string) => {
     setDismissingId(vehicleId)
     setDismissingId(vehicleId)
-    await dismissMaintenance(vehicleId)
+    // An action that cannot reach the server throws instead of answering, so
+    // it is caught and reported the way a refusal is.
+    const result = await dismissMaintenance(vehicleId).catch(() => null)
     setDismissingId(null)
     setDismissingId(null)
-    router.refresh()
+    if (result?.success) router.refresh()
+    else toast.error(result?.error || t('maintenance.dismissError'))
   }
   }
 
 
   const handleRestore = async (vehicleId: string) => {
   const handleRestore = async (vehicleId: string) => {

+ 17 - 13
src/app/(authenticated)/settings/report-schedule/report-schedule-settings.tsx

@@ -222,16 +222,18 @@ function ScheduleForm({
         endDate: endDate || null,
         endDate: endDate || null,
       }
       }
 
 
+      // An action that cannot reach the server throws instead of answering, so
+      // it is caught and reported the way a refusal is.
       const result = schedule
       const result = schedule
-        ? await updateReportSchedule({ ...payload, id: schedule.id })
-        : await createReportSchedule(payload)
+        ? await updateReportSchedule({ ...payload, id: schedule.id }).catch(() => null)
+        : await createReportSchedule(payload).catch(() => null)
 
 
-      if (result.success) {
+      if (result?.success) {
         toast.success(schedule ? t('updated') : t('created'))
         toast.success(schedule ? t('updated') : t('created'))
         router.refresh()
         router.refresh()
         onClose()
         onClose()
       } else {
       } else {
-        toast.error(result.error || t('failedSave'))
+        toast.error(result?.error || t('failedSave'))
       }
       }
     })
     })
   }
   }
@@ -384,12 +386,14 @@ function ScheduleCard({
   const handleSendNow = () => {
   const handleSendNow = () => {
     setSending(true)
     setSending(true)
     startTransition(async () => {
     startTransition(async () => {
-      const result = await sendReportNow(schedule.id)
-      if (result.success) {
+      // An action that cannot reach the server throws instead of answering, so
+      // it is caught and reported the way a refusal is.
+      const result = await sendReportNow(schedule.id).catch(() => null)
+      if (result?.success) {
         toast.success(t('sentNow'))
         toast.success(t('sentNow'))
         router.refresh()
         router.refresh()
       } else {
       } else {
-        toast.error(result.error || t('failedSend'))
+        toast.error(result?.error || t('failedSend'))
       }
       }
       setSending(false)
       setSending(false)
     })
     })
@@ -430,12 +434,12 @@ function ScheduleCard({
 
 
   const handleToggle = () => {
   const handleToggle = () => {
     startTransition(async () => {
     startTransition(async () => {
-      const result = await toggleReportSchedule(schedule.id)
-      if (result.success) {
+      const result = await toggleReportSchedule(schedule.id).catch(() => null)
+      if (result?.success) {
         toast.success(schedule.isActive ? t('toggledOff') : t('toggledOn'))
         toast.success(schedule.isActive ? t('toggledOff') : t('toggledOn'))
         router.refresh()
         router.refresh()
       } else {
       } else {
-        toast.error(result.error || t('failedToggle'))
+        toast.error(result?.error || t('failedToggle'))
       }
       }
     })
     })
   }
   }
@@ -443,12 +447,12 @@ function ScheduleCard({
   const handleDelete = () => {
   const handleDelete = () => {
     setDeleting(true)
     setDeleting(true)
     startTransition(async () => {
     startTransition(async () => {
-      const result = await deleteReportSchedule(schedule.id)
-      if (result.success) {
+      const result = await deleteReportSchedule(schedule.id).catch(() => null)
+      if (result?.success) {
         toast.success(t('deleted'))
         toast.success(t('deleted'))
         router.refresh()
         router.refresh()
       } else {
       } else {
-        toast.error(result.error || t('failedDelete'))
+        toast.error(result?.error || t('failedDelete'))
       }
       }
       setDeleting(false)
       setDeleting(false)
     })
     })

+ 14 - 4
src/app/(authenticated)/settings/team/team-settings.tsx

@@ -376,13 +376,23 @@ export function TeamSettings({
     })
     })
     if (!ok) return
     if (!ok) return
 
 
-    await removeTechnicianAccess({ userId: member.user.id })
-    const result = await setMemberTechnician({ userId: member.user.id, enabled: false })
-    if (result.success) {
+    // Taking the app back is the step that signs the phone out, so a refusal
+    // there stops here rather than reporting them signed out. An action that
+    // cannot reach the server throws instead of answering, so both are
+    // caught and reported the way a refusal is.
+    const revoked = await removeTechnicianAccess({ userId: member.user.id }).catch(() => null)
+    if (!revoked?.success) {
+      modal.open('error', 'Error', revoked?.error || t('team.technicianFailed'))
+      return
+    }
+    const result = await setMemberTechnician({ userId: member.user.id, enabled: false }).catch(
+      () => null
+    )
+    if (result?.success) {
       toast.success(t('team.revokeTechnicianDone', { name: member.user.name || member.user.email }))
       toast.success(t('team.revokeTechnicianDone', { name: member.user.name || member.user.email }))
       router.refresh()
       router.refresh()
     } else {
     } else {
-      modal.open('error', 'Error', result.error || t('team.technicianFailed'))
+      modal.open('error', 'Error', result?.error || t('team.technicianFailed'))
     }
     }
   }
   }
 
 

+ 27 - 21
src/app/(authenticated)/settings/webhooks/webhooks-settings.tsx

@@ -266,46 +266,48 @@ function WebhookRow({
 
 
   const handleToggle = () =>
   const handleToggle = () =>
     startTransition(async () => {
     startTransition(async () => {
-      const r = await toggleWebhook(webhook.id)
-      if (r.success) {
+      // An action that cannot reach the server throws instead of answering, so
+      // it is caught and reported the way a refusal is.
+      const r = await toggleWebhook(webhook.id).catch(() => null)
+      if (r?.success) {
         toast.success(r.data?.isActive ? t('resumed') : t('paused'))
         toast.success(r.data?.isActive ? t('resumed') : t('paused'))
         onRefresh()
         onRefresh()
       } else {
       } else {
-        toast.error(r.error || t('toggleFailed'))
+        toast.error(r?.error || t('toggleFailed'))
       }
       }
     })
     })
 
 
   const handleTest = () =>
   const handleTest = () =>
     startTransition(async () => {
     startTransition(async () => {
-      const r = await sendTestWebhook(webhook.id)
-      if (r.success) {
+      const r = await sendTestWebhook(webhook.id).catch(() => null)
+      if (r?.success) {
         toast.success(t('testSent'))
         toast.success(t('testSent'))
         onRefresh()
         onRefresh()
       } else {
       } else {
-        toast.error(r.error || t('testFailed'))
+        toast.error(r?.error || t('testFailed'))
       }
       }
     })
     })
 
 
   const handleDelete = () =>
   const handleDelete = () =>
     startTransition(async () => {
     startTransition(async () => {
-      const r = await deleteWebhook(webhook.id)
-      if (r.success) {
+      const r = await deleteWebhook(webhook.id).catch(() => null)
+      if (r?.success) {
         toast.success(t('deleted'))
         toast.success(t('deleted'))
         setConfirmDelete(false)
         setConfirmDelete(false)
         onRefresh()
         onRefresh()
       } else {
       } else {
-        toast.error(r.error || t('deleteFailed'))
+        toast.error(r?.error || t('deleteFailed'))
       }
       }
     })
     })
 
 
   const handleRotate = () =>
   const handleRotate = () =>
     startTransition(async () => {
     startTransition(async () => {
-      const r = await rotateWebhookSecret(webhook.id)
-      if (r.success && r.data) {
+      const r = await rotateWebhookSecret(webhook.id).catch(() => null)
+      if (r?.success && r.data) {
         setRotated(r.data.secret)
         setRotated(r.data.secret)
         setConfirmRotate(false)
         setConfirmRotate(false)
       } else {
       } else {
-        toast.error(r.error || t('rotateFailed'))
+        toast.error(r?.error || t('rotateFailed'))
       }
       }
     })
     })
 
 
@@ -591,21 +593,23 @@ function WebhookFormDialog({
         description: description.trim() || null,
         description: description.trim() || null,
         events: finalEvents,
         events: finalEvents,
       }
       }
+      // An action that cannot reach the server throws instead of answering, so
+      // it is caught and reported the way a refusal is.
       if (editing) {
       if (editing) {
-        const r = await updateWebhook({ ...payload, id: editing.id })
-        if (r.success) {
+        const r = await updateWebhook({ ...payload, id: editing.id }).catch(() => null)
+        if (r?.success) {
           toast.success(t('updated'))
           toast.success(t('updated'))
           onUpdated()
           onUpdated()
         } else {
         } else {
-          toast.error(r.error || t('saveFailed'))
+          toast.error(r?.error || t('saveFailed'))
         }
         }
       } else {
       } else {
-        const r = await createWebhook(payload)
-        if (r.success && r.data) {
+        const r = await createWebhook(payload).catch(() => null)
+        if (r?.success && r.data) {
           toast.success(t('created'))
           toast.success(t('created'))
           onCreated({ name: r.data.name, secret: r.data.secret })
           onCreated({ name: r.data.name, secret: r.data.secret })
         } else {
         } else {
-          toast.error(r.error || t('saveFailed'))
+          toast.error(r?.error || t('saveFailed'))
         }
         }
       }
       }
     })
     })
@@ -813,12 +817,14 @@ function DeliveriesSheet({ webhook, onClose }: { webhook: Webhook; onClose: () =
 
 
   const handleRetry = (id: string) =>
   const handleRetry = (id: string) =>
     startTransition(async () => {
     startTransition(async () => {
-      const r = await retryWebhookDelivery(id)
-      if (r.success) {
+      // An action that cannot reach the server throws instead of answering, so
+      // it is caught and reported the way a refusal is.
+      const r = await retryWebhookDelivery(id).catch(() => null)
+      if (r?.success) {
         toast.success(t('retryQueued'))
         toast.success(t('retryQueued'))
         load()
         load()
       } else {
       } else {
-        toast.error(r.error || t('retryFailed'))
+        toast.error(r?.error || t('retryFailed'))
       }
       }
     })
     })
 
 

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است