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

Keep a part scanned from the technician app on an open work order (#543)

* Keep a part scanned from the technician app on an open work order

* Count waiting lines in the totals and keep them in the lists after a save
Bernt Christian Egeland 1 день назад
Родитель
Сommit
083c6c10c8

+ 2 - 0
messages/de/service.json

@@ -256,6 +256,8 @@
   },
   },
   "liveLabor": {
   "liveLabor": {
     "title": "{count, plural, one {Ein Techniker hat Arbeit zu diesem Auftrag hinzugefügt} other {Ein Techniker hat # Arbeitspositionen zu diesem Auftrag hinzugefügt}}",
     "title": "{count, plural, one {Ein Techniker hat Arbeit zu diesem Auftrag hinzugefügt} other {Ein Techniker hat # Arbeitspositionen zu diesem Auftrag hinzugefügt}}",
+    "titleParts": "{count, plural, one {Ein Techniker hat ein Teil zu diesem Auftrag hinzugefügt} other {Ein Techniker hat # Teile zu diesem Auftrag hinzugefügt}}",
+    "titleBoth": "Ein Techniker hat Arbeit und Teile zu diesem Auftrag hinzugefügt",
     "body": "Sie bleibt beim Bearbeiten aus der Liste. Beim nächsten Speichern wird sie ohnehin übernommen.",
     "body": "Sie bleibt beim Bearbeiten aus der Liste. Beim nächsten Speichern wird sie ohnehin übernommen.",
     "show": "Anzeigen"
     "show": "Anzeigen"
   },
   },

+ 2 - 0
messages/en/service.json

@@ -256,6 +256,8 @@
   },
   },
   "liveLabor": {
   "liveLabor": {
     "title": "{count, plural, one {A technician added labor to this job} other {A technician added # labor lines to this job}}",
     "title": "{count, plural, one {A technician added labor to this job} other {A technician added # labor lines to this job}}",
+    "titleParts": "{count, plural, one {A technician added a part to this job} other {A technician added # parts to this job}}",
+    "titleBoth": "A technician added labor and parts to this job",
     "body": "It stays out of the list while you are editing. Your next save includes it either way.",
     "body": "It stays out of the list while you are editing. Your next save includes it either way.",
     "show": "Show it"
     "show": "Show it"
   },
   },

+ 2 - 0
messages/es/service.json

@@ -256,6 +256,8 @@
   },
   },
   "liveLabor": {
   "liveLabor": {
     "title": "{count, plural, one {Un técnico añadió mano de obra a este trabajo} other {Un técnico añadió # líneas de mano de obra a este trabajo}}",
     "title": "{count, plural, one {Un técnico añadió mano de obra a este trabajo} other {Un técnico añadió # líneas de mano de obra a este trabajo}}",
+    "titleParts": "{count, plural, one {Un técnico añadió una pieza a este trabajo} other {Un técnico añadió # piezas a este trabajo}}",
+    "titleBoth": "Un técnico añadió mano de obra y piezas a este trabajo",
     "body": "Se mantiene fuera de la lista mientras editas. Se guardará de todos modos al guardar.",
     "body": "Se mantiene fuera de la lista mientras editas. Se guardará de todos modos al guardar.",
     "show": "Mostrar"
     "show": "Mostrar"
   },
   },

+ 2 - 0
messages/fr/service.json

@@ -256,6 +256,8 @@
   },
   },
   "liveLabor": {
   "liveLabor": {
     "title": "{count, plural, one {Un technicien a ajouté de la main-d’œuvre à cette intervention} other {Un technicien a ajouté # lignes de main-d’œuvre à cette intervention}}",
     "title": "{count, plural, one {Un technicien a ajouté de la main-d’œuvre à cette intervention} other {Un technicien a ajouté # lignes de main-d’œuvre à cette intervention}}",
+    "titleParts": "{count, plural, one {Un technicien a ajouté une pièce à cette intervention} other {Un technicien a ajouté # pièces à cette intervention}}",
+    "titleBoth": "Un technicien a ajouté de la main-d’œuvre et des pièces à cette intervention",
     "body": "Elle reste hors de la liste pendant votre saisie. Votre prochain enregistrement l’inclura de toute façon.",
     "body": "Elle reste hors de la liste pendant votre saisie. Votre prochain enregistrement l’inclura de toute façon.",
     "show": "Afficher"
     "show": "Afficher"
   },
   },

+ 2 - 0
messages/it/service.json

