Quellcode durchsuchen

Say when a delete fails and put back what was removed, instead of failing silently (#569)

* Say when a delete fails and put back what was removed, instead of failing silently

A notification deleted in the panel came back after a reload when the
delete had failed. It is now put back at once, in its place, with an
error. Deleting a labor package on a dropped connection did nothing
anyone could see; it now shows the delete-failed message, and opening
one says when it cannot be loaded.

The same on the other screens with the same fault: a photo on an
inspection check, a mark or photo on the condition map, a job taken off
the work board, a file tile, and deleting customers, vehicles, parts
and work orders. A work order whose delete never reached the server
also stopped saving later edits; it carries on saving now. Opening a
notification or marking all read no longer raises the lost-connection
notice when that bookkeeping fails.

* Show notifications unread again when mark all read fails

Mark all read cleared the badges at once and then ignored a refusal or a
dropped request, so the panel claimed a change the server never made. The
ones that were unread are now shown unread again, and the count restored.

* Read notifications back from the server when overlapping changes fail

A delete that failed after Clear all succeeded put back a notification
the server had already removed, and an older Mark all read that failed
undid a newer one that went through. When changes overlap, a failure now
reads the list back from the server once all of them have answered. A
Mark all read that fails on its own also keeps counting unread ones that
new arrivals pushed off the end of the list.
Bernt Christian Egeland vor 4 Tagen
Ursprung
Commit
40f93bfb61
31 geänderte Dateien mit 1211 neuen und 57 gelöschten Zeilen
  1. 1 0
      messages/de/notifications.json
  2. 1 0
      messages/en/notifications.json
  3. 1 0
      messages/es/notifications.json
  4. 1 0
      messages/fr/notifications.json
  5. 1 0
      messages/it/notifications.json
  6. 1 0
      messages/lt/notifications.json
  7. 1 0
      messages/nb/notifications.json
  8. 1 0
      messages/nl/notifications.json
  9. 1 0
      messages/pl/notifications.json
  10. 1 0
      messages/pt-BR/notifications.json
  11. 1 0
      messages/pt-PT/notifications.json
  12. 1 0
      messages/ru/notifications.json
  13. 1 0
      messages/tr/notifications.json
  14. 105 0
      src/__tests__/app/customers/customers-client-delete.test.tsx
  15. 142 0
      src/__tests__/app/labor-presets/labor-presets-client.test.tsx
  16. 119 0
      src/__tests__/features/condition-map/remove-mark.test.tsx
  17. 102 0
      src/__tests__/features/inspections/item-photo-remove.test.tsx
  18. 370 0
      src/__tests__/features/notifications/notification-panel-delete.test.tsx
  19. 92 0
      src/__tests__/features/vehicles/media-grid-delete-failure.test.tsx
  20. 97 0
      src/__tests__/features/vehicles/work-order-delete-failure.test.ts
  21. 8 6
      src/app/(authenticated)/customers/customers-client.tsx
  22. 8 6
      src/app/(authenticated)/inventory/inventory-client.tsx
  23. 8 6
      src/app/(authenticated)/labor-presets/labor-presets-client.tsx
  24. 5 3
      src/app/(authenticated)/vehicles/vehicles-client.tsx
  25. 12 9
      src/features/condition-map/Components/ConditionMapCard.tsx
  26. 5 3
      src/features/inspections/Components/InspectionItemRow.tsx
  27. 86 12
      src/features/notifications/Components/NotificationPanel.tsx
  28. 18 0
      src/features/notifications/store/notificationStore.ts
  29. 5 3
      src/features/vehicles/Components/service-page/modern/MediaGrid.tsx
  30. 6 3
      src/features/vehicles/Components/service-page/useServiceActions.ts
  31. 10 6
      src/features/workboard/Components/WorkBoardClient.tsx

+ 1 - 0
messages/de/notifications.json

@@ -5,6 +5,7 @@
     "clearAll": "Alle entfernen",
     "clearAll": "Alle entfernen",
     "confirmClearAll": "Alle Benachrichtigungen entfernen?",
     "confirmClearAll": "Alle Benachrichtigungen entfernen?",
     "clearAllFailed": "Benachrichtigungen konnten nicht entfernt werden",
     "clearAllFailed": "Benachrichtigungen konnten nicht entfernt werden",
+    "deleteFailed": "Benachrichtigung konnte nicht gelöscht werden",
     "empty": "Noch keine Benachrichtigungen",
     "empty": "Noch keine Benachrichtigungen",
     "confirmDelete": "Sind Sie sicher?",
     "confirmDelete": "Sind Sie sicher?",
     "delete": "Löschen",
     "delete": "Löschen",

+ 1 - 0
messages/en/notifications.json

@@ -5,6 +5,7 @@
     "clearAll": "Clear all",
     "clearAll": "Clear all",
     "confirmClearAll": "Remove all notifications?",
     "confirmClearAll": "Remove all notifications?",
     "clearAllFailed": "Could not clear the notifications",
     "clearAllFailed": "Could not clear the notifications",
+    "deleteFailed": "Could not delete the notification",
     "empty": "No notifications yet",
     "empty": "No notifications yet",
     "confirmDelete": "Are you sure?",
     "confirmDelete": "Are you sure?",
     "delete": "Delete",
     "delete": "Delete",

+ 1 - 0
messages/es/notifications.json

@@ -5,6 +5,7 @@
     "clearAll": "Borrar todo",
     "clearAll": "Borrar todo",
     "confirmClearAll": "¿Eliminar todas las notificaciones?",
     "confirmClearAll": "¿Eliminar todas las notificaciones?",
     "clearAllFailed": "No se pudieron borrar las notificaciones",
     "clearAllFailed": "No se pudieron borrar las notificaciones",
+    "deleteFailed": "No se pudo eliminar la notificación",
     "empty": "Aún no hay notificaciones",
     "empty": "Aún no hay notificaciones",
     "confirmDelete": "¿Estás seguro?",
     "confirmDelete": "¿Estás seguro?",
     "delete": "Eliminar",
     "delete": "Eliminar",

+ 1 - 0
messages/fr/notifications.json

@@ -5,6 +5,7 @@
     "clearAll": "Tout effacer",
     "clearAll": "Tout effacer",
     "confirmClearAll": "Supprimer toutes les notifications ?",
     "confirmClearAll": "Supprimer toutes les notifications ?",
     "clearAllFailed": "Impossible d'effacer les notifications",
     "clearAllFailed": "Impossible d'effacer les notifications",
+    "deleteFailed": "Impossible de supprimer la notification",
     "empty": "Aucune notification pour le moment",
     "empty": "Aucune notification pour le moment",
     "confirmDelete": "Êtes-vous sûr ?",
     "confirmDelete": "Êtes-vous sûr ?",
     "delete": "Supprimer",
     "delete": "Supprimer",

+ 1 - 0
messages/it/notifications.json

@@ -5,6 +5,7 @@
     "clearAll": "Cancella tutto",
     "clearAll": "Cancella tutto",
     "confirmClearAll": "Eliminare tutte le notifiche?",
     "confirmClearAll": "Eliminare tutte le notifiche?",
     "clearAllFailed": "Impossibile cancellare le notifiche",
     "clearAllFailed": "Impossibile cancellare le notifiche",
+    "deleteFailed": "Impossibile eliminare la notifica",
     "empty": "Nessuna notifica ancora",
     "empty": "Nessuna notifica ancora",
     "confirmDelete": "Sei sicuro?",
     "confirmDelete": "Sei sicuro?",
     "delete": "Elimina",
     "delete": "Elimina",

+ 1 - 0
messages/lt/notifications.json

@@ -5,6 +5,7 @@
     "clearAll": "Išvalyti visus",
     "clearAll": "Išvalyti visus",
     "confirmClearAll": "Pašalinti visus pranešimus?",
     "confirmClearAll": "Pašalinti visus pranešimus?",
     "clearAllFailed": "Nepavyko išvalyti pranešimų",
     "clearAllFailed": "Nepavyko išvalyti pranešimų",
+    "deleteFailed": "Nepavyko ištrinti pranešimo",
     "empty": "Pranešimų dar nėra",
     "empty": "Pranešimų dar nėra",
     "confirmDelete": "Ar tikrai?",
     "confirmDelete": "Ar tikrai?",
     "delete": "Ištrinti",
     "delete": "Ištrinti",

+ 1 - 0
messages/nb/notifications.json

@@ -5,6 +5,7 @@
     "clearAll": "Fjern alle",
     "clearAll": "Fjern alle",
     "confirmClearAll": "Fjerne alle varsler?",
     "confirmClearAll": "Fjerne alle varsler?",
     "clearAllFailed": "Kunne ikke fjerne varslene",
     "clearAllFailed": "Kunne ikke fjerne varslene",
+    "deleteFailed": "Kunne ikke slette varselet",
     "empty": "Ingen varsler ennå",
     "empty": "Ingen varsler ennå",
     "confirmDelete": "Er du sikker?",
     "confirmDelete": "Er du sikker?",
     "delete": "Slett",
     "delete": "Slett",

+ 1 - 0
messages/nl/notifications.json

@@ -5,6 +5,7 @@
     "clearAll": "Alles wissen",
     "clearAll": "Alles wissen",
     "confirmClearAll": "Alle meldingen verwijderen?",
     "confirmClearAll": "Alle meldingen verwijderen?",
     "clearAllFailed": "Meldingen konden niet worden gewist",
     "clearAllFailed": "Meldingen konden niet worden gewist",
+    "deleteFailed": "Melding kon niet worden verwijderd",
     "empty": "Nog geen meldingen",
     "empty": "Nog geen meldingen",
     "confirmDelete": "Weet je het zeker?",
     "confirmDelete": "Weet je het zeker?",
     "delete": "Verwijderen",
     "delete": "Verwijderen",

+ 1 - 0
messages/pl/notifications.json

@@ -5,6 +5,7 @@
     "clearAll": "Wyczyść wszystko",
     "clearAll": "Wyczyść wszystko",
     "confirmClearAll": "Usunąć wszystkie powiadomienia?",
     "confirmClearAll": "Usunąć wszystkie powiadomienia?",
     "clearAllFailed": "Nie udało się wyczyścić powiadomień",
     "clearAllFailed": "Nie udało się wyczyścić powiadomień",
+    "deleteFailed": "Nie udało się usunąć powiadomienia",
     "empty": "Brak powiadomień",
     "empty": "Brak powiadomień",
     "confirmDelete": "Czy na pewno?",
     "confirmDelete": "Czy na pewno?",
     "delete": "Usuń",
     "delete": "Usuń",

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

@@ -5,6 +5,7 @@
     "clearAll": "Limpar tudo",
     "clearAll": "Limpar tudo",
     "confirmClearAll": "Excluir todas as notificações?",
     "confirmClearAll": "Excluir todas as notificações?",
     "clearAllFailed": "Não foi possível limpar as notificações",
     "clearAllFailed": "Não foi possível limpar as notificações",
+    "deleteFailed": "Não foi possível excluir a notificação",
     "empty": "Nenhuma notificação ainda",
     "empty": "Nenhuma notificação ainda",
     "confirmDelete": "Tem certeza?",
     "confirmDelete": "Tem certeza?",
     "delete": "Excluir",
     "delete": "Excluir",

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

@@ -5,6 +5,7 @@
     "clearAll": "Limpar tudo",
     "clearAll": "Limpar tudo",
     "confirmClearAll": "Eliminar todas as notificações?",
     "confirmClearAll": "Eliminar todas as notificações?",
     "clearAllFailed": "Não foi possível limpar as notificações",
     "clearAllFailed": "Não foi possível limpar as notificações",
+    "deleteFailed": "Não foi possível eliminar a notificação",
     "empty": "Ainda não há notificações",
     "empty": "Ainda não há notificações",
     "confirmDelete": "Tem a certeza?",
     "confirmDelete": "Tem a certeza?",
     "delete": "Eliminar",
     "delete": "Eliminar",

+ 1 - 0
messages/ru/notifications.json

@@ -5,6 +5,7 @@
     "clearAll": "Очистить все",
     "clearAll": "Очистить все",
     "confirmClearAll": "Удалить все уведомления?",
     "confirmClearAll": "Удалить все уведомления?",
     "clearAllFailed": "Не удалось очистить уведомления",
     "clearAllFailed": "Не удалось очистить уведомления",
+    "deleteFailed": "Не удалось удалить уведомление",
     "empty": "Уведомлений пока нет",
     "empty": "Уведомлений пока нет",
     "confirmDelete": "Вы уверены?",
     "confirmDelete": "Вы уверены?",
     "delete": "Удалить",
     "delete": "Удалить",

+ 1 - 0
messages/tr/notifications.json

@@ -5,6 +5,7 @@
     "clearAll": "Tümünü temizle",
     "clearAll": "Tümünü temizle",
     "confirmClearAll": "Tüm bildirimler silinsin mi?",
     "confirmClearAll": "Tüm bildirimler silinsin mi?",
     "clearAllFailed": "Bildirimler temizlenemedi",
     "clearAllFailed": "Bildirimler temizlenemedi",
+    "deleteFailed": "Bildirim silinemedi",
     "empty": "Henüz bildirim yok",
     "empty": "Henüz bildirim yok",
     "confirmDelete": "Emin misiniz?",
     "confirmDelete": "Emin misiniz?",
     "delete": "Sil",
     "delete": "Sil",

+ 105 - 0
src/__tests__/app/customers/customers-client-delete.test.tsx

@@ -0,0 +1,105 @@
+/**
+ * The customer list says so when a delete does not reach the server, for one
+ * customer and for a selection. The action throws in that case instead of
+ * answering, and the list only read the answer: nothing was shown, and the
+ * selection's Delete button stayed busy until the page was reloaded.
+ */
+import { act, fireEvent, render, screen, within } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { ConfirmProvider } from '@/components/confirm-dialog'
+import { GlassModal, useGlassModal } from '@/components/glass-modal'
+
+const deleteCustomer = vi.fn()
+const deleteCustomers = vi.fn()
+const router = { push: vi.fn(), refresh: vi.fn() }
+const searchParams = new URLSearchParams()
+
+vi.mock('next/navigation', () => ({
+  useRouter: () => router,
+  usePathname: () => '/customers',
+  useSearchParams: () => searchParams,
+}))
+vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
+vi.mock('@/features/customers/Actions/customerActions', () => ({
+  backfillCustomerNumbers: vi.fn(),
+  deleteCustomer: async (id: string) => deleteCustomer(id),
+  deleteCustomers: async (ids: string[]) => deleteCustomers(ids),
+}))
+// Dialogs of their own; closed throughout.
+vi.mock('@/features/customers/Components/CustomerForm', () => ({ CustomerForm: () => null }))
+vi.mock('@/features/import/Components/ImportWizard', () => ({ ImportWizard: () => null }))
+
+import { CustomersClient } from '@/app/(authenticated)/customers/customers-client'
+
+const CUSTOMER = {
+  id: 'c1',
+  customerNumber: '1001',
+  name: 'Ada Berg',
+  email: 'ada@example.com',
+  phone: null,
+  address: null,
+  company: null,
+  taxId: null,
+  taxExempt: false,
+  notes: null,
+  _count: { vehicles: 1 },
+}
+
+function renderList() {
+  render(
+    <ConfirmProvider>
+      <CustomersClient
+        data={{ customers: [CUSTOMER], total: 1, page: 1, pageSize: 20, totalPages: 1 }}
+        search=""
+        sortBy="name"
+        sortOrder="asc"
+      />
+      <GlassModal />
+    </ConfirmProvider>
+  )
+}
+
+/** Answers the question on screen with Delete. */
+async function confirmDelete() {
+  const question = await screen.findByRole('alertdialog')
+  await act(async () => {
+    fireEvent.click(within(question).getByRole('button', { name: 'Delete' }))
+  })
+}
+
+describe('customer list deletes', () => {
+  beforeEach(() => {
+    deleteCustomer.mockReset()
+    deleteCustomers.mockReset()
+    router.refresh.mockClear()
+    act(() => useGlassModal.getState().close())
+  })
+
+  it('says the delete failed when one customer cannot be deleted for lack of a connection', async () => {
+    deleteCustomer.mockRejectedValue(new TypeError('Failed to fetch'))
+    renderList()
+
+    fireEvent.keyDown(screen.getAllByRole('button', { name: 'Open menu' })[0], { key: 'Enter' })
+    fireEvent.click(await screen.findByRole('menuitem', { name: 'Delete' }))
+    await confirmDelete()
+
+    expect(deleteCustomer).toHaveBeenCalledWith('c1')
+    expect(await screen.findByText('Failed to delete customer')).toBeInTheDocument()
+    expect(router.refresh).not.toHaveBeenCalled()
+  })
+
+  it('says the delete failed for a selection, and leaves its Delete button usable', async () => {
+    deleteCustomers.mockRejectedValue(new TypeError('Failed to fetch'))
+    renderList()
+
+    fireEvent.click(screen.getAllByRole('checkbox')[0])
+    fireEvent.click(screen.getByRole('button', { name: 'Delete (1)' }))
+    await confirmDelete()
+
+    expect(deleteCustomers).toHaveBeenCalledWith(['c1'])
+    expect(await screen.findByText('Failed to delete customer')).toBeInTheDocument()
+    // Hidden behind the message, but not left busy: the selection is still
+    // there to try again once the message is closed.
+    expect(screen.getByRole('button', { name: 'Delete (1)', hidden: true })).toBeEnabled()
+  })
+})

+ 142 - 0
src/__tests__/app/labor-presets/labor-presets-client.test.tsx

@@ -0,0 +1,142 @@
+/**
+ * The labor packages list says so when a delete or an edit does not go
+ * through, whether the server refused it or the request never arrived.
+ *
+ * A server action that cannot reach the server throws instead of answering.
+ * The list only read the answer, so on a weak connection Delete did nothing
+ * anyone could see and people pressed it again and again.
+ */
+import { act, fireEvent, render, screen, within } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { ConfirmProvider } from '@/components/confirm-dialog'
+import { GlassModal, useGlassModal } from '@/components/glass-modal'
+
+const deleteLaborPreset = vi.fn()
+const getLaborPreset = vi.fn()
+const router = { push: vi.fn(), refresh: vi.fn() }
+const searchParams = new URLSearchParams()
+
+vi.mock('next/navigation', () => ({
+  useRouter: () => router,
+  usePathname: () => '/labor-presets',
+  useSearchParams: () => searchParams,
+}))
+
+vi.mock('@/features/labor-presets/Actions/laborPresetActions', () => ({
+  deleteLaborPreset: (id: string) => deleteLaborPreset(id),
+  getLaborPreset: (id: string) => getLaborPreset(id),
+}))
+
+// The form is its own screen; here it only has to show that it opened.
+vi.mock('@/features/labor-presets/Components/LaborPresetForm', () => ({
+  LaborPresetForm: ({ open, preset }: { open: boolean; preset?: { name: string } }) =>
+    open ? <p>Editing {preset?.name}</p> : null,
+}))
+
+import { LaborPresetsClient } from '@/app/(authenticated)/labor-presets/labor-presets-client'
+
+/** What a browser's fetch throws when the request never reached the server. */
+const offline = () => new TypeError('Failed to fetch')
+
+const PRESET = {
+  id: 'p1',
+  name: 'Brake service',
+  description: 'Pads and discs, front axle',
+  _count: { items: 2 },
+  items: [
+    { hours: 1.5, pricingType: 'hourly' },
+    { hours: 1, pricingType: 'service' },
+  ],
+}
+
+function renderList() {
+  render(
+    <ConfirmProvider>
+      <LaborPresetsClient
+        data={{ presets: [PRESET], total: 1, page: 1, pageSize: 20, totalPages: 1 }}
+        search=""
+        sortBy="name"
+        sortOrder="asc"
+      />
+      <GlassModal />
+    </ConfirmProvider>
+  )
+}
+
+/** Opens the row's menu, picks Delete, and answers the question with Delete. */
+async function deleteFromMenu() {
+  fireEvent.keyDown(screen.getAllByRole('button', { name: 'Open menu' })[0], { key: 'Enter' })
+  fireEvent.click(await screen.findByRole('menuitem', { name: 'Delete' }))
+  const question = await screen.findByRole('alertdialog')
+  expect(within(question).getByText(/permanently delete "Brake service"/)).toBeInTheDocument()
+  await act(async () => {
+    fireEvent.click(within(question).getByRole('button', { name: 'Delete' }))
+  })
+}
+
+describe('labor packages list', () => {
+  beforeEach(() => {
+    deleteLaborPreset.mockReset()
+    getLaborPreset.mockReset()
+    router.push.mockClear()
+    router.refresh.mockClear()
+    act(() => useGlassModal.getState().close())
+  })
+
+  it('re-reads the list after a delete that went through', async () => {
+    deleteLaborPreset.mockResolvedValue({ success: true })
+    renderList()
+
+    await deleteFromMenu()
+
+    expect(deleteLaborPreset).toHaveBeenCalledWith('p1')
+    expect(router.refresh).toHaveBeenCalledTimes(1)
+    expect(screen.queryByText('Failed to delete labor package')).not.toBeInTheDocument()
+  })
+
+  it('says the delete failed when the request never reaches the server', async () => {
+    deleteLaborPreset.mockRejectedValue(offline())
+    renderList()
+
+    await deleteFromMenu()
+
+    expect(await screen.findByText('Failed to delete labor package')).toBeInTheDocument()
+    expect(router.refresh).not.toHaveBeenCalled()
+  })
+
+  it('shows what the server said when it refuses the delete', async () => {
+    deleteLaborPreset.mockResolvedValue({ success: false, error: 'Preset not found' })
+    renderList()
+
+    await deleteFromMenu()
+
+    expect(await screen.findByText('Preset not found')).toBeInTheDocument()
+    expect(router.refresh).not.toHaveBeenCalled()
+  })
+
+  it('says the package could not be loaded when opening it never reaches the server', async () => {
+    getLaborPreset.mockRejectedValue(offline())
+    renderList()
+
+    await act(async () => {
+      fireEvent.click(screen.getAllByText('Brake service')[0])
+    })
+
+    expect(await screen.findByText('Failed to load labor package')).toBeInTheDocument()
+    expect(screen.queryByText('Editing Brake service')).not.toBeInTheDocument()
+  })
+
+  it('opens the package for editing once it has loaded', async () => {
+    getLaborPreset.mockResolvedValue({
+      success: true,
+      data: { id: 'p1', name: 'Brake service', description: null, items: [] },
+    })
+    renderList()
+
+    await act(async () => {
+      fireEvent.click(screen.getAllByText('Brake service')[0])
+    })
+
+    expect(await screen.findByText('Editing Brake service')).toBeInTheDocument()
+  })
+})

+ 119 - 0
src/__tests__/features/condition-map/remove-mark.test.tsx

@@ -0,0 +1,119 @@
+/**
+ * Removing a mark from the condition map takes it off the drawing at once.
+ * When the removal does not go through, the mark comes back 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.
+ */
+import { act, fireEvent, render, screen, within } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const removeConditionMark = vi.fn()
+const removeConditionMarkPhoto = vi.fn()
+const toastError = vi.fn()
+
+vi.mock('next/navigation', () => ({ useRouter: () => ({ refresh: vi.fn(), 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: vi.fn(),
+  discardConditionMarkUploads: vi.fn(),
+  removeConditionMark: async (id: string) => removeConditionMark(id),
+  removeConditionMarkPhoto: async (input: unknown) => removeConditionMarkPhoto(input),
+  resolveConditionMark: vi.fn(),
+  setVehicleBodyType: vi.fn(),
+  updateConditionMark: vi.fn(),
+}))
+// 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 MARKS: 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: ['/uploads/dent.jpg'],
+    recordedAt: '2026-11-09T09:00:00.000Z',
+    resolvedAt: null,
+  },
+]
+
+const renderCard = () =>
+  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/ }))
+
+describe('removing from the condition map', () => {
+  beforeEach(() => {
+    removeConditionMark.mockReset()
+    removeConditionMarkPhoto.mockReset()
+    toastError.mockClear()
+  })
+
+  it('takes the mark off once the server has removed it', async () => {
+    removeConditionMark.mockResolvedValue({ success: true })
+    renderCard()
+
+    openMark()
+    await act(async () => {
+      fireEvent.click(screen.getByRole('button', { name: 'Remove mark' }))
+    })
+
+    expect(removeConditionMark).toHaveBeenCalledWith('m1')
+    expect(legend().queryByRole('button', { name: /^Mark 1: Dent/ })).not.toBeInTheDocument()
+    expect(toastError).not.toHaveBeenCalled()
+  })
+
+  it('brings the mark back and says so when the request never reaches the server', async () => {
+    removeConditionMark.mockRejectedValue(new TypeError('Failed to fetch'))
+    renderCard()
+
+    openMark()
+    await act(async () => {
+      fireEvent.click(screen.getByRole('button', { name: 'Remove mark' }))
+    })
+
+    expect(legend().getByRole('button', { name: /^Mark 1: Dent/ })).toBeInTheDocument()
+    expect(toastError).toHaveBeenCalledWith('Could not save the mark')
+  })
+
+  it('brings a photo back and says so when its removal never reaches the server', async () => {
+    removeConditionMarkPhoto.mockRejectedValue(new TypeError('Failed to fetch'))
+    renderCard()
+
+    openMark()
+    await act(async () => {
+      fireEvent.click(screen.getByRole('button', { name: 'Remove photo 1' }))
+    })
+
+    expect(removeConditionMarkPhoto).toHaveBeenCalledWith({ id: 'm1', url: '/uploads/dent.jpg' })
+    expect(screen.getByRole('button', { name: 'Remove photo 1' })).toBeInTheDocument()
+    expect(toastError).toHaveBeenCalledWith('Could not save the mark')
+  })
+})

