Преглед изворни кода

Let a work order be left when its save is refused (#546)

* Let a work order be left when its save is refused

* Keep edits made during a save, and follow one link at a time
Bernt Christian Egeland пре 22 часа
родитељ
комит
450cb6b5f1

+ 7 - 0
messages/de/service.json

@@ -843,5 +843,12 @@
   },
   "customStatus": {
     "defaultMessage": "Hallo {customer_name}, ein Update zu Ihrem {vehicle}: {status}. {company_name}"
+  },
+  "leaveUnsaved": {
+    "title": "Ihre Änderungen sind nicht gespeichert",
+    "fallbackReason": "Die letzten Änderungen konnten nicht gespeichert werden.",
+    "hint": "Bleiben Sie, um es zu korrigieren, oder verlassen Sie die Seite und verlieren Sie die Änderungen seit dem letzten Speichern.",
+    "stay": "Bleiben und korrigieren",
+    "leave": "Ohne Speichern verlassen"
   }
 }

+ 7 - 0
messages/en/service.json

@@ -843,5 +843,12 @@
   },
   "customStatus": {
     "defaultMessage": "Hi {customer_name}, an update on your {vehicle}: {status}. {company_name}"
+  },
+  "leaveUnsaved": {
+    "title": "Your changes are not saved",
+    "fallbackReason": "The latest changes could not be saved.",
+    "hint": "Stay to fix it, or leave and lose the changes made since the last save.",
+    "stay": "Stay and fix",
+    "leave": "Leave without saving"
   }
 }

+ 7 - 0
messages/es/service.json

@@ -843,5 +843,12 @@
   },
   "customStatus": {
     "defaultMessage": "Hola {customer_name}, novedades sobre su {vehicle}: {status}. {company_name}"
+  },
+  "leaveUnsaved": {
+    "title": "Tus cambios no se han guardado",
+    "fallbackReason": "No se pudieron guardar los últimos cambios.",
+    "hint": "Quédate para corregirlo, o sal y pierde los cambios hechos desde el último guardado.",
+    "stay": "Quedarme y corregir",
+    "leave": "Salir sin guardar"
   }
 }

+ 7 - 0
messages/fr/service.json

@@ -843,5 +843,12 @@
   },
   "customStatus": {
     "defaultMessage": "Bonjour {customer_name}, des nouvelles de votre {vehicle} : {status}. {company_name}"
+  },
+  "leaveUnsaved": {
+    "title": "Vos modifications ne sont pas enregistrées",
+    "fallbackReason": "Les dernières modifications n'ont pas pu être enregistrées.",
+    "hint": "Restez pour corriger, ou partez et perdez les modifications faites depuis le dernier enregistrement.",
+    "stay": "Rester et corriger",
+    "leave": "Quitter sans enregistrer"
   }
 }

+ 7 - 0
messages/it/service.json

@@ -843,5 +843,12 @@
   },
   "customStatus": {
     "defaultMessage": "Ciao {customer_name}, un aggiornamento sulla tua {vehicle}: {status}. {company_name}"
+  },
+  "leaveUnsaved": {
+    "title": "Le modifiche non sono salvate",
+    "fallbackReason": "Non è stato possibile salvare le ultime modifiche.",
+    "hint": "Resta per correggere, oppure esci e perdi le modifiche fatte dall'ultimo salvataggio.",
+    "stay": "Resta e correggi",
+    "leave": "Esci senza salvare"
   }
 }

+ 7 - 0
messages/lt/service.json

@@ -843,5 +843,12 @@
   },
   "customStatus": {
     "defaultMessage": "Sveiki, {customer_name}, naujiena apie jūsų {vehicle}: {status}. {company_name}"
+  },
+  "leaveUnsaved": {
+    "title": "Pakeitimai neišsaugoti",
+    "fallbackReason": "Paskutinių pakeitimų nepavyko išsaugoti.",
+    "hint": "Likite ir pataisykite arba išeikite ir praraskite pakeitimus nuo paskutinio išsaugojimo.",
+    "stay": "Likti ir pataisyti",
+    "leave": "Išeiti neišsaugojus"
   }
 }