@@ -256,6 +256,8 @@
   },
   },
   "liveLabor": {
   "liveLabor": {
     "title": "{count, plural, one {Un tecnico ha aggiunto manodopera a questo lavoro} other {Un tecnico ha aggiunto # righe di manodopera a questo lavoro}}",
     "title": "{count, plural, one {Un tecnico ha aggiunto manodopera a questo lavoro} other {Un tecnico ha aggiunto # righe di manodopera a questo lavoro}}",
+    "titleParts": "{count, plural, one {Un tecnico ha aggiunto un ricambio a questo lavoro} other {Un tecnico ha aggiunto # ricambi a questo lavoro}}",
+    "titleBoth": "Un tecnico ha aggiunto manodopera e ricambi a questo lavoro",
     "body": "Resta fuori dall’elenco mentre modifichi. Verrà salvata comunque al prossimo salvataggio.",
     "body": "Resta fuori dall’elenco mentre modifichi. Verrà salvata comunque al prossimo salvataggio.",
     "show": "Mostra"
     "show": "Mostra"
   },
   },

+ 2 - 0
messages/lt/service.json

@@ -256,6 +256,8 @@
   },
   },
   "liveLabor": {
   "liveLabor": {
     "title": "{count, plural, one {Mechanikas pridėjo # darbo eilutę prie šio užsakymo} few {Mechanikas pridėjo # darbo eilutes prie šio užsakymo} many {Mechanikas pridėjo # darbo eilutės prie šio užsakymo} other {Mechanikas pridėjo # darbo eilučių prie šio užsakymo}}",
     "title": "{count, plural, one {Mechanikas pridėjo # darbo eilutę prie šio užsakymo} few {Mechanikas pridėjo # darbo eilutes prie šio užsakymo} many {Mechanikas pridėjo # darbo eilutės prie šio užsakymo} other {Mechanikas pridėjo # darbo eilučių prie šio užsakymo}}",
+    "titleParts": "{count, plural, one {Mechanikas pridėjo # dalį prie šio užsakymo} few {Mechanikas pridėjo # dalis prie šio užsakymo} many {Mechanikas pridėjo # dalies prie šio užsakymo} other {Mechanikas pridėjo # dalių prie šio užsakymo}}",
+    "titleBoth": "Mechanikas pridėjo darbų ir dalių prie šio užsakymo",
     "body": "Kol redaguojate, ji nerodoma sąraše. Kitą kartą išsaugant ji bus įtraukta.",
     "body": "Kol redaguojate, ji nerodoma sąraše. Kitą kartą išsaugant ji bus įtraukta.",
     "show": "Rodyti"
     "show": "Rodyti"
   },
   },

+ 2 - 0
messages/nb/service.json

@@ -256,6 +256,8 @@
   },
   },
   "liveLabor": {
   "liveLabor": {
     "title": "{count, plural, one {En mekaniker la til arbeid på denne jobben} other {En mekaniker la til # arbeidslinjer på denne jobben}}",
     "title": "{count, plural, one {En mekaniker la til arbeid på denne jobben} other {En mekaniker la til # arbeidslinjer på denne jobben}}",
+    "titleParts": "{count, plural, one {En mekaniker la til en del på denne jobben} other {En mekaniker la til # deler på denne jobben}}",
+    "titleBoth": "En mekaniker la til arbeid og deler på denne jobben",
     "body": "Den holdes utenfor listen mens du redigerer. Den blir lagret uansett neste gang du lagrer.",
     "body": "Den holdes utenfor listen mens du redigerer. Den blir lagret uansett neste gang du lagrer.",
     "show": "Vis den"
     "show": "Vis den"
   },
   },

+ 2 - 0
messages/nl/service.json

@@ -256,6 +256,8 @@
   },
   },
   "liveLabor": {
   "liveLabor": {
     "title": "{count, plural, one {Een monteur heeft arbeid aan deze order toegevoegd} other {Een monteur heeft # arbeidsregels aan deze order toegevoegd}}",
     "title": "{count, plural, one {Een monteur heeft arbeid aan deze order toegevoegd} other {Een monteur heeft # arbeidsregels aan deze order toegevoegd}}",
+    "titleParts": "{count, plural, one {Een monteur heeft een onderdeel aan deze order toegevoegd} other {Een monteur heeft # onderdelen aan deze order toegevoegd}}",
+    "titleBoth": "Een monteur heeft arbeid en onderdelen aan deze order toegevoegd",
     "body": "Die blijft buiten de lijst terwijl je bewerkt. Bij je volgende keer opslaan gaat die sowieso mee.",
     "body": "Die blijft buiten de lijst terwijl je bewerkt. Bij je volgende keer opslaan gaat die sowieso mee.",
     "show": "Tonen"
     "show": "Tonen"
   },
   },

+ 2 - 0
messages/pl/service.json