+ 102 - 0
src/__tests__/features/inspections/item-photo-remove.test.tsx

@@ -0,0 +1,102 @@
+/**
+ * Removing a photo from a check takes it off the row at once. When the
+ * removal does not go through, because the server refused it or the request
+ * never arrived, the photo comes back and the row says so. On a dropped
+ * connection it used to stay gone until the page was reloaded, where it was
+ * back as if nothing had been pressed.
+ */
+import { act, fireEvent, render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const removeInspectionItemMedia = vi.fn()
+const toastError = vi.fn()
+
+vi.mock('next/navigation', () => ({ useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }) }))
+vi.mock('sonner', () => ({
+  toast: { success: vi.fn(), error: (...args: unknown[]) => toastError(...args) },
+}))
+vi.mock('@/features/inspections/Actions/inspectionActions', () => ({
+  updateInspectionItem: vi.fn(),
+}))
+vi.mock('@/features/inspections/Actions/attachmentActions', () => ({
+  addInspectionItemMedia: vi.fn(),
+  removeInspectionItemMedia: async (input: unknown) => removeInspectionItemMedia(input),
+}))
+vi.mock('@/features/vehicles/Components/service-page/PhotoHandoffButton', () => ({
+  PhotoHandoffButton: () => null,
+}))
+
+import { InspectionItemRow } from '@/features/inspections/Components/InspectionItemRow'
+
+const ITEM = {
+  id: 'tyres',
+  name: 'Tyres',
+  section: 'Wheels',
+  sortOrder: 0,
+  condition: 'fail',
+  notes: null,
+  imageUrls: ['/uploads/tread.jpg', '/uploads/sidewall.jpg'],
+}
+
+const renderRow = () =>
+  render(
+    <InspectionItemRow
+      item={ITEM}
+      scale="eu"
+      isCompleted={false}
+      onOpenImage={() => undefined}
+      onChanged={() => undefined}
+    />
+  )
+
+const photos = () => screen.getAllByRole('img').map((img) => img.getAttribute('src'))
+
+const removeFirst = () =>
+  act(async () => {
+    fireEvent.click(screen.getByRole('button', { name: 'Remove photo 1 from Tyres' }))
+  })
+
+describe('removing a photo from a check', () => {
+  beforeEach(() => {
+    removeInspectionItemMedia.mockReset()
+    toastError.mockClear()
+  })
+
+  it('takes the photo off the row once the server has removed it', async () => {
+    removeInspectionItemMedia.mockResolvedValue({ success: true, data: ['/uploads/sidewall.jpg'] })
+    renderRow()
+
+    await removeFirst()
+
+    expect(removeInspectionItemMedia).toHaveBeenCalledWith({
+      itemId: 'tyres',
+      url: '/uploads/tread.jpg',
+    })
+    expect(photos()).toEqual(['/uploads/sidewall.jpg'])
+    expect(toastError).not.toHaveBeenCalled()
+  })
+
+  it('brings the photo back and says so when the request never reaches the server', async () => {
+    removeInspectionItemMedia.mockRejectedValue(new TypeError('Failed to fetch'))
+    renderRow()
+
+    await removeFirst()
+
+    expect(photos()).toHaveLength(2)
+    expect(photos()).toContain('/uploads/tread.jpg')
+    expect(toastError).toHaveBeenCalledWith('Could not save this check')
+  })
+
+  it('brings the photo back with the reason when the server refuses', async () => {
+    removeInspectionItemMedia.mockResolvedValue({
+      success: false,
+      error: 'Reopen the inspection to change its photos',
+    })
+    renderRow()
+
+    await removeFirst()
+
+    expect(photos()).toContain('/uploads/tread.jpg')
+    expect(toastError).toHaveBeenCalledWith('Reopen the inspection to change its photos')
+  })
+})