+ 7 - 0
messages/nb/service.json

@@ -843,5 +843,12 @@
   },
   "customStatus": {
     "defaultMessage": "Hei {customer_name}, en oppdatering om din {vehicle}: {status}. {company_name}"
+  },
+  "leaveUnsaved": {
+    "title": "Endringene er ikke lagret",
+    "fallbackReason": "De siste endringene kunne ikke lagres.",
+    "hint": "Bli for å rette det, eller gå og mist endringene siden forrige lagring.",
+    "stay": "Bli og rett opp",
+    "leave": "Gå uten å lagre"
   }
 }

+ 7 - 0
messages/nl/service.json

@@ -843,5 +843,12 @@
   },
   "customStatus": {
     "defaultMessage": "Hallo {customer_name}, een update over uw {vehicle}: {status}. {company_name}"
+  },
+  "leaveUnsaved": {
+    "title": "Je wijzigingen zijn niet opgeslagen",
+    "fallbackReason": "De laatste wijzigingen konden niet worden opgeslagen.",
+    "hint": "Blijf om het te herstellen, of ga weg en verlies de wijzigingen sinds de laatste keer opslaan.",
+    "stay": "Blijven en herstellen",
+    "leave": "Weggaan zonder opslaan"
   }
 }

+ 7 - 0
messages/pl/service.json

@@ -843,5 +843,12 @@
   },
   "customStatus": {
     "defaultMessage": "Dzień dobry {customer_name}, aktualizacja dotycząca {vehicle}: {status}. {company_name}"
+  },
+  "leaveUnsaved": {
+    "title": "Zmiany nie zostały zapisane",
+    "fallbackReason": "Nie udało się zapisać ostatnich zmian.",
+    "hint": "Zostań, aby to poprawić, albo wyjdź i strać zmiany wprowadzone od ostatniego zapisu.",
+    "stay": "Zostań i popraw",
+    "leave": "Wyjdź bez zapisywania"
   }
 }

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

@@ -843,5 +843,12 @@
   },
   "customStatus": {
     "defaultMessage": "Olá {customer_name}, uma atualização sobre o seu {vehicle}: {status}. {company_name}"
+  },
+  "leaveUnsaved": {
+    "title": "Suas alterações não foram salvas",
+    "fallbackReason": "Não foi possível salvar as últimas alterações.",
+    "hint": "Fique para corrigir ou saia e perca as alterações feitas desde o último salvamento.",
+    "stay": "Ficar e corrigir",
+    "leave": "Sair sem salvar"
   }
 }

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

@@ -843,5 +843,12 @@
   },
   "customStatus": {
     "defaultMessage": "Olá {customer_name}, uma atualização sobre a sua {vehicle}: {status}. {company_name}"
+  },
+  "leaveUnsaved": {
+    "title": "As suas alterações não foram guardadas",
+    "fallbackReason": "Não foi possível guardar as últimas alterações.",
+    "hint": "Fique para corrigir ou saia e perca as alterações feitas desde a última gravação.",
+    "stay": "Ficar e corrigir",
+    "leave": "Sair sem guardar"
   }
 }

+ 7 - 0
messages/ru/service.json

@@ -843,5 +843,12 @@
   },
   "customStatus": {
     "defaultMessage": "Здравствуйте, {customer_name}! Новости о вашем {vehicle}: {status}. {company_name}"
+  },
+  "leaveUnsaved": {
+    "title": "Изменения не сохранены",
+    "fallbackReason": "Не удалось сохранить последние изменения.",
+    "hint": "Останьтесь, чтобы исправить, или уйдите и потеряйте изменения с момента последнего сохранения.",
+    "stay": "Остаться и исправить",
+    "leave": "Уйти без сохранения"
   }
 }

+ 7 - 0
messages/tr/service.json

@@ -843,5 +843,12 @@
   },
   "customStatus": {
     "defaultMessage": "Merhaba {customer_name}, {vehicle} aracınızla ilgili bir güncelleme: {status}. {company_name}"
+  },
+  "leaveUnsaved": {
+    "title": "Değişiklikleriniz kaydedilmedi",
+    "fallbackReason": "Son değişiklikler kaydedilemedi.",
+    "hint": "Düzeltmek için kalın ya da çıkın ve son kayıttan bu yana yapılan değişiklikleri kaybedin.",
+    "stay": "Kal ve düzelt",
+    "leave": "Kaydetmeden çık"
   }
 }