@@ -256,6 +256,8 @@
   },
   },
   "liveLabor": {
   "liveLabor": {
     "title": "{count, plural, one {Mechanik dodał pozycję robocizny do tego zlecenia} few {Mechanik dodał # pozycje robocizny do tego zlecenia} many {Mechanik dodał # pozycji robocizny do tego zlecenia} other {Mechanik dodał # pozycji robocizny do tego zlecenia}}",
     "title": "{count, plural, one {Mechanik dodał pozycję robocizny do tego zlecenia} few {Mechanik dodał # pozycje robocizny do tego zlecenia} many {Mechanik dodał # pozycji robocizny do tego zlecenia} other {Mechanik dodał # pozycji robocizny do tego zlecenia}}",
+    "titleParts": "{count, plural, one {Mechanik dodał część do tego zlecenia} few {Mechanik dodał # części do tego zlecenia} many {Mechanik dodał # części do tego zlecenia} other {Mechanik dodał # części do tego zlecenia}}",
+    "titleBoth": "Mechanik dodał robociznę i części do tego zlecenia",
     "body": "Pozostaje poza listą podczas edycji. Następny zapis i tak ją uwzględni.",
     "body": "Pozostaje poza listą podczas edycji. Następny zapis i tak ją uwzględni.",
     "show": "Pokaż"
     "show": "Pokaż"
   },
   },

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

@@ -256,6 +256,8 @@
   },
   },
   "liveLabor": {
   "liveLabor": {
     "title": "{count, plural, one {Um técnico adicionou mão de obra a esta ordem} other {Um técnico adicionou # linhas de mão de obra a esta ordem}}",
     "title": "{count, plural, one {Um técnico adicionou mão de obra a esta ordem} other {Um técnico adicionou # linhas de mão de obra a esta ordem}}",
+    "titleParts": "{count, plural, one {Um técnico adicionou uma peça a esta ordem} other {Um técnico adicionou # peças a esta ordem}}",
+    "titleBoth": "Um técnico adicionou mão de obra e peças a esta ordem",
     "body": "Ela fica fora da lista enquanto você edita. O próximo salvamento a inclui de qualquer forma.",
     "body": "Ela fica fora da lista enquanto você edita. O próximo salvamento a inclui de qualquer forma.",
     "show": "Mostrar"
     "show": "Mostrar"
   },
   },

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

@@ -256,6 +256,8 @@
   },
   },
   "liveLabor": {
   "liveLabor": {
     "title": "{count, plural, one {Um técnico adicionou mão de obra a esta folha de obra} other {Um técnico adicionou # linhas de mão de obra a esta folha de obra}}",
     "title": "{count, plural, one {Um técnico adicionou mão de obra a esta folha de obra} other {Um técnico adicionou # linhas de mão de obra a esta folha de obra}}",
+    "titleParts": "{count, plural, one {Um técnico adicionou uma peça a esta folha de obra} other {Um técnico adicionou # peças a esta folha de obra}}",
+    "titleBoth": "Um técnico adicionou mão de obra e peças a esta folha de obra",
     "body": "Fica fora da lista enquanto edita. A próxima gravação inclui-a de qualquer forma.",
     "body": "Fica fora da lista enquanto edita. A próxima gravação inclui-a de qualquer forma.",
     "show": "Mostrar"
     "show": "Mostrar"
   },
   },

+ 2 - 0
messages/ru/service.json

@@ -256,6 +256,8 @@
   },
   },
   "liveLabor": {
   "liveLabor": {
     "title": "{count, plural, one {Механик добавил # строку работ в этот заказ} few {Механик добавил # строки работ в этот заказ} many {Механик добавил # строк работ в этот заказ} other {Механик добавил # строк работ в этот заказ}}",
     "title": "{count, plural, one {Механик добавил # строку работ в этот заказ} few {Механик добавил # строки работ в этот заказ} many {Механик добавил # строк работ в этот заказ} other {Механик добавил # строк работ в этот заказ}}",
+    "titleParts": "{count, plural, one {Механик добавил # запчасть в этот заказ} few {Механик добавил # запчасти в этот заказ} many {Механик добавил # запчастей в этот заказ} other {Механик добавил # запчастей в этот заказ}}",
+    "titleBoth": "Механик добавил работы и запчасти в этот заказ",
     "body": "Пока вы редактируете, она не попадает в список. При следующем сохранении она сохранится в любом случае.",
     "body": "Пока вы редактируете, она не попадает в список. При следующем сохранении она сохранится в любом случае.",
     "show": "Показать"
     "show": "Показать"
   },
   },

+ 2 - 0
messages/tr/service.json

@@ -256,6 +256,8 @@
   },
   },
   "liveLabor": {
   "liveLabor": {
     "title": "{count, plural, one {Bir teknisyen bu işe işçilik ekledi} other {Bir teknisyen bu işe # işçilik satırı ekledi}}",
     "title": "{count, plural, one {Bir teknisyen bu işe işçilik ekledi} other {Bir teknisyen bu işe # işçilik satırı ekledi}}",
+    "titleParts": "{count, plural, one {Bir teknisyen bu işe bir parça ekledi} other {Bir teknisyen bu işe # parça ekledi}}",
+    "titleBoth": "Bir teknisyen bu işe işçilik ve parça ekledi",
     "body": "Siz düzenlerken listeye eklenmez. Bir sonraki kaydetmede yine de kaydedilir.",
     "body": "Siz düzenlerken listeye eklenmez. Bir sonraki kaydetmede yine de kaydedilir.",
     "show": "Göster"
     "show": "Göster"
   },
   },