+ 370 - 0
src/__tests__/features/notifications/notification-panel-delete.test.tsx

@@ -0,0 +1,370 @@
+/**
+ * Deleting one notification in the panel: it goes at once, and the server is
+ * told. A delete that the server refuses, or that never reaches it, puts the
+ * notification back where it was and says so. It used to stay gone until the
+ * next reload brought it back.
+ *
+ * Opening a notification marks it read as bookkeeping nobody waits on, so a
+ * failure there stays quiet rather than surfacing as a lost connection. Mark
+ * all read that fails shows the notifications unread again.
+ */
+import { act, fireEvent, render, screen, within } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { NotificationPanel } from '@/features/notifications/Components/NotificationPanel'
+import { useNotificationStore } from '@/features/notifications/store/notificationStore'
+
+const deleteNotification = vi.fn(async (_id: string) => ({ success: true }))
+const markNotificationRead = vi.fn(async (_id: string) => ({ success: true }))
+const markAllNotificationsRead = vi.fn(async () => ({ success: true }))
+type Fresh = { notifications: ReturnType<typeof makeNotifications>; unreadCount: number } | null
+const getNotifications = vi.fn(async () => ({ success: true, data: null as Fresh }))
+const deleteAllNotifications = vi.fn(async (_before: string) => ({ success: true }))
+const toastError = vi.fn()
+
+vi.mock('next/navigation', () => ({
+  useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }),
+}))
+
+vi.mock('sonner', () => ({
+  toast: Object.assign(vi.fn(), { error: (...args: unknown[]) => toastError(...args) }),
+}))
+
+// Async wrappers, as the real actions are: each call hands the panel a promise
+// of its own. Vitest watches the promise a mock returns, which would count as
+// handling a rejection the panel itself let go.
+vi.mock('@/features/notifications/Actions/notificationActions', () => ({
+  getNotifications: async () => getNotifications(),
+  markNotificationRead: async (id: string) => markNotificationRead(id),
+  markAllNotificationsRead: async () => markAllNotificationsRead(),
+  deleteNotification: async (id: string) => deleteNotification(id),
+  deleteAllNotifications: async (before: string) => deleteAllNotifications(before),
+}))
+
+/** What a browser's fetch throws when the request never reached the server. */
+const offline = () => new TypeError('Failed to fetch')
+
+function makeNotifications(count: number) {
+  return Array.from({ length: count }, (_, i) => ({
+    id: `n${i}`,
+    type: 'quote_accepted',
+    title: `Quote ${i} accepted`,
+    message: 'A customer accepted a quote',
+    entityType: 'quote',
+    entityId: `q${i}`,
+    entityUrl: `/quotes/q${i}`,
+    // Only the middle one of three is unread.
+    read: i !== 1,
+    organizationId: 'org1',
+    // Newest first, a minute apart.
+    createdAt: new Date(Date.UTC(2026, 9, 30, 12, 59 - i)).toISOString(),
+  }))
+}
+
+function seed() {
+  useNotificationStore.getState().setNotifications(makeNotifications(3), 1)
+  useNotificationStore.getState().setPanelOpen(true)
+}
+
+/** A request held open until the test answers it. */
+function deferred<T>() {
+  let resolve: (value: T) => void = () => undefined
+  let reject: (reason: unknown) => void = () => undefined
+  const promise = new Promise<T>((res, rej) => {
+    resolve = res
+    reject = rej
+  })
+  return { promise, resolve, reject }
+}
+
+const titles = () => screen.getAllByText(/^Quote \d accepted$/).map((el) => el.textContent)
+
+/** Presses the row's X, then Delete on the question it asks. */
+async function deleteRow(title: string) {
+  const row = screen.getByText(title).closest('[role="button"]') as HTMLElement
+  fireEvent.click(within(row).getAllByRole('button')[0])
+  expect(within(row).getByText('Are you sure?')).toBeInTheDocument()
+  await act(async () => {
+    fireEvent.click(within(row).getByRole('button', { name: 'Delete' }))
+  })
+}
+
+/** Long enough for Node to report a rejection that nothing handled. */
+const settle = (ms = 0) =>
+  act(async () => {
+    await new Promise((resolve) => setTimeout(resolve, ms))
+  })
+
+describe('NotificationPanel delete', () => {
+  const unhandled = vi.fn()
+
+  beforeEach(() => {
+    deleteNotification.mockReset()
+    deleteNotification.mockResolvedValue({ success: true })
+    markNotificationRead.mockReset()
+    markNotificationRead.mockResolvedValue({ success: true })
+    markAllNotificationsRead.mockReset()
+    markAllNotificationsRead.mockResolvedValue({ success: true })
+    getNotifications.mockReset()
+    getNotifications.mockResolvedValue({ success: true, data: null })
+    deleteAllNotifications.mockReset()
+    deleteAllNotifications.mockResolvedValue({ success: true })
+    toastError.mockClear()
+    unhandled.mockClear()
+    process.on('unhandledRejection', unhandled)
+    useNotificationStore.getState().setNotifications([], 0)
+  })
+
+  afterEach(() => {
+    process.off('unhandledRejection', unhandled)
+  })
+
+  it('removes the notification and tells the server', async () => {
+    seed()
+    render(<NotificationPanel />)
+
+    await deleteRow('Quote 1 accepted')
+
+    expect(deleteNotification).toHaveBeenCalledWith('n1')
+    expect(titles()).toEqual(['Quote 0 accepted', 'Quote 2 accepted'])
+    expect(useNotificationStore.getState().unreadCount).toBe(0)
+    expect(toastError).not.toHaveBeenCalled()
+  })
+
+  it('puts the notification back in its place and says so when the server refuses', async () => {
+    seed()
+    deleteNotification.mockResolvedValue({ success: false })
+    render(<NotificationPanel />)
+
+    await deleteRow('Quote 1 accepted')
+
+    expect(titles()).toEqual(['Quote 0 accepted', 'Quote 1 accepted', 'Quote 2 accepted'])
+    expect(useNotificationStore.getState().unreadCount).toBe(1)
+    expect(toastError).toHaveBeenCalledWith('Could not delete the notification')
+  })
+
+  it('puts the notification back and says so when the request never arrives', async () => {
+    seed()
+    deleteNotification.mockRejectedValue(offline())
+    render(<NotificationPanel />)
+
+    await deleteRow('Quote 1 accepted')
+    await settle()
+
+    expect(titles()).toEqual(['Quote 0 accepted', 'Quote 1 accepted', 'Quote 2 accepted'])
+    expect(useNotificationStore.getState().unreadCount).toBe(1)
+    expect(toastError).toHaveBeenCalledWith('Could not delete the notification')
+    // Handled here, so the lost-connection notice has nothing to add.
+    expect(unhandled).not.toHaveBeenCalled()
+  })
+
+  it('keeps a notification that arrived meanwhile above the one put back', async () => {
+    seed()
+    let fail: (reason: unknown) => void = () => undefined
+    deleteNotification.mockReturnValue(
+      new Promise<{ success: boolean }>((_resolve, reject) => {
+        fail = reject
+      })
+    )
+    render(<NotificationPanel />)
+
+    await deleteRow('Quote 0 accepted')
+    act(() => {
+      useNotificationStore.getState().addNotification({
+        ...makeNotifications(1)[0],
+        id: 'n9',
+        title: 'Quote 9 accepted',
+        read: false,
+        createdAt: new Date(Date.UTC(2026, 9, 30, 13, 5)).toISOString(),
+      })
+    })
+    await act(async () => {
+      fail(offline())
+    })
+
+    expect(titles()).toEqual([
+      'Quote 9 accepted',
+      'Quote 0 accepted',
+      'Quote 1 accepted',
+      'Quote 2 accepted',
+    ])
+    // The middle one of the first three, and the new arrival.
+    expect(useNotificationStore.getState().unreadCount).toBe(2)
+  })
+
+  it('stays quiet when opening a notification cannot mark it read', async () => {
+    seed()
+    markNotificationRead.mockRejectedValue(offline())
+    render(<NotificationPanel />)
+
+    fireEvent.click(screen.getByText('Quote 1 accepted'))
+    // Past the pause before the panel navigates, so nothing is left running.
+    await settle(350)
+
+    expect(markNotificationRead).toHaveBeenCalledWith('n1')
+    expect(useNotificationStore.getState().unreadCount).toBe(0)
+    expect(unhandled).not.toHaveBeenCalled()
+    expect(toastError).not.toHaveBeenCalled()
+  })
+
+  it('marks all read', async () => {
+    seed()
+    render(<NotificationPanel />)
+
+    fireEvent.click(screen.getByRole('button', { name: 'Mark all read' }))
+    await settle()
+
+    expect(markAllNotificationsRead).toHaveBeenCalledTimes(1)
+    expect(useNotificationStore.getState().unreadCount).toBe(0)
+    expect(useNotificationStore.getState().notifications.every((n) => n.read)).toBe(true)
+  })
+
+  it('shows them unread again when mark all read cannot reach the server', async () => {
+    seed()
+    markAllNotificationsRead.mockRejectedValue(offline())
+    render(<NotificationPanel />)
+
+    fireEvent.click(screen.getByRole('button', { name: 'Mark all read' }))
+    await settle()
+
+    expect(markAllNotificationsRead).toHaveBeenCalledTimes(1)
+    expect(useNotificationStore.getState().unreadCount).toBe(1)
+    expect(
+      useNotificationStore
+        .getState()
+        .notifications.filter((n) => !n.read)
+        .map((n) => n.id)
+    ).toEqual(['n1'])
+    expect(unhandled).not.toHaveBeenCalled()
+  })
+
+  it('shows them unread again when the server refuses mark all read', async () => {
+    seed()
+    markAllNotificationsRead.mockResolvedValue({ success: false })
+    render(<NotificationPanel />)
+
+    fireEvent.click(screen.getByRole('button', { name: 'Mark all read' }))
+    await settle()
+
+    expect(useNotificationStore.getState().unreadCount).toBe(1)
+  })
+
+  it('counts every unread one again when mark all read fails after new ones pushed some off the list', async () => {
+    // A full list of 50 unread; one more arriving pushes the oldest off the
+    // end of the list, but it is still unread on the server.
+    const full = makeNotifications(50).map((n) => ({ ...n, read: false }))
+    useNotificationStore.getState().setNotifications(full, 50)
+    useNotificationStore.getState().setPanelOpen(true)
+    const markAll = deferred<{ success: boolean }>()
+    markAllNotificationsRead.mockReturnValue(markAll.promise)
+    render(<NotificationPanel />)
+
+    fireEvent.click(screen.getByRole('button', { name: 'Mark all read' }))
+    act(() => {
+      useNotificationStore.getState().addNotification({
+        ...full[0],
+        id: 'n99',
+        title: 'Quote 99 accepted',
+        createdAt: new Date(Date.UTC(2026, 9, 30, 13, 5)).toISOString(),
+      })
+    })
+    await act(async () => {
+      markAll.reject(offline())
+    })
+    await settle()
+
+    expect(useNotificationStore.getState().unreadCount).toBe(51)
+  })
+
+  it('does not bring back a notification that clear all removed while its delete was out', async () => {
+    seed()
+    const del = deferred<{ success: boolean }>()
+    deleteNotification.mockReturnValue(del.promise)
+    getNotifications.mockResolvedValue({
+      success: true,
+      data: { notifications: [], unreadCount: 0 },
+    })
+    render(<NotificationPanel />)
+
+    await deleteRow('Quote 1 accepted')
+    fireEvent.click(screen.getByRole('button', { name: 'Clear all' }))
+    await act(async () => {
+      fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
+    })
+    await act(async () => {
+      del.reject(offline())
+    })
+    await settle()
+
+    expect(screen.queryByText(/^Quote \d accepted$/)).not.toBeInTheDocument()
+    expect(useNotificationStore.getState().unreadCount).toBe(0)
+    expect(toastError).toHaveBeenCalledWith('Could not delete the notification')
+  })
+
+  it('does not bring back a notification that clear all removed, when clear all answers last', async () => {
+    seed()
+    const del = deferred<{ success: boolean }>()
+    const clear = deferred<{ success: boolean }>()
+    deleteNotification.mockReturnValue(del.promise)
+    deleteAllNotifications.mockReturnValue(clear.promise)
+    getNotifications.mockResolvedValue({
+      success: true,
+      data: { notifications: [], unreadCount: 0 },
+    })
+    render(<NotificationPanel />)
+
+    await deleteRow('Quote 1 accepted')
+    fireEvent.click(screen.getByRole('button', { name: 'Clear all' }))
+    await act(async () => {
+      fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
+    })
+    await act(async () => {
+      del.reject(offline())
+    })
+    // Not put back while clear all is still out.
+    expect(screen.queryByText(/^Quote \d accepted$/)).not.toBeInTheDocument()
+    await act(async () => {
+      clear.resolve({ success: true })
+    })
+    await settle()
+
+    expect(screen.queryByText(/^Quote \d accepted$/)).not.toBeInTheDocument()
+    expect(useNotificationStore.getState().unreadCount).toBe(0)
+    expect(toastError).toHaveBeenCalledWith('Could not delete the notification')
+  })
+
+  it('does not undo a later mark all read that succeeded when an earlier one fails', async () => {
+    seed()
+    const first = deferred<{ success: boolean }>()
+    markAllNotificationsRead.mockReturnValueOnce(first.promise)
+    markAllNotificationsRead.mockResolvedValueOnce({ success: true })
+    const allRead = makeNotifications(3).map((n) => ({ ...n, read: true }))
+    getNotifications.mockResolvedValue({
+      success: true,
+      data: { notifications: allRead, unreadCount: 0 },
+    })
+    render(<NotificationPanel />)
+
+    fireEvent.click(screen.getByRole('button', { name: 'Mark all read' }))
+    // A new one arrives, which brings the button back.
+    act(() => {
+      useNotificationStore.getState().addNotification({
+        ...makeNotifications(1)[0],
+        id: 'n9',
+        title: 'Quote 9 accepted',
+        read: false,
+        createdAt: new Date(Date.UTC(2026, 9, 30, 13, 5)).toISOString(),
+      })
+    })
+    await act(async () => {
+      fireEvent.click(screen.getByRole('button', { name: 'Mark all read' }))
+    })
+    await act(async () => {
+      first.reject(offline())
+    })
+    await settle()
+
+    expect(useNotificationStore.getState().unreadCount).toBe(0)
+    expect(useNotificationStore.getState().notifications.every((n) => n.read)).toBe(true)
+    expect(screen.queryByRole('button', { name: 'Mark all read' })).not.toBeInTheDocument()
+  })
+})