+ 211 - 7
src/__tests__/features/workorders/leave-saves-first.test.tsx

@@ -5,10 +5,16 @@
  * guards leaving the site, so a retyped title followed by the back arrow was
  * lost. The page now holds the click, saves, and lets the same click through
  * once the save went through.
+ *
+ * A save the rules refuse (a required custom field left empty) used to keep
+ * the person on the page with no way out: every link answered with the same
+ * error. They now get the reason and a choice: stay and fix it, or leave
+ * without the unsaved changes.
  */
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { act, renderHook } from '@testing-library/react'
+import { act, cleanup, fireEvent, render, renderHook, screen } from '@testing-library/react'
 import { useServiceFormState } from '@/features/vehicles/Components/service-page/useServiceFormState'
+import { LeaveUnsavedDialog } from '@/features/vehicles/Components/service-page/LeaveUnsavedDialog'
 
 const initialData = {
   id: 'rec-1',
@@ -37,6 +43,21 @@ function renderForm() {
   )
 }
 
+const REFUSED = 'Please fill in all required custom fields'
+
+/**
+ * The form the save submits through, with the rules refusing it: the page's
+ * handleSubmit records why and returns before marking anything saved.
+ */
+function refusingForm(result: { current: ReturnType<typeof useServiceFormState> }) {
+  const form = document.createElement('form')
+  document.body.appendChild(form)
+  result.current.formRef.current = form
+  return vi.spyOn(form, 'requestSubmit').mockImplementation(() => {
+    result.current.lastSaveErrorRef.current = REFUSED
+  })
+}
+
 /** A link to another page of the app, in the document so the capture listener sees it. */
 function link(href = '/vehicles/v-1') {
   const anchor = document.createElement('a')
@@ -55,6 +76,8 @@ function click(anchor: HTMLAnchorElement) {
 beforeEach(() => vi.useFakeTimers())
 afterEach(() => {
   vi.useRealTimers()
+  // Unmount first: the dialog renders in a portal under body.
+  cleanup()
   document.body.innerHTML = ''
 })
 
@@ -90,13 +113,9 @@ describe('leaving with unsaved changes', () => {
     expect(result.current.hasUnsavedChanges).toBe(false)
   })
 
-  it('keeps the page when the save was refused', async () => {
+  it('keeps the page when the save was refused, and asks why not leave', async () => {
     const { result } = renderForm()
-    const form = document.createElement('form')
-    document.body.appendChild(form)
-    result.current.formRef.current = form
-    // The rules refuse it: handleSubmit returns before marking anything saved.
-    vi.spyOn(form, 'requestSubmit').mockImplementation(() => undefined)
+    refusingForm(result)
 
     act(() => result.current.markDirty())
     const anchor = link()
@@ -109,6 +128,166 @@ describe('leaving with unsaved changes', () => {
 
     expect(released).not.toHaveBeenCalled()
     expect(result.current.hasUnsavedChanges).toBe(true)
+    expect(result.current.blockedLeave?.reason).toBe(REFUSED)
+  })
+
+  it('leaves without saving when asked, through the same link', async () => {
+    const { result } = renderForm()
+    const requestSubmit = refusingForm(result)
+
+    act(() => result.current.markDirty())
+    const anchor = link()
+    const released = vi.fn((e: Event) => e.preventDefault())
+    anchor.addEventListener('click', released)
+    click(anchor)
+    await act(async () => {
+      await vi.advanceTimersByTimeAsync(200)
+    })
+    expect(result.current.blockedLeave).not.toBeNull()
+
+    act(() => result.current.leaveWithoutSaving())
+
+    expect(released).toHaveBeenCalledOnce()
+    expect(result.current.blockedLeave).toBeNull()
+    expect(result.current.hasUnsavedChanges).toBe(false)
+    // Nothing more is tried on the way out: the autosave timer is gone too.
+    await act(async () => {
+      await vi.advanceTimersByTimeAsync(10_000)
+    })
+    expect(requestSubmit).toHaveBeenCalledOnce()
+  })
+
+  it('stays with every edit when asked, and asks again on the next link', async () => {
+    const { result } = renderForm()
+    refusingForm(result)
+
+    act(() => result.current.markDirty())
+    const anchor = link()
+    const released = vi.fn((e: Event) => e.preventDefault())
+    anchor.addEventListener('click', released)
+    click(anchor)
+    await act(async () => {
+      await vi.advanceTimersByTimeAsync(200)
+    })
+
+    act(() => result.current.stayOnPage())
+    expect(result.current.blockedLeave).toBeNull()
+    expect(result.current.hasUnsavedChanges).toBe(true)
+    expect(released).not.toHaveBeenCalled()
+
+    click(anchor)
+    await act(async () => {
+      await vi.advanceTimersByTimeAsync(200)
+    })
+    expect(result.current.blockedLeave?.reason).toBe(REFUSED)
+    expect(released).not.toHaveBeenCalled()
+  })
+
+  it('waits for a save already running instead of calling it refused', async () => {
+    const { result } = renderForm()
+    const requestSubmit = refusingForm(result)
+
+    act(() => result.current.markDirty())
+    // An autosave is on its way to the server.
+    result.current.isSavingRef.current = true
+    const anchor = link()
+    const released = vi.fn((e: Event) => e.preventDefault())
+    anchor.addEventListener('click', released)
+    click(anchor)
+    await act(async () => {
+      await vi.advanceTimersByTimeAsync(500)
+    })
+    expect(result.current.blockedLeave).toBeNull()
+    expect(released).not.toHaveBeenCalled()
+
+    // It lands.
+    act(() => {
+      result.current.setHasUnsavedChanges(false)
+      result.current.isSavingRef.current = false
+    })
+    await act(async () => {
+      await vi.advanceTimersByTimeAsync(200)
+    })
+
+    expect(released).toHaveBeenCalledOnce()
+    expect(requestSubmit).not.toHaveBeenCalled()
+    expect(result.current.blockedLeave).toBeNull()
+  })
+
+  it('saves an edit made while an autosave ran before leaving', async () => {
+    const { result } = renderForm()
+    const form = document.createElement('form')
+    document.body.appendChild(form)
+    result.current.formRef.current = form
+    // Each save covers the edits made before it started, as handleSubmit does.
+    const saves: number[] = []
+    const requestSubmit = vi.spyOn(form, 'requestSubmit').mockImplementation(() => {
+      const revision = result.current.editRevisionRef.current
+      saves.push(revision)
+      result.current.isSavingRef.current = true
+      setTimeout(() => {
+        act(() => result.current.markSaved(revision))
+        result.current.isSavingRef.current = false
+      }, 100)
+    })
+
+    act(() => result.current.markDirty())
+    // The autosave goes out...
+    await act(async () => {
+      await vi.advanceTimersByTimeAsync(5000)
+    })
+    expect(requestSubmit).toHaveBeenCalledOnce()
+    // ...a note is typed while it is on its way, and a link is clicked.
+    act(() => result.current.markDirty())
+    const anchor = link()
+    const released = vi.fn((e: Event) => e.preventDefault())
+    anchor.addEventListener('click', released)
+    click(anchor)
+    await act(async () => {
+      await vi.advanceTimersByTimeAsync(400)
+    })
+
+    expect(saves).toHaveLength(2)
+    expect(saves[1]).toBeGreaterThan(saves[0])
+    expect(released).toHaveBeenCalledOnce()
+    expect(result.current.hasUnsavedChanges).toBe(false)
+  })
+
+  it('follows only the first link while a save is on its way', async () => {
+    const { result } = renderForm()
+    const form = document.createElement('form')
+    document.body.appendChild(form)
+    result.current.formRef.current = form
+    vi.spyOn(form, 'requestSubmit').mockImplementation(() => {
+      const revision = result.current.editRevisionRef.current
+      result.current.isSavingRef.current = true
+      setTimeout(() => {
+        act(() => result.current.markSaved(revision))
+        result.current.isSavingRef.current = false
+      }, 100)
+    })
+
+    act(() => result.current.markDirty())
+    // The autosave is on its way when both links are clicked.
+    await act(async () => {
+      await vi.advanceTimersByTimeAsync(5000)
+    })
+    expect(result.current.isSavingRef.current).toBe(true)
+    const first = link('/vehicles/v-1')
+    const second = link('/work-orders')
+    const toFirst = vi.fn((e: Event) => e.preventDefault())
+    const toSecond = vi.fn((e: Event) => e.preventDefault())
+    first.addEventListener('click', toFirst)
+    second.addEventListener('click', toSecond)
+    click(first)
+    expect(click(second).defaultPrevented).toBe(true)
+    await act(async () => {
+      await vi.advanceTimersByTimeAsync(400)
+    })
+
+    expect(toFirst).toHaveBeenCalledOnce()
+    expect(toSecond).not.toHaveBeenCalled()
+    expect(result.current.blockedLeave).toBeNull()
   })
 
   it('leaves links elsewhere, and clean pages, alone', () => {
@@ -124,3 +303,28 @@ describe('leaving with unsaved changes', () => {
     expect(click(link()).defaultPrevented).toBe(false)
   })
 })
+
+describe('the leave dialog', () => {
+  it('says why the save was refused and offers both ways', () => {
+    const onStay = vi.fn()
+    const onLeave = vi.fn()
+    render(<LeaveUnsavedDialog open reason={REFUSED} onStay={onStay} onLeave={onLeave} />)
+
+    expect(screen.getByText('Your changes are not saved')).toBeInTheDocument()
+    expect(screen.getByText(REFUSED)).toBeInTheDocument()
+
+    fireEvent.click(screen.getByRole('button', { name: 'Leave without saving' }))
+    expect(onLeave).toHaveBeenCalledOnce()
+  })
+
+  it('stays by default', () => {
+    const onStay = vi.fn()
+    render(<LeaveUnsavedDialog open reason={null} onStay={onStay} onLeave={vi.fn()} />)
+
+    expect(screen.getByText('The latest changes could not be saved.')).toBeInTheDocument()
+    const stay = screen.getByRole('button', { name: 'Stay and fix' })
+    expect(stay).toHaveFocus()
+    fireEvent.click(stay)
+    expect(onStay).toHaveBeenCalled()
+  })
+})

+ 55 - 0
src/features/vehicles/Components/service-page/LeaveUnsavedDialog.tsx

@@ -0,0 +1,55 @@
+'use client'
+
+import { useTranslations } from 'next-intl'
+import {
+  AlertDialog,
+  AlertDialogAction,
+  AlertDialogCancel,
+  AlertDialogContent,
+  AlertDialogDescription,
+  AlertDialogFooter,
+  AlertDialogHeader,
+  AlertDialogTitle,
+} from '@/components/ui/alert-dialog'
+
+/**
+ * A link was clicked with edits on the page, and the save it waited for was
+ * refused: a required custom field left empty, a priced line with no name.
+ * Staying keeps every edit on screen to be fixed. Leaving drops the edits made
+ * since the last save and goes where the link points.
+ */
+export function LeaveUnsavedDialog({
+  open,
+  reason,
+  onStay,
+  onLeave,
+}: {
+  open: boolean
+  /** Why the save was refused, as the save said it. */
+  reason: string | null
+  onStay: () => void
+  onLeave: () => void
+}) {
+  const t = useTranslations('service.leaveUnsaved')
+  return (
+    <AlertDialog open={open} onOpenChange={(next) => !next && onStay()}>
+      <AlertDialogContent>
+        <AlertDialogHeader>
+          <AlertDialogTitle>{t('title')}</AlertDialogTitle>
+          <AlertDialogDescription asChild>
+            <div className="space-y-2">
+              <p className="font-medium text-foreground">{reason || t('fallbackReason')}</p>
+              <p>{t('hint')}</p>
+            </div>
+          </AlertDialogDescription>
+        </AlertDialogHeader>
+        <AlertDialogFooter>
+          <AlertDialogCancel>{t('stay')}</AlertDialogCancel>
+          <AlertDialogAction variant="destructive" onClick={onLeave}>
+            {t('leave')}
+          </AlertDialogAction>
+        </AlertDialogFooter>
+      </AlertDialogContent>
+    </AlertDialog>
+  )
+}

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

@@ -63,6 +63,7 @@ import type { ServicePageClientProps, ServiceTab } from './service-page-types'
 import { WorkOrderLayoutProvider } from '@/components/work-order-layout-context'
 import { track } from '@/lib/analytics'
 import { LaborAddedBanner } from './LaborAddedBanner'
+import { LeaveUnsavedDialog } from './LeaveUnsavedDialog'
 import { useLiveRecord } from '@/features/realtime/hooks'
 import { PresenceChips } from '@/features/realtime/Components/PresenceChips'
 import { ModernDetails } from './modern/ModernDetails'
@@ -630,6 +631,13 @@ export function ServicePageClient({
               }}
             />
 
+            <LeaveUnsavedDialog
+              open={formState.blockedLeave !== null}
+              reason={formState.blockedLeave?.reason ?? null}
+              onStay={formState.stayOnPage}
+              onLeave={formState.leaveWithoutSaving}
+            />
+
             {(lockState.locked || lockState.unlockedAt) && (
               <div className="shrink-0 px-4 pt-3">
                 <DocumentLockBanner

+ 39 - 7
src/features/vehicles/Components/service-page/useServiceActions.ts

@@ -77,25 +77,46 @@ export function useServiceActions({
     notesRef,
     initialData,
     customFieldsSaveRef,
+    lastSaveErrorRef,
+    leavingRef,
+    editRevisionRef,
+    markSaved,
   } = formState
 
+  /**
+   * A save the rules refused. The reason is kept for the leave dialog; when a
+   * link is waiting on this save, the dialog says it instead of a toast.
+   */
+  const refuse = (message: string, show: () => void = () => toast.error(message)) => {
+    lastSaveErrorRef.current = message
+    if (!leavingRef.current) show()
+  }
+
   const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
     e.preventDefault()
     const form = e.currentTarget
     if (isSavingRef.current) return
     if (autosaveTimer.current) clearTimeout(autosaveTimer.current)
+    // Running from here: the custom fields go to the server before the rest,
+    // and a link waiting on this save has to wait for that part too.
+    isSavingRef.current = true
+    // What this save covers. An edit made while it runs is newer, and keeps
+    // the page unsaved once it lands.
+    const revision = editRevisionRef.current
 
     // Validate and save custom fields before saving
     if (customFieldsSaveRef.current) {
       try {
         const { valid } = await customFieldsSaveRef.current()
         if (!valid) {
-          toast.error(t('page.customFieldsInvalid'))
+          isSavingRef.current = false
+          refuse(t('page.customFieldsInvalid'))
           return
         }
       } catch (err) {
         console.error('Custom fields save error:', err)
-        toast.error(t('page.customFieldsSaveFailed'))
+        isSavingRef.current = false
+        refuse(t('page.customFieldsSaveFailed'))
         return
       }
     }
@@ -124,11 +145,11 @@ export function useServiceActions({
       concerns,
     })
     if (problem) {
-      toast.error(t(`page.problems.${problem}`))
+      isSavingRef.current = false
+      refuse(t(`page.problems.${problem}`))
       return
     }
 
-    isSavingRef.current = true
     setLoading(true)
 
     // A field the layout does not render at all is left alone; an emptied one
@@ -204,12 +225,22 @@ export function useServiceActions({
       warrantyNotes: warranty.warrantyNotes ?? '',
     }
 
-    const result = await updateServiceRecord(payload)
+    let result: Awaited<ReturnType<typeof updateServiceRecord>>
+    try {
+      result = await updateServiceRecord(payload)
+    } catch (err) {
+      // The request never came back (the connection dropped). Saving has to
+      // end here, or the next save, and a link waiting on this one, waits
+      // forever.
+      isSavingRef.current = false
+      setLoading(false)
+      throw err
+    }
 
     if (result.success) {
       commitLaborAddedElsewhere()
       commitPartsAddedElsewhere()
-      setHasUnsavedChanges(false)
+      markSaved(revision)
       flashSaved()
       if (selectedVehicleId && selectedVehicleId !== vehicleId) {
         router.push(`/vehicles/${selectedVehicleId}/service/${initialData.id}`)
@@ -217,7 +248,8 @@ export function useServiceActions({
         router.refresh()
       }
     } else {
-      modal.open('error', tc('errors.error'), result.error || t('page.failedUpdate'))
+      const message = result.error || t('page.failedUpdate')
+      refuse(message, () => modal.open('error', tc('errors.error'), message))
     }
 
     isSavingRef.current = false

+ 112 - 8
src/features/vehicles/Components/service-page/useServiceFormState.ts

@@ -72,6 +72,10 @@ export function useServiceFormState({
 
   // Autosave state
   const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
+  // The same, readable from callbacks and the link handler without waiting
+  // for a render.
+  const hasUnsavedChangesRef = useRef(hasUnsavedChanges)
+  hasUnsavedChangesRef.current = hasUnsavedChanges
   const [showSaved, setShowSaved] = useState(false)
   const formRef = useRef<HTMLFormElement>(null)
   const autosaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
@@ -88,10 +92,20 @@ export function useServiceFormState({
     savedTimerRef.current = setTimeout(() => setShowSaved(false), 2000)
   }, [])
 
+  /**
+   * Counts edits. A save notes the count it started from and clears the
+   * unsaved flag only if nothing was edited while it ran (markSaved): a note
+   * typed during an autosave used to read as saved, and a link waiting on that
+   * autosave left without it.
+   */
+  const editRevisionRef = useRef(0)
+
   const markDirty = useCallback(() => {
     // A locked invoice cannot be saved, so nothing may queue an autosave that
     // the server will only refuse five seconds later.
     if (lockedRef.current) return
+    editRevisionRef.current += 1
+    hasUnsavedChangesRef.current = true
     setHasUnsavedChanges(true)
     if (autosaveTimer.current) clearTimeout(autosaveTimer.current)
     autosaveTimer.current = setTimeout(() => {
@@ -101,6 +115,24 @@ export function useServiceFormState({
     }, 5000)
   }, [])
 
+  /**
+   * A save that started at `revision` went through. Edits made while it ran
+   * keep the page unsaved and queue a save of their own.
+   */
+  const markSaved = useCallback(
+    (revision: number) => {
+      if (editRevisionRef.current === revision) {
+        // The ref at once: a link waiting on this save reads it before the
+        // page has rendered again.
+        hasUnsavedChangesRef.current = false
+        setHasUnsavedChanges(false)
+        return
+      }
+      markDirty()
+    },
+    [markDirty]
+  )
+
   // When the lock engages mid-session — the invoice was just sent, or an
   // admin re-locked it — any save already queued can only be refused, and
   // "Unsaved changes" would offer a save that can never complete (and keep
@@ -111,8 +143,6 @@ export function useServiceFormState({
   // this a concern typed in this session went back without an id on the next
   // save, was taken for a new one, and the saved row was deleted with every
   // finding that pointed at it.
-  const hasUnsavedChangesRef = useRef(hasUnsavedChanges)
-  hasUnsavedChangesRef.current = hasUnsavedChanges
   const savedConcernsKey = JSON.stringify(initialData.concerns ?? [])
   useEffect(() => {
     const saved: ConcernRow[] = JSON.parse(savedConcernsKey)
@@ -195,6 +225,9 @@ export function useServiceFormState({
   useEffect(() => {
     if (!hasUnsavedChanges) return
     const handler = (e: BeforeUnloadEvent) => {
+      // Read live: "Leave without saving" drops the edits and goes in the
+      // same moment, before this listener is taken down.
+      if (!hasUnsavedChangesRef.current) return
       e.preventDefault()
     }
     window.addEventListener('beforeunload', handler)
@@ -222,12 +255,58 @@ export function useServiceFormState({
    * arrow pressed within those seconds went nowhere, and in-app navigation
    * raised no warning about it. So a click on any link to another page of
    * the app is held until the pending save has run, and the same click is
-   * then let through; a save the rules refuse has already said why, and the
-   * edit stays on screen to be fixed.
+   * then let through. A save the rules refuse holds the link and asks: stay
+   * and fix it, or leave without the unsaved changes (blockedLeave below).
    */
   const saveNowRef = useRef(saveNow)
   saveNowRef.current = saveNow
   const releasedLink = useRef<HTMLAnchorElement | null>(null)
+
+  /**
+   * Why the latest save was refused, in words, so the way out can say it.
+   * Set by the save itself; cleared when a link starts a new attempt.
+   */
+  const lastSaveErrorRef = useRef<string | null>(null)
+  /**
+   * True while a link waits on its save. A refusal then goes into the leave
+   * dialog rather than a toast or an error box on top of it.
+   */
+  const leavingRef = useRef(false)
+  /**
+   * The link a refused save held back, and why it was refused.
+   *
+   * Holding the link used to be the end of it: a required custom field left
+   * empty refused every save, so a note typed on the job could neither be
+   * saved nor left behind, and every link on the page answered with the same
+   * error. The person now chooses: stay and fix it, or leave without the
+   * changes made since the last save.
+   */
+  const [blockedLeave, setBlockedLeave] = useState<{
+    anchor: HTMLAnchorElement
+    reason: string | null
+  } | null>(null)
+
+  const stayOnPage = useCallback(() => setBlockedLeave(null), [])
+
+  const leaveWithoutSaving = useCallback(() => {
+    if (!blockedLeave) return
+    if (autosaveTimer.current) {
+      clearTimeout(autosaveTimer.current)
+      autosaveTimer.current = null
+    }
+    hasUnsavedChangesRef.current = false
+    setHasUnsavedChanges(false)
+    setBlockedLeave(null)
+    const { anchor } = blockedLeave
+    if (!anchor.isConnected) {
+      // A link inside a menu that has since closed: go by its address.
+      window.location.assign(anchor.href)
+      return
+    }
+    releasedLink.current = anchor
+    anchor.click()
+  }, [blockedLeave])
+
   useEffect(() => {
     if (!hasUnsavedChanges) return
     const handler = (event: MouseEvent) => {
@@ -249,11 +328,28 @@ export function useServiceFormState({
 
       event.preventDefault()
       event.stopPropagation()
-      void saveNowRef.current().then(() => {
-        if (hasUnsavedChangesRef.current) return
-        releasedLink.current = anchor
-        anchor.click()
+      // One way out at a time: a second link clicked while the first waits
+      // would start its own wait, and both would navigate once the save landed.
+      if (leavingRef.current) return
+      leavingRef.current = true
+      lastSaveErrorRef.current = null
+      // An autosave already under way answers first; a second save would be
+      // turned away while it runs and read as a refusal.
+      const settled = new Promise<void>((resolve) => {
+        const check = () => (isSavingRef.current ? setTimeout(check, 50) : resolve())
+        check()
       })
+      void settled
+        .then(() => (hasUnsavedChangesRef.current ? saveNowRef.current() : undefined))
+        .then(() => {
+          leavingRef.current = false
+          if (hasUnsavedChangesRef.current) {
+            setBlockedLeave({ anchor, reason: lastSaveErrorRef.current })
+            return
+          }
+          releasedLink.current = anchor
+          anchor.click()
+        })
     }
     document.addEventListener('click', handler, true)
     return () => document.removeEventListener('click', handler, true)
@@ -534,6 +630,12 @@ export function useServiceFormState({
   )
 
   return {
+    /** A link the save refused to let through, waiting on the person: stay or leave. */
+    blockedLeave,
+    stayOnPage,
+    leaveWithoutSaving,
+    lastSaveErrorRef,
+    leavingRef,
     // State
     loading,
     setLoading,
@@ -565,6 +667,8 @@ export function useServiceFormState({
     isSavingRef,
     flashSaved,
     markDirty,
+    markSaved,
+    editRevisionRef,
     saveNow,
     /**
      * Whether edits are still unsaved, read at the moment of the call. For