+ 182 - 0
src/__tests__/features/vehicles/live-parts.test.tsx

@@ -0,0 +1,182 @@
+/**
+ * A part added while the desk has the work order open.
+ *
+ * The technician app scans a part straight onto the job, the page hears that
+ * the job changed and reads it again. The part list used to be read once,
+ * when the page opened, so the part never showed and the desk's next save,
+ * which replaces every part line, deleted it and put its stock back. It now
+ * behaves the way labour does:
+ *
+ * - nothing being edited: the part is simply there, as after a reload;
+ * - mid-edit: the list stays as typed, the part waits behind a banner, and a
+ *   save writes it whether or not the banner was taken up.
+ */
+import { act, render, renderHook, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import { addedPartLines } from '@/features/vehicles/Lib/partLines'
+import { useServiceFormState } from '@/features/vehicles/Components/service-page/useServiceFormState'
+import { LaborAddedBanner } from '@/features/vehicles/Components/service-page/LaborAddedBanner'
+import type { ServiceLaborInput, ServicePartInput } from '@/features/vehicles/Schema/serviceSchema'
+
+vi.mock('@/hooks/use-deferred-commit', () => ({
+  useDeferredCommit: () => ({ schedule: vi.fn(), cancel: vi.fn() }),
+}))
+
+const part = (name: string, unitPrice = 50, quantity = 1, inventoryPartId?: string) =>
+  ({
+    name,
+    partNumber: '',
+    quantity,
+    unitPrice,
+    total: unitPrice * quantity,
+    unitCost: 0,
+    markupPercent: 0,
+    inventoryPartId,
+  }) as ServicePartInput
+
+const DESK = part('Brake cleaner', 12)
+const SCANNED = part('Front brake pad set, ceramic', 72, 1, 'inv-pads')
+
+function props(partItems: ServicePartInput[], laborItems: ServiceLaborInput[] = []) {
+  return {
+    vehicleId: 'veh-1',
+    defaultTaxRate: 0,
+    currentUserName: 'Desk',
+    record: { id: 'rec-1', attachments: [], payments: [] } as never,
+    initialData: {
+      id: 'rec-1',
+      concerns: [],
+      partItems,
+      laborItems,
+      taxRate: 0,
+      taxInclusive: false,
+      discountValue: 0,
+    } as never,
+  }
+}
+
+function openPage(partItems: ServicePartInput[] = [DESK]) {
+  return renderHook((p: ReturnType<typeof props>) => useServiceFormState(p), {
+    initialProps: props(partItems),
+  })
+}
+
+describe('a part scanned on from the technician app', () => {
+  it('appears in the list and the total when nothing is being edited', () => {
+    const { result, rerender } = openPage()
+
+    rerender(props([DESK, SCANNED]))
+
+    expect(result.current.partItems).toEqual([DESK, SCANNED])
+    expect(result.current.partsAddedElsewhere).toEqual([])
+    expect(result.current.subtotal).toBe(DESK.total + SCANNED.total)
+  })
+
+  it('waits while the desk is typing, and is saved even if never shown', () => {
+    const { result, rerender } = openPage()
+
+    act(() => result.current.dirtySetPartItems([part('Brake cleaner, 2 cans', 24)]))
+    rerender(props([DESK, SCANNED]))
+
+    expect(result.current.partItems).toEqual([part('Brake cleaner, 2 cans', 24)])
+    expect(result.current.partsAddedElsewhere).toEqual([SCANNED])
+    expect(result.current.partItemsForSave).toEqual([part('Brake cleaner, 2 cans', 24), SCANNED])
+  })
+
+  it('goes into the list when the offer is taken, once', () => {
+    const { result, rerender } = openPage()
+
+    act(() => result.current.dirtySetPartItems([DESK, part('Shop rags', 5)]))
+    rerender(props([DESK, SCANNED]))
+    act(() => result.current.applyPartsAddedElsewhere())
+
+    expect(result.current.partItems).toEqual([DESK, part('Shop rags', 5), SCANNED])
+    expect(result.current.partsAddedElsewhere).toEqual([])
+    expect(result.current.partItemsForSave).toEqual([DESK, part('Shop rags', 5), SCANNED])
+  })
+})
+
+describe('saving with lines still waiting', () => {
+  it('counts them in the totals the save sends', () => {
+    const { result, rerender } = openPage()
+
+    act(() => result.current.dirtySetPartItems([DESK]))
+    rerender(props([DESK, SCANNED]))
+
+    // The list on screen is still the desk's, but the money matches the lines a save writes.
+    expect(result.current.partItems).toEqual([DESK])
+    expect(result.current.subtotal).toBe(DESK.total + SCANNED.total)
+  })
+
+  it('keeps them in the list once a save has written them', () => {
+    const { result, rerender } = openPage()
+
+    act(() => result.current.dirtySetPartItems([DESK]))
+    rerender(props([DESK, SCANNED]))
+    act(() => result.current.commitPartsAddedElsewhere())
+
+    // The re-read after the save reads the same as before, so nothing else
+    // would bring the part back; the next save must still include it.
+    rerender(props([DESK, SCANNED]))
+    expect(result.current.partItems).toEqual([DESK, SCANNED])
+    expect(result.current.partsAddedElsewhere).toEqual([])
+    expect(result.current.partItemsForSave).toEqual([DESK, SCANNED])
+  })
+
+  it('leaves a part that arrived while the save ran waiting', () => {
+    const { result, rerender } = openPage()
+    const late = part('Caliper grease', 9)
+
+    act(() => result.current.dirtySetPartItems([DESK]))
+    rerender(props([DESK, SCANNED]))
+    // The save goes out with what was waiting then...
+    const commitSent = result.current.commitPartsAddedElsewhere
+    // ...and another part lands before it comes back.
+    rerender(props([DESK, SCANNED, late]))
+    act(() => commitSent())
+
+    expect(result.current.partItems).toEqual([DESK, SCANNED])
+    expect(result.current.partsAddedElsewhere).toEqual([late])
+  })
+
+  it('does the same for labour', () => {
+    const { result, rerender } = openPage([])
+    const billed: ServiceLaborInput = {
+      description: 'Replaced front pads',
+      hours: 1,
+      rate: 125,
+      total: 125,
+      pricingType: 'hourly',
+    }
+
+    act(() => result.current.dirtySetPartItems([DESK]))
+    rerender(props([], [billed]))
+    expect(result.current.laborAddedElsewhere).toEqual([billed])
+    expect(result.current.subtotal).toBe(DESK.total + 125)
+
+    act(() => result.current.commitLaborAddedElsewhere())
+    expect(result.current.laborItems).toEqual([billed])
+    expect(result.current.laborAddedElsewhere).toEqual([])
+  })
+})
+
+describe('the banner', () => {
+  it('names parts, labour, or both', () => {
+    const { rerender } = render(<LaborAddedBanner count={0} parts={1} onShow={vi.fn()} />)
+    expect(screen.getByText('A technician added a part to this job')).toBeInTheDocument()
+
+    rerender(<LaborAddedBanner count={1} parts={2} onShow={vi.fn()} />)
+    expect(screen.getByText('A technician added labor and parts to this job')).toBeInTheDocument()
+
+    rerender(<LaborAddedBanner count={0} parts={0} onShow={vi.fn()} />)
+    expect(screen.queryByTestId('labor-added-banner')).toBeNull()
+  })
+})
+
+describe('addedPartLines', () => {
+  it('reads parts by what they are, and counts repeats', () => {
+    expect(addedPartLines([DESK], [DESK, SCANNED])).toEqual([SCANNED])
+    expect(addedPartLines([DESK, SCANNED], [DESK])).toEqual([])
+    expect(addedPartLines([SCANNED], [SCANNED, SCANNED])).toEqual([SCANNED])
+  })
+})

+ 28 - 10
src/features/vehicles/Components/service-page/LaborAddedBanner.tsx

@@ -5,18 +5,36 @@ import { Wrench } from 'lucide-react'
 import { Button } from '@/components/ui/button'
 import { Button } from '@/components/ui/button'
 
 
 /**
 /**
- * A technician billed time from the app while this work order was open, and
- * the page is mid-edit, so the line is waiting rather than appearing under
- * someone's cursor. Taking the offer puts it in the list with everything
- * else; ignoring it costs nothing, because the next save writes it either
- * way (see laborAddedElsewhere in useServiceFormState).
+ * A technician billed time or scanned a part from the app while this work
+ * order was open, and the page is mid-edit, so the lines are waiting rather
+ * than appearing under someone's cursor. Taking the offer puts them in the
+ * lists with everything else; ignoring it costs nothing, because the next save
+ * writes them either way (see laborAddedElsewhere and partsAddedElsewhere in
+ * useServiceFormState).
  *
  *
- * With nothing being edited no banner is shown at all: the line is simply in
- * the list, the way it would be after a reload.
+ * With nothing being edited no banner is shown at all: the lines are simply
+ * in the lists, the way they would be after a reload.
  */
  */
-export function LaborAddedBanner({ count, onShow }: { count: number; onShow: () => void }) {
+export function LaborAddedBanner({
+  count,
+  parts = 0,
+  onShow,
+}: {
+  /** Labour lines waiting. */
+  count: number
+  /** Parts waiting. */
+  parts?: number
+  onShow: () => void
+}) {
   const t = useTranslations('service.liveLabor')
   const t = useTranslations('service.liveLabor')
-  if (count === 0) return null
+  if (count === 0 && parts === 0) return null
+
+  const title =
+    count > 0 && parts > 0
+      ? t('titleBoth')
+      : parts > 0
+        ? t('titleParts', { count: parts })
+        : t('title', { count })
 
 
   return (
   return (
     <div
     <div
@@ -26,7 +44,7 @@ export function LaborAddedBanner({ count, onShow }: { count: number; onShow: ()
     >
     >
       <Wrench className="h-4 w-4 shrink-0 text-primary" aria-hidden="true" />
       <Wrench className="h-4 w-4 shrink-0 text-primary" aria-hidden="true" />
       <p className="min-w-0 flex-1 text-sm">
       <p className="min-w-0 flex-1 text-sm">
-        <span className="font-medium">{t('title', { count })}</span>{' '}
+        <span className="font-medium">{title}</span>{' '}
         <span className="text-muted-foreground">{t('body')}</span>
         <span className="text-muted-foreground">{t('body')}</span>
       </p>
       </p>
       <Button type="button" size="sm" className="h-8" onClick={onShow}>
       <Button type="button" size="sm" className="h-8" onClick={onShow}>

+ 5 - 1
src/features/vehicles/Components/service-page/ServicePageClient.tsx

@@ -623,7 +623,11 @@ export function ServicePageClient({
 
 
             <LaborAddedBanner
             <LaborAddedBanner
               count={formState.laborAddedElsewhere.length}
               count={formState.laborAddedElsewhere.length}
-              onShow={formState.applyLaborAddedElsewhere}
+              parts={formState.partsAddedElsewhere.length}
+              onShow={() => {
+                formState.applyLaborAddedElsewhere()
+                formState.applyPartsAddedElsewhere()
+              }}
             />
             />
 
 
             {(lockState.locked || lockState.unlockedAt) && (
             {(lockState.locked || lockState.unlockedAt) && (

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

@@ -57,7 +57,9 @@ export function useServiceActions({
     partItems,
     partItems,
     laborItems,
     laborItems,
     laborItemsForSave,
     laborItemsForSave,
-    clearLaborAddedElsewhere,
+    commitLaborAddedElsewhere,
+    partItemsForSave,
+    commitPartsAddedElsewhere,
     subtotal,
     subtotal,
     taxRate,
     taxRate,
     taxInclusive,
     taxInclusive,
@@ -178,7 +180,10 @@ export function useServiceActions({
           confirmation: c.confirmation ?? null,
           confirmation: c.confirmation ?? null,
           confirmed: c.confirmed ?? false,
           confirmed: c.confirmed ?? false,
         })),
         })),
-      partItems: partItems.filter((p) => p.name),
+      // Parts and labour a technician added while this page was open go with
+      // the save even if the offer to show them was never taken: this action
+      // replaces every line, so one left out of the payload is deleted.
+      partItems: partItemsForSave.filter((p) => p.name),
       // A line a technician added while this page was open goes with the save
       // A line a technician added while this page was open goes with the save
       // even if the offer to show it was never taken: this action replaces
       // even if the offer to show it was never taken: this action replaces
       // every labour line, so one left out of the payload is deleted.
       // every labour line, so one left out of the payload is deleted.
@@ -202,7 +207,8 @@ export function useServiceActions({
     const result = await updateServiceRecord(payload)
     const result = await updateServiceRecord(payload)
 
 
     if (result.success) {
     if (result.success) {
-      clearLaborAddedElsewhere()
+      commitLaborAddedElsewhere()
+      commitPartsAddedElsewhere()
       setHasUnsavedChanges(false)
       setHasUnsavedChanges(false)
       flashSaved()
       flashSaved()
       if (selectedVehicleId && selectedVehicleId !== vehicleId) {
       if (selectedVehicleId && selectedVehicleId !== vehicleId) {

+ 67 - 15
src/features/vehicles/Components/service-page/useServiceFormState.ts

@@ -12,6 +12,7 @@ import { useDeferredCommit } from '@/hooks/use-deferred-commit'
 import { lineTotal, repricePartRow } from '@/features/inventory/Lib/partPricing'
 import { lineTotal, repricePartRow } from '@/features/inventory/Lib/partPricing'
 import { reconcileConcernRows } from '@/features/vehicles/Lib/concernStory'
 import { reconcileConcernRows } from '@/features/vehicles/Lib/concernStory'
 import { addedLaborLines } from '@/features/vehicles/Lib/laborLines'
 import { addedLaborLines } from '@/features/vehicles/Lib/laborLines'
+import { addedPartLines } from '@/features/vehicles/Lib/partLines'
 import {
 import {
   recalculateShopFeeLines,
   recalculateShopFeeLines,
   shopFeeLinesLast,
   shopFeeLinesLast,
@@ -162,6 +163,26 @@ export function useServiceFormState({
     if (added.length > 0) setLaborAddedElsewhere((current) => [...current, ...added])
     if (added.length > 0) setLaborAddedElsewhere((current) => [...current, ...added])
   }, [savedLaborKey])
   }, [savedLaborKey])
 
 
+  // Parts the same way: a technician scanning a part onto the job from the
+  // app. The list used to be read once when the page opened, so the part never
+  // showed, and the desk's next save, which replaces every part line, deleted
+  // it and put its stock back.
+  const [partsAddedElsewhere, setPartsAddedElsewhere] = useState<ServicePartInput[]>([])
+  const savedPartsKey = JSON.stringify(initialData.partItems ?? [])
+  const syncedPartsRef = useRef<ServicePartInput[]>(initialData.partItems ?? [])
+  useEffect(() => {
+    const saved: ServicePartInput[] = JSON.parse(savedPartsKey)
+    const previous = syncedPartsRef.current
+    syncedPartsRef.current = saved
+    if (!hasUnsavedChangesRef.current) {
+      setPartItems(saved)
+      setPartsAddedElsewhere([])
+      return
+    }
+    const added = addedPartLines(previous, saved)
+    if (added.length > 0) setPartsAddedElsewhere((current) => [...current, ...added])
+  }, [savedPartsKey])
+
   useEffect(() => {
   useEffect(() => {
     if (!locked) return
     if (!locked) return
     if (autosaveTimer.current) {
     if (autosaveTimer.current) {
@@ -260,8 +281,19 @@ export function useServiceFormState({
     [markDirty]
     [markDirty]
   )
   )
 
 
-  // Computed totals
-  const partsSubtotal = partItems.reduce((sum, p) => sum + p.total, 0)
+  /**
+   * What a save has to write: the lists on screen plus anything added
+   * elsewhere that has not been shown yet. A save replaces every part and
+   * labour line of the job, so a line left out of these is a line deleted.
+   * The shop fee goes last, under the work it is charged on.
+   */
+  const partItemsForSave = [...partItems, ...partsAddedElsewhere]
+  const laborItemsForSave = shopFeeLinesLast([...laborItems, ...laborAddedElsewhere])
+
+  // Computed totals. They count the lines waiting behind the banner too: those
+  // are on the job already, and a save sends them, so the money it sends has
+  // to include them or the invoice is saved short.
+  const partsSubtotal = partItemsForSave.reduce((sum, p) => sum + p.total, 0)
 
 
   // A percentage shop fee follows the lines it is a percentage of. Returns the
   // A percentage shop fee follows the lines it is a percentage of. Returns the
   // same array when the fee is already right, so this settles after one pass.
   // same array when the fee is already right, so this settles after one pass.
@@ -278,18 +310,18 @@ export function useServiceFormState({
 
 
   // Internal-only: what the parts cost the workshop before markup. Shown in
   // Internal-only: what the parts cost the workshop before markup. Shown in
   // the totals card for the mechanic, never on the PDF or share views.
   // the totals card for the mechanic, never on the PDF or share views.
-  const partsCostSubtotal = partItems.reduce(
+  const partsCostSubtotal = partItemsForSave.reduce(
     (sum, p) => sum + (Number(p.unitCost) || 0) * (Number(p.quantity) || 0),
     (sum, p) => sum + (Number(p.unitCost) || 0) * (Number(p.quantity) || 0),
     0
     0
   )
   )
-  const laborSubtotal = laborItems.reduce((sum, l) => sum + l.total, 0)
+  const laborSubtotal = laborItemsForSave.reduce((sum, l) => sum + l.total, 0)
   const subtotal = partsSubtotal + laborSubtotal
   const subtotal = partsSubtotal + laborSubtotal
   const discountAmount = discountAmountFor(subtotal, discountType, discountValue)
   const discountAmount = discountAmountFor(subtotal, discountType, discountValue)
   // The tax is charged on the taxable lines alone. A line says whether it
   // The tax is charged on the taxable lines alone. A line says whether it
   // is taxed, and one that has not been asked follows the workshop's rule
   // is taxed, and one that has not been asked follows the workshop's rule
   // for its kind; the server settles it the same way when the job is saved.
   // for its kind; the server settles it the same way when the job is saved.
-  const taxableParts = taxablePartsTotal(partItems, taxApplies)
-  const taxableLabor = taxableLaborTotal(laborItems, taxApplies)
+  const taxableParts = taxablePartsTotal(partItemsForSave, taxApplies)
+  const taxableLabor = taxableLaborTotal(laborItemsForSave, taxApplies)
   const taxableSubtotal = storedTaxableSubtotal(subtotal, taxableParts + taxableLabor)
   const taxableSubtotal = storedTaxableSubtotal(subtotal, taxableParts + taxableLabor)
   const {
   const {
     taxAmount,
     taxAmount,
@@ -426,15 +458,30 @@ export function useServiceFormState({
     setLaborAddedElsewhere([])
     setLaborAddedElsewhere([])
   }, [laborAddedElsewhere, dirtySetLaborItems])
   }, [laborAddedElsewhere, dirtySetLaborItems])
 
 
-  const clearLaborAddedElsewhere = useCallback(() => setLaborAddedElsewhere([]), [])
-
   /**
   /**
-   * What a save has to write: the list on screen plus anything added
-   * elsewhere that has not been shown yet. A save replaces every labour line
-   * of the job, so a line left out of this is a line deleted. The shop fee
-   * goes last, under the work it is charged on.
+   * After a save has written them: they are ordinary saved lines now, so they
+   * go into the list. Dropping them instead lost them, because the re-read
+   * that follows a save can read exactly as it did before and so never brings
+   * them back, and the next save then deleted them. Only the lines that save
+   * sent are moved; one that arrived while it ran keeps waiting.
    */
    */
-  const laborItemsForSave = shopFeeLinesLast([...laborItems, ...laborAddedElsewhere])
+  const commitLaborAddedElsewhere = useCallback(() => {
+    if (laborAddedElsewhere.length === 0) return
+    setLaborItems((prev) => shopFeeLinesLast([...prev, ...laborAddedElsewhere]))
+    setLaborAddedElsewhere((current) => current.slice(laborAddedElsewhere.length))
+  }, [laborAddedElsewhere])
+
+  const applyPartsAddedElsewhere = useCallback(() => {
+    if (partsAddedElsewhere.length === 0) return
+    dirtySetPartItems((prev) => [...prev, ...partsAddedElsewhere])
+    setPartsAddedElsewhere([])
+  }, [partsAddedElsewhere, dirtySetPartItems])
+
+  const commitPartsAddedElsewhere = useCallback(() => {
+    if (partsAddedElsewhere.length === 0) return
+    setPartItems((prev) => [...prev, ...partsAddedElsewhere])
+    setPartsAddedElsewhere((current) => current.slice(partsAddedElsewhere.length))
+  }, [partsAddedElsewhere])
 
 
   const dirtySetDiscountType = useCallback(
   const dirtySetDiscountType = useCallback(
     (v: string) => {
     (v: string) => {
@@ -558,8 +605,13 @@ export function useServiceFormState({
     applyLaborAddedElsewhere,
     applyLaborAddedElsewhere,
     /** The list a save must write: what is on screen, plus those. */
     /** The list a save must write: what is on screen, plus those. */
     laborItemsForSave,
     laborItemsForSave,
-    /** After a save has written them: they are ordinary saved lines now. */
-    clearLaborAddedElsewhere,
+    /** After a save has written them: they join the list as ordinary saved lines. */
+    commitLaborAddedElsewhere,
+    /** The same four for parts. */
+    partsAddedElsewhere,
+    applyPartsAddedElsewhere,
+    partItemsForSave,
+    commitPartsAddedElsewhere,
     dirtySetDiscountType,
     dirtySetDiscountType,
     dirtySetDiscountValue,
     dirtySetDiscountValue,
     dirtySetTaxRate,
     dirtySetTaxRate,

+ 40 - 0
src/features/vehicles/Lib/partLines.ts

@@ -0,0 +1,40 @@
+import type { ServicePartInput } from '@/features/vehicles/Schema/serviceSchema'
+
+/**
+ * What a job gained in parts while a page was open: a part a technician
+ * scanned onto it from the app, say.
+ *
+ * Parts save the way labour does, by replacing the lot, so a saved row carries
+ * no id the page could match on. The comparison is by content and by count,
+ * the same as addedLaborLines, so a second identical part still reads as one
+ * part added.
+ */
+
+const key = (part: ServicePartInput) =>
+  JSON.stringify([
+    part.name,
+    part.partNumber ?? null,
+    part.quantity,
+    part.unitPrice,
+    part.total,
+    part.inventoryPartId ?? null,
+  ])
+
+export function addedPartLines(
+  before: readonly ServicePartInput[],
+  after: readonly ServicePartInput[]
+): ServicePartInput[] {
+  const remaining = new Map<string, number>()
+  for (const part of before) {
+    const k = key(part)
+    remaining.set(k, (remaining.get(k) ?? 0) + 1)
+  }
+  const added: ServicePartInput[] = []
+  for (const part of after) {
+    const k = key(part)
+    const left = remaining.get(k) ?? 0
+    if (left > 0) remaining.set(k, left - 1)
+    else added.push(part)
+  }
+  return added
+}