+ 92 - 0
src/__tests__/features/vehicles/media-grid-delete-failure.test.tsx

@@ -0,0 +1,92 @@
+/**
+ * Deleting a file tile on a work order or an inspection. When the delete
+ * never reaches the server the tile says it failed and can be pressed again.
+ * It used to stay busy, with its Delete button dead, until the page was
+ * reloaded.
+ */
+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 remove = vi.fn()
+const store: MediaStore = {
+  add: vi.fn(),
+  update: vi.fn(),
+  remove: async (id) => remove(id),
+}
+
+const renderGrid = () =>
+  render(<MediaGrid kind="document" serviceRecordId="job1" store={store} files={[FILE]} />)
+
+const pressDelete = () =>
+  act(async () => {
+    fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
+  })
+
+describe('deleting a file tile', () => {
+  beforeEach(() => {
+    remove.mockReset()
+    toastError.mockClear()
+  })
+
+  it('removes the tile once the server has deleted the file', async () => {
+    remove.mockResolvedValue({ success: true })
+    renderGrid()
+
+    await pressDelete()
+
+    expect(remove).toHaveBeenCalledWith('f1')
+    expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument()
+    expect(toastError).not.toHaveBeenCalled()
+  })
+
+  it('says the delete failed and stays usable when the request never reaches the server', async () => {
+    remove.mockRejectedValue(new TypeError('Failed to fetch'))
+    renderGrid()
+
+    await pressDelete()
+
+    expect(toastError).toHaveBeenCalledWith('Failed to delete')
+    expect(screen.getByRole('button', { name: 'Delete' })).toBeEnabled()
+  })
+})

+ 97 - 0
src/__tests__/features/vehicles/work-order-delete-failure.test.ts

@@ -0,0 +1,97 @@
+/**
+ * Deleting a work order blocks saving first, so an autosave cannot land on a
+ * job that is about to be gone. A delete that never reached the server threw
+ * past the code that lifts the block: nothing was shown, the job was still
+ * there, and no later edit on the page was saved. It now says the delete
+ * failed and saving carries on.
+ */
+import { act, renderHook } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { useGlassModal } from '@/components/glass-modal'
+
+const deleteServiceRecord = 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: async (id: string) => deleteServiceRecord(id),
+  deleteServiceAttachment: vi.fn(),
+  toggleManuallyPaid: vi.fn(),
+}))
+vi.mock('@/features/payments/Actions/paymentActions', () => ({
+  createPayment: vi.fn(),
+  deletePayment: vi.fn(),
+}))
+vi.mock('@/features/sms/Actions/smsActions', () => ({ getSmsTemplates: vi.fn() }))
+
+import { useServiceActions } from '@/features/vehicles/Components/service-page/useServiceActions'
+
+type Options = Parameters<typeof useServiceActions>[0]
+
+function renderActions() {
+  // Only what a delete touches; the rest of the form's state is not read.
+  const form = {
+    isSavingRef: { current: false },
+    autosaveTimer: { current: null },
+    setHasUnsavedChanges: vi.fn(),
+  }
+  const hook = renderHook(() =>
+    useServiceActions({
+      record: { id: 'job1' } as unknown as Options['record'],
+      vehicleId: 'v1',
+      currencyCode: 'USD',
+      formState: form as unknown as Options['formState'],
+    })
+  )
+  return { form, hook }
+}
+
+describe('deleting a work order', () => {
+  beforeEach(() => {
+    deleteServiceRecord.mockReset()
+    router.push.mockClear()
+    router.refresh.mockClear()
+    act(() => useGlassModal.getState().close())
+  })
+
+  it('leaves for the vehicle once the job is deleted', async () => {
+    deleteServiceRecord.mockResolvedValue({ success: true })
+    const { hook } = renderActions()
+
+    await act(() => hook.result.current.handleDelete())
+
+    expect(deleteServiceRecord).toHaveBeenCalledWith('job1')
+    expect(router.push).toHaveBeenCalledWith('/vehicles/v1')
+    expect(useGlassModal.getState().isOpen).toBe(false)
+  })
+
+  it('says the delete failed and keeps saving when the request never reaches the server', async () => {
+    deleteServiceRecord.mockRejectedValue(new TypeError('Failed to fetch'))
+    const { form, hook } = renderActions()
+
+    await act(() => hook.result.current.handleDelete())
+
+    expect(useGlassModal.getState()).toMatchObject({
+      isOpen: true,
+      type: 'error',
+      message: 'Failed to delete',
+    })
+    expect(router.push).not.toHaveBeenCalled()
+    // The job is still there, so the page must go on saving it.
+    expect(form.isSavingRef.current).toBe(false)
+  })
+
+  it('shows what the server said when it refuses, and keeps saving', async () => {
+    deleteServiceRecord.mockResolvedValue({ success: false, error: 'Invoice is locked' })
+    const { form, hook } = renderActions()
+
+    await act(() => hook.result.current.handleDelete())
+
+    expect(useGlassModal.getState()).toMatchObject({ isOpen: true, message: 'Invoice is locked' })
+    expect(form.isSavingRef.current).toBe(false)
+  })
+})

+ 8 - 6
src/app/(authenticated)/customers/customers-client.tsx

@@ -201,11 +201,13 @@ export function CustomersClient({
       destructive: true,
       destructive: true,
     })
     })
     if (!ok) return
     if (!ok) return
-    const result = await deleteCustomer(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 deleteCustomer(id).catch(() => null)
+    if (result?.success) {
       router.refresh()
       router.refresh()
     } else {
     } else {
-      modal.open('error', tc('errors.error'), result.error || t('deleteError'))
+      modal.open('error', tc('errors.error'), result?.error || t('deleteError'))
     }
     }
   }
   }
 
 
@@ -219,13 +221,13 @@ export function CustomersClient({
     })
     })
     if (!ok) return
     if (!ok) return
     setIsDeleting(true)
     setIsDeleting(true)
-    const result = await deleteCustomers(Array.from(selected))
-    if (result.success) {
+    const result = await deleteCustomers(Array.from(selected)).catch(() => null)
+    if (result?.success) {
       toast.success(t('batchDeleteSuccess', { count: result.data?.deleted ?? selected.size }))
       toast.success(t('batchDeleteSuccess', { count: result.data?.deleted ?? selected.size }))
       setSelected(new Set())
       setSelected(new Set())
       router.refresh()
       router.refresh()
     } else {
     } else {
-      modal.open('error', tc('errors.error'), result.error || t('deleteError'))
+      modal.open('error', tc('errors.error'), result?.error || t('deleteError'))
     }
     }
     setIsDeleting(false)
     setIsDeleting(false)
   }
   }

+ 8 - 6
src/app/(authenticated)/inventory/inventory-client.tsx

@@ -277,11 +277,13 @@ export function InventoryClient({
       destructive: true,
       destructive: true,
     })
     })
     if (!ok) return
     if (!ok) return
-    const result = await deleteInventoryPart(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 deleteInventoryPart(id).catch(() => null)
+    if (result?.success) {
       router.refresh()
       router.refresh()
     } else {
     } else {
-      modal.open('error', t('errors.error'), result.error || t('errors.deleteFailed'))
+      modal.open('error', t('errors.error'), result?.error || t('errors.deleteFailed'))
     }
     }
   }
   }
 
 
@@ -312,13 +314,13 @@ export function InventoryClient({
     })
     })
     if (!ok) return
     if (!ok) return
     setIsBulkDeleting(true)
     setIsBulkDeleting(true)
-    const result = await deleteInventoryParts(Array.from(selected))
-    if (result.success) {
+    const result = await deleteInventoryParts(Array.from(selected)).catch(() => null)
+    if (result?.success) {
       toast.success(t('bulkDelete.success', { count: result.data?.deleted ?? selected.size }))
       toast.success(t('bulkDelete.success', { count: result.data?.deleted ?? selected.size }))
       setSelected(new Set())
       setSelected(new Set())
       router.refresh()
       router.refresh()
     } else {
     } else {
-      modal.open('error', t('errors.error'), result.error || t('errors.deleteFailed'))
+      modal.open('error', t('errors.error'), result?.error || t('errors.deleteFailed'))
     }
     }
     setIsBulkDeleting(false)
     setIsBulkDeleting(false)
   }
   }

+ 8 - 6
src/app/(authenticated)/labor-presets/labor-presets-client.tsx

@@ -163,13 +163,15 @@ export function LaborPresetsClient({
     )
     )
   }
   }
 
 
+  // An action that cannot reach the server throws instead of answering, so
+  // both of these catch it and say the same thing a refusal does.
   const handleEdit = async (id: string) => {
   const handleEdit = async (id: string) => {
-    const result = await getLaborPreset(id)
-    if (result.success && result.data) {
+    const result = await getLaborPreset(id).catch(() => null)
+    if (result?.success && result.data) {
       setEditPreset(result.data)
       setEditPreset(result.data)
       setShowForm(true)
       setShowForm(true)
     } else {
     } else {
-      modal.open('error', t('errors.error'), result.error || t('errors.loadFailed'))
+      modal.open('error', t('errors.error'), result?.error || t('errors.loadFailed'))
     }
     }
   }
   }
 
 
@@ -181,11 +183,11 @@ export function LaborPresetsClient({
       destructive: true,
       destructive: true,
     })
     })
     if (!ok) return
     if (!ok) return
-    const result = await deleteLaborPreset(id)
-    if (result.success) {
+    const result = await deleteLaborPreset(id).catch(() => null)
+    if (result?.success) {
       router.refresh()
       router.refresh()
     } else {
     } else {
-      modal.open('error', t('errors.error'), result.error || t('errors.deleteFailed'))
+      modal.open('error', t('errors.error'), result?.error || t('errors.deleteFailed'))
     }
     }
   }
   }
 
 

+ 5 - 3
src/app/(authenticated)/vehicles/vehicles-client.tsx

@@ -261,11 +261,13 @@ export function VehiclesClient({
       destructive: true,
       destructive: true,
     })
     })
     if (!ok) return
     if (!ok) return
-    const result = await deleteVehicle(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 deleteVehicle(id).catch(() => null)
+    if (result?.success) {
       router.refresh()
       router.refresh()
     } else {
     } else {
-      modal.open('error', 'Error', result.error || t('deleteError'))
+      modal.open('error', 'Error', result?.error || t('deleteError'))
     }
     }
   }
   }
 
 

+ 12 - 9
src/features/condition-map/Components/ConditionMapCard.tsx

@@ -218,10 +218,13 @@ export function ConditionMapCard({
     const before = marks
     const before = marks
     setMarks((prev) => prev.filter((m) => m.id !== id))
     setMarks((prev) => prev.filter((m) => m.id !== id))
     startTransition(async () => {
     startTransition(async () => {
-      const result = await removeConditionMark(id)
-      if (!result.success) {
+      // A request that never reaches the server throws instead of answering.
+      // Uncaught in a transition that is the error page; caught, the mark
+      // comes back like one the server refused to remove.
+      const result = await removeConditionMark(id).catch(() => null)
+      if (!result?.success) {
         setMarks(before)
         setMarks(before)
-        toast.error(result.error || t('saveFailed'))
+        toast.error(result?.error || t('saveFailed'))
       }
       }
     })
     })
   }
   }
@@ -267,10 +270,10 @@ export function ConditionMapCard({
     if (!before) return
     if (!before) return
     replace({ ...before, imageUrls: before.imageUrls.filter((u) => u !== url) })
     replace({ ...before, imageUrls: before.imageUrls.filter((u) => u !== url) })
     startTransition(async () => {
     startTransition(async () => {
-      const result = await removeConditionMarkPhoto({ id: editingId, url })
-      if (!result.success) {
+      const result = await removeConditionMarkPhoto({ id: editingId, url }).catch(() => null)
+      if (!result?.success) {
         replace(before)
         replace(before)
-        toast.error(result.error || t('saveFailed'))
+        toast.error(result?.error || t('saveFailed'))
       }
       }
     })
     })
   }
   }
@@ -283,10 +286,10 @@ export function ConditionMapCard({
     if (!before) return
     if (!before) return
     replace({ ...before, resolvedAt: new Date().toISOString() })
     replace({ ...before, resolvedAt: new Date().toISOString() })
     startTransition(async () => {
     startTransition(async () => {
-      const result = await resolveConditionMark(id, true)
-      if (!result.success) {
+      const result = await resolveConditionMark(id, true).catch(() => null)
+      if (!result?.success) {
         replace(before)
         replace(before)
-        toast.error(result.error || t('saveFailed'))
+        toast.error(result?.error || t('saveFailed'))
       }
       }
     })
     })
   }
   }

+ 5 - 3
src/features/inspections/Components/InspectionItemRow.tsx

@@ -425,13 +425,15 @@ export function InspectionItemRow({
     const url = imageUrls[index]
     const url = imageUrls[index]
     if (!url) return
     if (!url) return
     setImageUrls((prev) => prev.filter((u) => u !== url))
     setImageUrls((prev) => prev.filter((u) => u !== url))
-    const result = await removeInspectionItemMedia({ itemId: item.id, url })
-    if (result.success && result.data) {
+    // A request that never reaches the server throws instead of answering;
+    // the photo is still there, so it comes back like one the server refused.
+    const result = await removeInspectionItemMedia({ itemId: item.id, url }).catch(() => null)
+    if (result?.success && result.data) {
       setImageUrls(result.data)
       setImageUrls(result.data)
       onChanged(item.id, { condition, photoCount: result.data.length })
       onChanged(item.id, { condition, photoCount: result.data.length })
     } else {
     } else {
       setImageUrls((prev) => (prev.includes(url) ? prev : [...prev, url]))
       setImageUrls((prev) => (prev.includes(url) ? prev : [...prev, url]))
-      toast.error(result.error || t('saveFailed'))
+      toast.error(result?.error || t('saveFailed'))
     }
     }
   }
   }
 
 

+ 86 - 12
src/features/notifications/Components/NotificationPanel.tsx

@@ -1,6 +1,6 @@
 'use client'
 'use client'
 
 
-import { useState } from 'react'
+import { useRef, useState } from 'react'
 import { useRouter } from 'next/navigation'
 import { useRouter } from 'next/navigation'
 import { useTranslations } from 'next-intl'
 import { useTranslations } from 'next-intl'
 import { formatDistanceToNow } from 'date-fns'
 import { formatDistanceToNow } from 'date-fns'
@@ -58,12 +58,58 @@ export function NotificationPanel() {
     markRead,
     markRead,
     markAllRead,
     markAllRead,
     removeNotification,
     removeNotification,
+    restoreNotification,
     clearAll,
     clearAll,
     setNotifications,
     setNotifications,
   } = useNotificationStore()
   } = useNotificationStore()
   const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null)
   const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null)
   const [pendingClearAll, setPendingClearAll] = useState(false)
   const [pendingClearAll, setPendingClearAll] = useState(false)
 
 
+  // Changes sent to the server and not yet answered. When they overlap, one
+  // failing cannot be undone by hand: an older failure would undo a newer
+  // success (a clear all, a second mark all read), so the list is read back
+  // from the server once all of them have settled.
+  const inFlight = useRef(0)
+  const overlapped = useRef(false)
+  const resyncNeeded = useRef(false)
+
+  const resync = async () => {
+    const fresh = await getNotifications().catch(() => null)
+    // A change started while this was out has its own answer still coming.
+    if (inFlight.current > 0) return
+    if (fresh?.success && fresh.data) {
+      setNotifications(fresh.data.notifications, fresh.data.unreadCount)
+    }
+  }
+
+  /**
+   * Sends one change. On failure, `rollback` undoes it on screen when it ran
+   * alone; when it overlapped another change, the list is read back instead.
+   * Returns whether the server took it.
+   */
+  const runChange = async (
+    request: () => Promise<{ success: boolean }>,
+    rollback: () => void
+  ): Promise<boolean> => {
+    if (inFlight.current > 0) overlapped.current = true
+    inFlight.current += 1
+    const result = await request().catch(() => ({ success: false }))
+    inFlight.current -= 1
+
+    if (!result.success) {
+      if (overlapped.current) resyncNeeded.current = true
+      else rollback()
+    }
+    if (inFlight.current === 0) {
+      overlapped.current = false
+      if (resyncNeeded.current) {
+        resyncNeeded.current = false
+        await resync()
+      }
+    }
+    return result.success
+  }
+
   const handleClickNotification = (id: string, entityUrl: string, read: boolean) => {
   const handleClickNotification = (id: string, entityUrl: string, read: boolean) => {
     if (pendingDeleteId) {
     if (pendingDeleteId) {
       setPendingDeleteId(null)
       setPendingDeleteId(null)
@@ -71,7 +117,10 @@ export function NotificationPanel() {
     }
     }
     if (!read) {
     if (!read) {
       markRead(id)
       markRead(id)
-      markNotificationRead(id)
+      markNotificationRead(id).catch(() => {
+        // Nobody is waiting on this: it stays unread on the server, and shows
+        // so on the next page load
+      })
     }
     }
     setPanelOpen(false)
     setPanelOpen(false)
     setTimeout(() => {
     setTimeout(() => {
@@ -90,11 +139,21 @@ export function NotificationPanel() {
     setPendingDeleteId(id)
     setPendingDeleteId(id)
   }
   }
 
 
-  const handleConfirmDelete = (e: React.MouseEvent, id: string) => {
+  const handleConfirmDelete = async (e: React.MouseEvent, id: string) => {
     e.stopPropagation()
     e.stopPropagation()
+    const removed = notifications.find((n) => n.id === id)
     removeNotification(id)
     removeNotification(id)
-    deleteNotification(id)
     setPendingDeleteId(null)
     setPendingDeleteId(null)
+
+    // Still on the server, so it would be back after a reload: put it back
+    // now and say so.
+    const ok = await runChange(
+      () => deleteNotification(id),
+      () => {
+        if (removed) restoreNotification(removed)
+      }
+    )
+    if (!ok) toast.error(t('panel.deleteFailed'))
   }
   }
 
 
   const handleCancelDelete = (e: React.MouseEvent) => {
   const handleCancelDelete = (e: React.MouseEvent) => {
@@ -103,8 +162,23 @@ export function NotificationPanel() {
   }
   }
 
 
   const handleMarkAllRead = async () => {
   const handleMarkAllRead = async () => {
+    const unreadIds = new Set(notifications.filter((n) => !n.read).map((n) => n.id))
+    const unreadBefore = unreadCount
     markAllRead()
     markAllRead()
-    markAllNotificationsRead()
+    await runChange(
+      () => markAllNotificationsRead(),
+      () => {
+        // They are still unread on the server, so show them unread again
+        // rather than claim a change that did not happen. Nothing was deleted
+        // meanwhile (that would have overlapped), so every one still counts,
+        // including any pushed off the end of the list by new arrivals.
+        const now = useNotificationStore.getState()
+        setNotifications(
+          now.notifications.map((n) => (unreadIds.has(n.id) ? { ...n, read: false } : n)),
+          now.unreadCount + unreadBefore
+        )
+      }
+    )
   }
   }
 
 
   const handleConfirmClearAll = async () => {
   const handleConfirmClearAll = async () => {
@@ -122,19 +196,19 @@ export function NotificationPanel() {
     setPendingClearAll(false)
     setPendingClearAll(false)
     setPendingDeleteId(null)
     setPendingDeleteId(null)
 
 
-    const result = await deleteAllNotifications(before).catch(() => ({ success: false }))
-    if (result.success) {
+    const ok = await runChange(
+      () => deleteAllNotifications(before),
+      () => {
+        void resync()
+      }
+    )
+    if (ok) {
       // The dashboard's Recent Notifications card is server-fetched, not read
       // The dashboard's Recent Notifications card is server-fetched, not read
       // from the store, so it only empties with a refresh.
       // from the store, so it only empties with a refresh.
       router.refresh()
       router.refresh()
       return
       return
     }
     }
-
     toast.error(t('panel.clearAllFailed'))
     toast.error(t('panel.clearAllFailed'))
-    const fresh = await getNotifications().catch(() => null)
-    if (fresh?.success && fresh.data) {
-      setNotifications(fresh.data.notifications, fresh.data.unreadCount)
-    }
   }
   }
 
 
   return (
   return (

+ 18 - 0
src/features/notifications/store/notificationStore.ts

@@ -23,6 +23,7 @@ type NotificationState = {
   markRead: (id: string) => void
   markRead: (id: string) => void
   markAllRead: () => void
   markAllRead: () => void
   removeNotification: (id: string) => void
   removeNotification: (id: string) => void
+  restoreNotification: (notification: Notification) => void
   clearAll: () => void
   clearAll: () => void
   setConnected: (connected: boolean) => void
   setConnected: (connected: boolean) => void
   setPanelOpen: (open: boolean) => void
   setPanelOpen: (open: boolean) => void
@@ -67,6 +68,23 @@ export const useNotificationStore = create<NotificationState>((set) => ({
         unreadCount: Math.max(0, state.unreadCount - (target && !target.read ? 1 : 0)),
         unreadCount: Math.max(0, state.unreadCount - (target && !target.read ? 1 : 0)),
       }
       }
     }),
     }),
+  // Puts back a notification whose delete did not go through. The feed is
+  // newest first, so it goes back by its date rather than by the place it
+  // had: others can have arrived or gone while the request was out.
+  restoreNotification: (notification) =>
+    set((state) => {
+      if (state.notifications.some((n) => n.id === notification.id)) return state
+      const createdAt = new Date(notification.createdAt).getTime()
+      const older = state.notifications.findIndex(
+        (n) => new Date(n.createdAt).getTime() < createdAt
+      )
+      const notifications = [...state.notifications]
+      notifications.splice(older === -1 ? notifications.length : older, 0, notification)
+      return {
+        notifications,
+        unreadCount: state.unreadCount + (notification.read ? 0 : 1),
+      }
+    }),
   clearAll: () => set({ notifications: [], unreadCount: 0 }),
   clearAll: () => set({ notifications: [], unreadCount: 0 }),
   setConnected: (isConnected) => set({ isConnected }),
   setConnected: (isConnected) => set({ isConnected }),
   setPanelOpen: (isPanelOpen) => set({ isPanelOpen }),
   setPanelOpen: (isPanelOpen) => set({ isPanelOpen }),

+ 5 - 3
src/features/vehicles/Components/service-page/modern/MediaGrid.tsx

@@ -288,13 +288,15 @@ export function MediaGrid({
 
 
   const remove = async (file: Attachment) => {
   const remove = async (file: Attachment) => {
     setBusyId(file.id)
     setBusyId(file.id)
-    const result = await store.remove(file.id)
+    // A request that never reaches the server throws instead of answering;
+    // uncaught, the tile stayed busy until the page was reloaded.
+    const result = await store.remove(file.id).catch(() => null)
     setBusyId(null)
     setBusyId(null)
-    if (result.success) {
+    if (result?.success) {
       setItems((prev) => prev.filter((f) => f.id !== file.id))
       setItems((prev) => prev.filter((f) => f.id !== file.id))
       router.refresh()
       router.refresh()
     } else {
     } else {
-      toast.error(result.error || t('attachments.failedDelete'))
+      toast.error(result?.error || t('attachments.failedDelete'))
     }
     }
   }
   }
 
 

+ 6 - 3
src/features/vehicles/Components/service-page/useServiceActions.ts

@@ -268,14 +268,17 @@ export function useServiceActions({
     // longer exists, so cancel it and block further saves before deleting.
     // longer exists, so cancel it and block further saves before deleting.
     if (autosaveTimer.current) clearTimeout(autosaveTimer.current)
     if (autosaveTimer.current) clearTimeout(autosaveTimer.current)
     isSavingRef.current = true
     isSavingRef.current = true
-    const result = await deleteServiceRecord(record.id)
-    if (result.success) {
+    // A request that never reaches the server throws instead of answering.
+    // Uncaught, saving stayed blocked on a job that still exists, and every
+    // later edit on the page was lost without a word.
+    const result = await deleteServiceRecord(record.id).catch(() => null)
+    if (result?.success) {
       setHasUnsavedChanges(false)
       setHasUnsavedChanges(false)
       router.push(vehicleId ? `/vehicles/${vehicleId}` : '/work-orders')
       router.push(vehicleId ? `/vehicles/${vehicleId}` : '/work-orders')
       router.refresh()
       router.refresh()
     } else {
     } else {
       isSavingRef.current = false
       isSavingRef.current = false
-      modal.open('error', tc('errors.error'), result.error || t('page.failedDelete'))
+      modal.open('error', tc('errors.error'), result?.error || t('page.failedDelete'))
     }
     }
   }
   }
 
 

+ 10 - 6
src/features/workboard/Components/WorkBoardClient.tsx

@@ -568,9 +568,11 @@ export function WorkBoardClient({
       const job = realJob(activeData.job as WorkBoardJob)
       const job = realJob(activeData.job as WorkBoardJob)
       if (!job.technicianId && !job.workBayId) return
       if (!job.technicianId && !job.workBayId) return
       store.removeJob(job.id)
       store.removeJob(job.id)
-      const res = await unassignJob({ id: job.id, type: job.type })
-      if (!res.success) {
-        toast.error(t('failedRemove'), { description: res.error })
+      // A request that never reaches the server throws instead of answering;
+      // the job is still assigned, so it goes back on the board.
+      const res = await unassignJob({ id: job.id, type: job.type }).catch(() => null)
+      if (!res?.success) {
+        toast.error(t('failedRemove'), { description: res?.error })
         store.addJob(job)
         store.addJob(job)
       } else {
       } else {
         const unRes = await getUnassignedJobs()
         const unRes = await getUnassignedJobs()
@@ -754,9 +756,11 @@ export function WorkBoardClient({
     setPopoverOpen(false)
     setPopoverOpen(false)
     setSelectedJob(null)
     setSelectedJob(null)
     store.removeJob(job.id)
     store.removeJob(job.id)
-    const res = await unassignJob({ id: job.id, type: job.type })
-    if (!res.success) {
-      toast.error(t('failedRemove'), { description: res.error })
+    // As when a job is dragged off the board: one that could not be
+    // unassigned goes back on it.
+    const res = await unassignJob({ id: job.id, type: job.type }).catch(() => null)
+    if (!res?.success) {
+      toast.error(t('failedRemove'), { description: res?.error })
       store.addJob(job)
       store.addJob(job)
     } else {
     } else {
       const unRes = await getUnassignedJobs()
       const unRes = await getUnassignedJobs()