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

Show only completed work orders in billing and reports, remembered per browser (#422)

Bernt Christian Egeland пре 1 недеља
родитељ
комит
eb5be643ce

+ 3 - 1
messages/de/billing.json

@@ -25,7 +25,9 @@
     "deliveryViewed": "Geöffnet",
     "deliveryViewedOn": "Zuletzt geöffnet {date}",
     "filterUnviewed": "Nicht geöffnet",
-    "filterUnviewedHint": "Rechnungen, die gesendet, aber nie geöffnet wurden"
+    "filterUnviewedHint": "Rechnungen, die gesendet, aber nie geöffnet wurden",
+    "filterCompleted": "Nur abgeschlossene",
+    "filterCompletedHint": "Nur als abgeschlossen markierte Aufträge anzeigen. Wird für das nächste Mal gespeichert."
   },
   "recurring": {
     "title": "Wiederkehrend",

+ 3 - 1
messages/de/reports.json

@@ -429,5 +429,7 @@
   "pagination": {
     "previousPage": "Vorherige Seite",
     "nextPage": "Nächste Seite"
-  }
+  },
+  "completedOnly": "Nur abgeschlossene",
+  "completedOnlyHint": "Nur als abgeschlossen markierte Aufträge anzeigen. Wird für das nächste Mal gespeichert."
 }

+ 3 - 1
messages/en/billing.json

@@ -25,7 +25,9 @@
     "deliveryViewed": "Viewed",
     "deliveryViewedOn": "Last opened {date}",
     "filterUnviewed": "Not viewed",
-    "filterUnviewedHint": "Invoices that were sent but have never been opened"
+    "filterUnviewedHint": "Invoices that were sent but have never been opened",
+    "filterCompleted": "Completed only",
+    "filterCompletedHint": "Show only work orders marked completed. Remembered for next time."
   },
   "recurring": {
     "title": "Recurring",

+ 3 - 1
messages/en/reports.json

@@ -429,5 +429,7 @@
   "pagination": {
     "previousPage": "Previous page",
     "nextPage": "Next page"
-  }
+  },
+  "completedOnly": "Completed only",
+  "completedOnlyHint": "Show only work orders marked completed. Remembered for next time."
 }

+ 3 - 1
messages/es/billing.json

@@ -25,7 +25,9 @@
     "deliveryViewed": "Vista",
     "deliveryViewedOn": "Abierta por última vez el {date}",
     "filterUnviewed": "Sin ver",
-    "filterUnviewedHint": "Facturas enviadas que nunca se han abierto"
+    "filterUnviewedHint": "Facturas enviadas que nunca se han abierto",
+    "filterCompleted": "Solo completadas",
+    "filterCompletedHint": "Muestra solo las órdenes de trabajo marcadas como completadas. Se recuerda para la próxima vez."
   },
   "recurring": {
     "title": "Recurrente",

+ 3 - 1
messages/es/reports.json

@@ -429,5 +429,7 @@
   "pagination": {
     "previousPage": "Página anterior",
     "nextPage": "Página siguiente"
-  }
+  },
+  "completedOnly": "Solo completadas",
+  "completedOnlyHint": "Muestra solo las órdenes de trabajo marcadas como completadas. Se recuerda para la próxima vez."
 }

+ 3 - 1
messages/fr/billing.json

@@ -25,7 +25,9 @@
     "deliveryViewed": "Consultée",
     "deliveryViewedOn": "Dernière ouverture le {date}",
     "filterUnviewed": "Non consultée",
-    "filterUnviewedHint": "Factures envoyées mais jamais ouvertes"
+    "filterUnviewedHint": "Factures envoyées mais jamais ouvertes",
+    "filterCompleted": "Terminés uniquement",
+    "filterCompletedHint": "Afficher uniquement les ordres de réparation marqués comme terminés. Mémorisé pour la prochaine fois."
   },
   "recurring": {
     "title": "Récurrent",

+ 3 - 1
messages/fr/reports.json

@@ -429,5 +429,7 @@
   "pagination": {
     "previousPage": "Page précédente",
     "nextPage": "Page suivante"
-  }
+  },
+  "completedOnly": "Terminés uniquement",
+  "completedOnlyHint": "Afficher uniquement les ordres de réparation marqués comme terminés. Mémorisé pour la prochaine fois."
 }

+ 3 - 1
messages/it/billing.json

@@ -25,7 +25,9 @@
     "deliveryViewed": "Aperta",
     "deliveryViewedOn": "Ultima apertura il {date}",
     "filterUnviewed": "Non aperte",
-    "filterUnviewedHint": "Fatture inviate ma mai aperte"
+    "filterUnviewedHint": "Fatture inviate ma mai aperte",
+    "filterCompleted": "Solo completati",
+    "filterCompletedHint": "Mostra solo gli ordini di lavoro segnati come completati. Viene ricordato per la prossima volta."
   },
   "recurring": {
     "title": "Ricorrente",

+ 3 - 1
messages/it/reports.json

@@ -429,5 +429,7 @@
   "pagination": {
     "previousPage": "Pagina precedente",
     "nextPage": "Pagina successiva"
-  }
+  },
+  "completedOnly": "Solo completati",
+  "completedOnlyHint": "Mostra solo gli ordini di lavoro segnati come completati. Viene ricordato per la prossima volta."
 }

+ 3 - 1
messages/lt/billing.json

@@ -25,7 +25,9 @@
     "deliveryViewed": "Peržiūrėta",
     "deliveryViewedOn": "Paskutinį kartą atidaryta {date}",
     "filterUnviewed": "Neperžiūrėtos",
-    "filterUnviewedHint": "Sąskaitos, kurios išsiųstos, bet niekada neatidarytos"
+    "filterUnviewedHint": "Sąskaitos, kurios išsiųstos, bet niekada neatidarytos",
+    "filterCompleted": "Tik užbaigti",
+    "filterCompletedHint": "Rodyti tik užsakymus, pažymėtus kaip užbaigti. Įsimenama kitam kartui."
   },
   "recurring": {
     "title": "Periodinės",

+ 3 - 1
messages/lt/reports.json

@@ -429,5 +429,7 @@
   "pagination": {
     "previousPage": "Ankstesnis puslapis",
     "nextPage": "Kitas puslapis"
-  }
+  },
+  "completedOnly": "Tik užbaigti",
+  "completedOnlyHint": "Rodyti tik užsakymus, pažymėtus kaip užbaigti. Įsimenama kitam kartui."
 }

+ 3 - 1
messages/nb/billing.json

@@ -25,7 +25,9 @@
     "deliveryViewed": "Åpnet",
     "deliveryViewedOn": "Sist åpnet {date}",
     "filterUnviewed": "Ikke åpnet",
-    "filterUnviewedHint": "Fakturaer som er sendt, men aldri åpnet"
+    "filterUnviewedHint": "Fakturaer som er sendt, men aldri åpnet",
+    "filterCompleted": "Kun fullførte",
+    "filterCompletedHint": "Vis bare arbeidsordrer som er merket som fullført. Huskes til neste gang."
   },
   "recurring": {
     "title": "Gjentakende",

+ 3 - 1
messages/nb/reports.json

@@ -429,5 +429,7 @@
   "pagination": {
     "previousPage": "Forrige side",
     "nextPage": "Neste side"
-  }
+  },
+  "completedOnly": "Kun fullførte",
+  "completedOnlyHint": "Vis bare arbeidsordrer som er merket som fullført. Huskes til neste gang."
 }

+ 3 - 1
messages/nl/billing.json

@@ -25,7 +25,9 @@
     "deliveryViewed": "Bekeken",
     "deliveryViewedOn": "Laatst geopend op {date}",
     "filterUnviewed": "Niet bekeken",
-    "filterUnviewedHint": "Facturen die zijn verzonden maar nooit geopend"
+    "filterUnviewedHint": "Facturen die zijn verzonden maar nooit geopend",
+    "filterCompleted": "Alleen afgerond",
+    "filterCompletedHint": "Toon alleen werkorders die als afgerond zijn gemarkeerd. Wordt onthouden voor de volgende keer."
   },
   "recurring": {
     "title": "Terugkerend",

+ 3 - 1
messages/nl/reports.json

@@ -429,5 +429,7 @@
   "pagination": {
     "previousPage": "Vorige pagina",
     "nextPage": "Volgende pagina"
-  }
+  },
+  "completedOnly": "Alleen afgerond",
+  "completedOnlyHint": "Toon alleen werkorders die als afgerond zijn gemarkeerd. Wordt onthouden voor de volgende keer."
 }

+ 3 - 1
messages/pl/billing.json

@@ -25,7 +25,9 @@
     "deliveryViewed": "Otwarta",
     "deliveryViewedOn": "Ostatnio otwarta {date}",
     "filterUnviewed": "Nieotwarte",
-    "filterUnviewedHint": "Faktury wysłane, ale nigdy nieotwarte"
+    "filterUnviewedHint": "Faktury wysłane, ale nigdy nieotwarte",
+    "filterCompleted": "Tylko zakończone",
+    "filterCompletedHint": "Pokazuj tylko zlecenia oznaczone jako zakończone. Zapamiętywane na następny raz."
   },
   "recurring": {
     "title": "Cykliczne",

+ 3 - 1
messages/pl/reports.json

@@ -429,5 +429,7 @@
   "pagination": {
     "previousPage": "Poprzednia strona",
     "nextPage": "Następna strona"
-  }
+  },
+  "completedOnly": "Tylko zakończone",
+  "completedOnlyHint": "Pokazuj tylko zlecenia oznaczone jako zakończone. Zapamiętywane na następny raz."
 }

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

@@ -25,7 +25,9 @@
     "deliveryViewed": "Aberta",
     "deliveryViewedOn": "Aberta pela última vez em {date}",
     "filterUnviewed": "Não abertas",
-    "filterUnviewedHint": "Faturas enviadas que nunca foram abertas"
+    "filterUnviewedHint": "Faturas enviadas que nunca foram abertas",
+    "filterCompleted": "Somente concluídas",
+    "filterCompletedHint": "Mostra somente ordens de serviço marcadas como concluídas. Fica salvo para a próxima vez."
   },
   "recurring": {
     "title": "Recorrente",

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

@@ -429,5 +429,7 @@
   "pagination": {
     "previousPage": "Página anterior",
     "nextPage": "Próxima página"
-  }
+  },
+  "completedOnly": "Somente concluídas",
+  "completedOnlyHint": "Mostra somente ordens de serviço marcadas como concluídas. Fica salvo para a próxima vez."
 }

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

@@ -25,7 +25,9 @@
     "deliveryViewed": "Aberta",
     "deliveryViewedOn": "Aberta pela última vez a {date}",
     "filterUnviewed": "Não abertas",
-    "filterUnviewedHint": "Faturas enviadas que nunca foram abertas"
+    "filterUnviewedHint": "Faturas enviadas que nunca foram abertas",
+    "filterCompleted": "Só concluídas",
+    "filterCompletedHint": "Mostra só as ordens de reparação marcadas como concluídas. Fica guardado para a próxima vez."
   },
   "recurring": {
     "title": "Recorrente",

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

@@ -429,5 +429,7 @@
   "pagination": {
     "previousPage": "Página anterior",
     "nextPage": "Página seguinte"
-  }
+  },
+  "completedOnly": "Só concluídas",
+  "completedOnlyHint": "Mostra só as ordens de reparação marcadas como concluídas. Fica guardado para a próxima vez."
 }

+ 3 - 1
messages/ru/billing.json

@@ -25,7 +25,9 @@
     "deliveryViewed": "Открыт",
     "deliveryViewedOn": "Последний просмотр {date}",
     "filterUnviewed": "Не открыты",
-    "filterUnviewedHint": "Счета, которые отправлены, но ни разу не открыты"
+    "filterUnviewedHint": "Счета, которые отправлены, но ни разу не открыты",
+    "filterCompleted": "Только завершённые",
+    "filterCompletedHint": "Показывать только заказ-наряды со статусом «завершён». Запоминается на следующий раз."
   },
   "recurring": {
     "title": "Периодические",

+ 3 - 1
messages/ru/reports.json

@@ -429,5 +429,7 @@
   "pagination": {
     "previousPage": "Предыдущая страница",
     "nextPage": "Следующая страница"
-  }
+  },
+  "completedOnly": "Только завершённые",
+  "completedOnlyHint": "Показывать только заказ-наряды со статусом «завершён». Запоминается на следующий раз."
 }

+ 3 - 1
messages/tr/billing.json

@@ -25,7 +25,9 @@
     "deliveryViewed": "Görüntülendi",
     "deliveryViewedOn": "Son açılma {date}",
     "filterUnviewed": "Görüntülenmeyen",
-    "filterUnviewedHint": "Gönderilen ancak hiç açılmayan faturalar"
+    "filterUnviewedHint": "Gönderilen ancak hiç açılmayan faturalar",
+    "filterCompleted": "Yalnızca tamamlananlar",
+    "filterCompletedHint": "Yalnızca tamamlandı olarak işaretlenen iş emirlerini göster. Bir sonraki sefer için hatırlanır."
   },
   "recurring": {
     "title": "Tekrarlayan",

+ 3 - 1
messages/tr/reports.json

@@ -429,5 +429,7 @@
   "pagination": {
     "previousPage": "Önceki sayfa",
     "nextPage": "Sonraki sayfa"
-  }
+  },
+  "completedOnly": "Yalnızca tamamlananlar",
+  "completedOnlyHint": "Yalnızca tamamlandı olarak işaretlenen iş emirlerini göster. Bir sonraki sefer için hatırlanır."
 }

+ 62 - 0
src/__tests__/features/reports/completed-only.test.ts

@@ -0,0 +1,62 @@
+/**
+ * Which work orders the reports count: every job in the window, or with
+ * "completed only" switched on, only those marked completed.
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+
+vi.mock('@/lib/cached-session', () => ({
+  getCachedSession: vi.fn(),
+  getCachedMembership: vi.fn(),
+}))
+vi.mock('@/lib/workshop-timezone', () => ({ workshopTimeZone: async () => 'UTC' }))
+vi.mock('@/lib/db', () => ({
+  db: {
+    user: { findUnique: vi.fn() },
+    serviceRecord: { findMany: vi.fn() },
+    $queryRaw: vi.fn(),
+  },
+}))
+
+import { getCachedSession, getCachedMembership } from '@/lib/cached-session'
+import { db } from '@/lib/db'
+import { getRevenueReport, getServiceReport } from '@/features/reports/Actions/reportActions'
+import { parseCompletedOnlyCookie } from '@/lib/completed-only-preference'
+
+function setupAuth() {
+  vi.mocked(getCachedSession).mockResolvedValue({ user: { id: 'u1', email: 'a@b.c' } } as any)
+  vi.mocked(getCachedMembership).mockResolvedValue({
+    organizationId: 'org-1',
+    role: 'owner',
+    roleId: null,
+    customRole: null,
+  } as any)
+  vi.mocked(db.user.findUnique).mockResolvedValue({ isSuperAdmin: false } as any)
+}
+
+const whereOf = () => (vi.mocked(db.serviceRecord.findMany).mock.calls[0][0] as any).where
+
+beforeEach(() => {
+  vi.resetAllMocks()
+  setupAuth()
+  vi.mocked(db.serviceRecord.findMany).mockResolvedValue([])
+})
+
+describe('reports', () => {
+  it('count every job when the switch is off', async () => {
+    await getRevenueReport({ startDate: '2026-01-01', endDate: '2026-01-31' })
+    expect(whereOf().status).toBeUndefined()
+  })
+
+  it('count only completed jobs when the switch is on', async () => {
+    await getServiceReport({ startDate: '2026-01-01', endDate: '2026-01-31', completedOnly: true })
+    expect(whereOf().status).toBe('completed')
+  })
+})
+
+describe('the remembered switch', () => {
+  it('reads the pages it is on for and ignores anything else', () => {
+    expect([...parseCompletedOnlyCookie('billing,reports')]).toEqual(['billing', 'reports'])
+    expect([...parseCompletedOnlyCookie('reports,whatever')]).toEqual(['reports'])
+    expect(parseCompletedOnlyCookie(undefined).size).toBe(0)
+  })
+})

+ 27 - 0
src/app/(authenticated)/billing/billing-client.tsx

@@ -31,6 +31,7 @@ import {
   ArrowDown,
   ArrowUp,
   ArrowUpDown,
+  CheckCircle2,
   EyeOff,
   Receipt,
   Plus,
@@ -39,6 +40,7 @@ import { cn } from '@/lib/utils'
 import { deliveryState } from '@/features/billing/Lib/deliveryState'
 import { useFormatCurrency } from '@/components/currency-settings-context'
 import { useRememberedSort } from '@/hooks/use-remembered-sort'
+import { rememberCompletedOnly } from '@/lib/completed-only-preference'
 import { ListEmpty } from '@/components/list-empty'
 
 interface BillingRecord {
@@ -89,6 +91,8 @@ interface BillingClientProps {
   search: string
   statusFilter: string
   deliveryFilter: string
+  /** Only work orders marked completed. */
+  completedOnly?: boolean
   sortBy?: string
   sortOrder?: 'asc' | 'desc'
 }
@@ -106,6 +110,7 @@ export default function BillingClient({
   search,
   statusFilter,
   deliveryFilter,
+  completedOnly = false,
   sortBy = '',
   sortOrder = 'desc',
 }: BillingClientProps) {
@@ -217,6 +222,16 @@ export default function BillingClient({
     })
   }
 
+  // Remembered in a cookie as well as put in the URL, so the list opens the
+  // same way next time without anybody asking for it.
+  const handleCompletedToggle = () => {
+    const next = !completedOnly
+    rememberCompletedOnly('billing', next)
+    startTransition(() => {
+      router.push(`${pathname}?${createQueryString({ completed: next ? '1' : '0', page: '1' })}`)
+    })
+  }
+
   const getStatusBadge = (status: string) => {
     switch (status.toLowerCase()) {
       case 'paid':
@@ -394,6 +409,18 @@ export default function BillingClient({
             <EyeOff className="mr-1 h-3.5 w-3.5" />
             {t('history.filterUnviewed')}
           </Button>
+          <Button
+            variant={completedOnly ? 'default' : 'outline'}
+            size="sm"
+            className="h-9 shrink-0 sm:h-8"
+            onClick={handleCompletedToggle}
+            disabled={isPending}
+            aria-pressed={completedOnly}
+            title={t('history.filterCompletedHint')}
+          >
+            <CheckCircle2 className="mr-1 h-3.5 w-3.5" />
+            {t('history.filterCompleted')}
+          </Button>
         </div>
 
         <form onSubmit={handleSearch} className="flex gap-2">

+ 10 - 0
src/app/(authenticated)/billing/page.tsx

@@ -4,6 +4,8 @@ import { getDisplaySettings } from '@/features/settings/Actions/settingsActions'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { PageHeader } from '@/components/page-header'
 import { ListPage } from '@/components/list-page'
+import { cookies } from 'next/headers'
+import { COMPLETED_ONLY_COOKIE, parseCompletedOnlyCookie } from '@/lib/completed-only-preference'
 import BillingClient from './billing-client'
 
 export default async function BillingPage({
@@ -15,6 +17,7 @@ export default async function BillingPage({
     search?: string
     status?: string
     delivery?: string
+    completed?: string
     sortBy?: string
     sortOrder?: string
   }>
@@ -25,6 +28,11 @@ export default async function BillingPage({
   const search = params.search || ''
   const statusFilter = params.status || 'all'
   const deliveryFilter = params.delivery === 'unviewed' ? 'unviewed' : ''
+  // The URL wins, as with sorting; otherwise the switch as this browser left it.
+  const completedOnly =
+    params.completed !== undefined
+      ? params.completed === '1'
+      : parseCompletedOnlyCookie((await cookies()).get(COMPLETED_ONLY_COOKIE)?.value).has('billing')
   // No column asked for anywhere means the list keeps its own default, which
   // getBillingHistory reads as newest first.
   const sort = await resolveListSort('billing', params, { sortBy: undefined, sortOrder: 'desc' })
@@ -38,6 +46,7 @@ export default async function BillingPage({
       search,
       status: statusFilter,
       delivery: deliveryFilter,
+      completedOnly,
       sortBy,
       sortOrder,
     }),
@@ -68,6 +77,7 @@ export default async function BillingPage({
           search={search}
           statusFilter={statusFilter}
           deliveryFilter={deliveryFilter}
+          completedOnly={completedOnly}
           sortBy={sortBy}
           sortOrder={sortOrder}
         />

+ 5 - 0
src/app/(authenticated)/reports/page.tsx

@@ -8,6 +8,8 @@ import { redirect } from 'next/navigation'
 import { getCachedMembership } from '@/lib/cached-session'
 import { hasPermission, PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { getTranslations } from 'next-intl/server'
+import { cookies } from 'next/headers'
+import { COMPLETED_ONLY_COOKIE, parseCompletedOnlyCookie } from '@/lib/completed-only-preference'
 import ReportsClient from './reports-client'
 
 export default async function ReportsPage() {
@@ -61,6 +63,9 @@ export default async function ReportsPage() {
           currencyCode={currencyCode}
           primaryColor={primaryColor}
           organizationName={organizationName}
+          initialCompletedOnly={parseCompletedOnlyCookie(
+            (await cookies()).get(COMPLETED_ONLY_COOKIE)?.value
+          ).has('reports')}
         />
       </div>
     </>

+ 34 - 1
src/app/(authenticated)/reports/reports-client.tsx

@@ -4,7 +4,8 @@ import { taxRateLabel } from '@/features/reports/Lib/taxRateLabel'
 
 import { interactiveRow } from '@/lib/interactive-row'
 import { formatQuantity } from '@/lib/format-quantity'
-import { useState, useEffect, useCallback } from 'react'
+import { useState, useEffect, useCallback, useRef } from 'react'
+import { rememberCompletedOnly } from '@/lib/completed-only-preference'
 import { useSearchParams, useRouter, usePathname } from 'next/navigation'
 import { useTranslations } from 'next-intl'
 import { Button } from '@/components/ui/button'
@@ -58,6 +59,7 @@ import {
   RefreshCw,
   Timer,
   Gauge,
+  CheckCircle2,
 } from 'lucide-react'
 import {
   getRevenueReport,
@@ -141,6 +143,8 @@ interface ReportsClientProps {
   currencyCode: string
   primaryColor: string
   organizationName: string
+  /** Whether the "completed only" switch was left on in this browser. */
+  initialCompletedOnly?: boolean
 }
 
 const VALID_TABS: ReportTab[] = [
@@ -161,6 +165,7 @@ export default function ReportsClient({
   currencyCode,
   primaryColor,
   organizationName,
+  initialCompletedOnly = false,
 }: ReportsClientProps) {
   const formatCurrency = useFormatCurrency()
   const t = useTranslations('reports')
@@ -209,6 +214,10 @@ export default function ReportsClient({
   })
   const [pendingDateRange, setPendingDateRange] = useState<DateRange>(dateRange)
   const [datePickerOpen, setDatePickerOpen] = useState(false)
+  const [completedOnly, setCompletedOnly] = useState(initialCompletedOnly)
+  // Read by fetchReport, so a toggle refetches with the new value straight away
+  // rather than with the one its callback closed over.
+  const completedOnlyRef = useRef(initialCompletedOnly)
   const [loading, setLoading] = useState(false)
 
   // Past due invoices state
@@ -248,6 +257,7 @@ export default function ReportsClient({
         const dateParams = {
           startDate: range.from ? format(range.from, 'yyyy-MM-dd') : '',
           endDate: range.to ? format(range.to, 'yyyy-MM-dd') : '',
+          completedOnly: completedOnlyRef.current,
         }
         switch (type) {
           case 'revenue': {
@@ -333,6 +343,16 @@ export default function ReportsClient({
     }
   }, []) // eslint-disable-line react-hooks/exhaustive-deps
 
+  const handleCompletedToggle = () => {
+    const next = !completedOnly
+    completedOnlyRef.current = next
+    setCompletedOnly(next)
+    rememberCompletedOnly('reports', next)
+    if (activeTab === 'financial') fetchReport(financialSubTab)
+    else if (activeTab === 'technicians') fetchReport(technicianFetchKey(technicianSubTab))
+    else fetchReport(activeTab)
+  }
+
   const handleTabChange = (value: string) => {
     const tab = value as ReportTab
     setActiveTab(tab)
@@ -807,6 +827,19 @@ export default function ReportsClient({
 
         {/* Date range and actions */}
         <div className="flex items-center gap-2 flex-wrap justify-end">
+          {showDateRange && (
+            <Button
+              variant={completedOnly ? 'default' : 'outline'}
+              size="sm"
+              className="h-9 text-xs md:h-8 md:text-sm"
+              onClick={handleCompletedToggle}
+              aria-pressed={completedOnly}
+              title={t('completedOnlyHint')}
+            >
+              <CheckCircle2 className="mr-1.5 h-3.5 w-3.5" />
+              {t('completedOnly')}
+            </Button>
+          )}
           {showDateRange && (
             <Popover
               open={datePickerOpen}

+ 9 - 0
src/features/billing/Actions/billingActions.ts

@@ -66,6 +66,8 @@ export async function getBillingHistory(params: {
   status?: string
   /** 'unviewed' keeps only invoices that went out and were never opened. */
   delivery?: string
+  /** Only work orders marked completed. */
+  completedOnly?: boolean
   sortBy?: string
   sortOrder?: 'asc' | 'desc'
 }) {
@@ -101,6 +103,11 @@ export async function getBillingHistory(params: {
           statusCondition = Prisma.empty
         }
 
+        // Finished work only, for a workshop that bills a job once it is done.
+        const completedCondition = params.completedOnly
+          ? Prisma.sql`AND sr.status = 'completed'`
+          : Prisma.empty
+
         // Sent but never opened. A draft nobody has sent is not a chase, and
         // neither is one the customer has read, so both fall out here.
         const deliveryCondition =
@@ -124,6 +131,7 @@ export async function getBillingHistory(params: {
           LEFT JOIN "public"."vehicles" v ON v.id = sr."vehicleId"
           WHERE sr."organizationId" = ${organizationId}
           ${searchCondition}
+          ${completedCondition}
         ) sub
         WHERE 1=1 ${statusCondition} ${deliveryCondition}
       `)
@@ -202,6 +210,7 @@ export async function getBillingHistory(params: {
           LEFT JOIN "public"."customers" c ON c.id = COALESCE(sr."customerId", v."customerId")
           WHERE sr."organizationId" = ${organizationId}
           ${searchCondition}
+          ${completedCondition}
         ) sub
         WHERE 1=1 ${statusCondition} ${deliveryCondition}
         ORDER BY ${(() => {

+ 45 - 20
src/features/reports/Actions/reportActions.ts

@@ -15,16 +15,31 @@ import { workshopTimeZone } from '@/lib/workshop-timezone'
 const laborHours = (line: { hours: number; pricingType: string }) =>
   isShopFeeLine(line) ? 0 : line.hours
 
+/** The date range a report covers, and whether it counts finished work only. */
+interface ReportRange {
+  startDate?: string
+  endDate?: string
+  /** Only work orders marked completed; open ones are left out. */
+  completedOnly?: boolean
+}
+
+/**
+ * Which work orders a report counts: every job in the window, or with the
+ * switch on, only those marked completed. The stored status is one of the
+ * four system values, whatever the workshop calls its own stages, so
+ * `completed` is the same job for every workshop.
+ */
+function statusScope(completedOnly?: boolean) {
+  return completedOnly ? { status: 'completed' } : {}
+}
+
 /**
  * The report window on the workshop's calendar. The picker sends bare
  * YYYY-MM-DD keys, so "to 31 Dec" must cover the whole of that day in the
  * workshop, and the default start is 1 January of the workshop's current
  * year, not the server's. `end` is exclusive: query with `lt`.
  */
-async function reportWindow(
-  organizationId: string,
-  params: { startDate?: string; endDate?: string }
-) {
+async function reportWindow(organizationId: string, params: ReportRange) {
   const tz = await workshopTimeZone(organizationId)
   const now = new Date()
   const { gte, lt } = workshopDayRange(params.startDate, params.endDate, tz, {
@@ -34,7 +49,7 @@ async function reportWindow(
   return { tz, start: gte, end: lt }
 }
 
-export async function getRevenueReport(params: { startDate?: string; endDate?: string }) {
+export async function getRevenueReport(params: ReportRange) {
   return withAuth(
     async ({ organizationId }) => {
       const { tz, start, end } = await reportWindow(organizationId, params)
@@ -43,6 +58,7 @@ export async function getRevenueReport(params: { startDate?: string; endDate?: s
         where: {
           organizationId,
           startDateTime: { gte: start, lt: end },
+          ...statusScope(params.completedOnly),
         },
         select: {
           serviceDate: true,
@@ -153,7 +169,7 @@ export async function getRevenueReport(params: { startDate?: string; endDate?: s
   )
 }
 
-export async function getServiceReport(params: { startDate?: string; endDate?: string }) {
+export async function getServiceReport(params: ReportRange) {
   return withAuth(
     async ({ organizationId }) => {
       const { start, end } = await reportWindow(organizationId, params)
@@ -162,6 +178,7 @@ export async function getServiceReport(params: { startDate?: string; endDate?: s
         where: {
           organizationId,
           startDateTime: { gte: start, lt: end },
+          ...statusScope(params.completedOnly),
         },
         select: {
           type: true,
@@ -189,7 +206,7 @@ export async function getServiceReport(params: { startDate?: string; endDate?: s
   )
 }
 
-export async function getCustomerReport(params: { startDate?: string; endDate?: string }) {
+export async function getCustomerReport(params: ReportRange) {
   return withAuth(
     async ({ organizationId }) => {
       const { start, end } = await reportWindow(organizationId, params)
@@ -203,7 +220,10 @@ export async function getCustomerReport(params: { startDate?: string; endDate?:
           vehicles: {
             select: {
               serviceRecords: {
-                where: { startDateTime: { gte: start, lt: end } },
+                where: {
+                  startDateTime: { gte: start, lt: end },
+                  ...statusScope(params.completedOnly),
+                },
                 select: { totalAmount: true, cost: true },
               },
             },
@@ -236,7 +256,7 @@ export async function getCustomerReport(params: { startDate?: string; endDate?:
   )
 }
 
-export async function getTechnicianReport(params: { startDate?: string; endDate?: string }) {
+export async function getTechnicianReport(params: ReportRange) {
   return withAuth(
     async ({ organizationId }) => {
       const { start, end } = await reportWindow(organizationId, params)
@@ -245,6 +265,7 @@ export async function getTechnicianReport(params: { startDate?: string; endDate?
         where: {
           organizationId,
           startDateTime: { gte: start, lt: end },
+          ...statusScope(params.completedOnly),
           OR: [{ technicianId: { not: null } }, { techName: { not: null } }],
         },
         select: {
@@ -304,7 +325,7 @@ export async function getTechnicianReport(params: { startDate?: string; endDate?
  * is the efficiency a workshop actually manages by: hours it could invoice
  * for every hour it paid.
  */
-export async function getTechnicianTimeReport(params: { startDate?: string; endDate?: string }) {
+export async function getTechnicianTimeReport(params: ReportRange) {
   return withAuth(
     async ({ organizationId }) => {
       const { start, end } = await reportWindow(organizationId, params)
@@ -316,6 +337,7 @@ export async function getTechnicianTimeReport(params: { startDate?: string; endD
           where: {
             organizationId,
             startDateTime: { gte: start, lt: end },
+            ...statusScope(params.completedOnly),
             technicianId: { not: null },
           },
           select: {
@@ -390,7 +412,7 @@ export async function getTechnicianTimeReport(params: { startDate?: string; endD
   )
 }
 
-export async function getPartsUsageReport(params: { startDate?: string; endDate?: string }) {
+export async function getPartsUsageReport(params: ReportRange) {
   return withAuth(
     async ({ organizationId }) => {
       const { start, end } = await reportWindow(organizationId, params)
@@ -400,6 +422,7 @@ export async function getPartsUsageReport(params: { startDate?: string; endDate?
           serviceRecord: {
             organizationId,
             startDateTime: { gte: start, lt: end },
+            ...statusScope(params.completedOnly),
           },
         },
         select: {
@@ -481,7 +504,7 @@ export async function getPartsUsageReport(params: { startDate?: string; endDate?
   )
 }
 
-export async function getJobAnalyticsReport(params: { startDate?: string; endDate?: string }) {
+export async function getJobAnalyticsReport(params: ReportRange) {
   return withAuth(
     async ({ organizationId }) => {
       const { tz, start, end } = await reportWindow(organizationId, params)
@@ -490,6 +513,7 @@ export async function getJobAnalyticsReport(params: { startDate?: string; endDat
         where: {
           organizationId,
           startDateTime: { gte: start, lt: end },
+          ...statusScope(params.completedOnly),
         },
         select: {
           type: true,
@@ -568,7 +592,7 @@ export async function getJobAnalyticsReport(params: { startDate?: string; endDat
   )
 }
 
-export async function getCustomerRetentionReport(params: { startDate?: string; endDate?: string }) {
+export async function getCustomerRetentionReport(params: ReportRange) {
   return withAuth(
     async ({ organizationId }) => {
       const { start, end } = await reportWindow(organizationId, params)
@@ -582,7 +606,10 @@ export async function getCustomerRetentionReport(params: { startDate?: string; e
           vehicles: {
             select: {
               serviceRecords: {
-                where: { startDateTime: { gte: start, lt: end } },
+                where: {
+                  startDateTime: { gte: start, lt: end },
+                  ...statusScope(params.completedOnly),
+                },
                 select: { serviceDate: true, startDateTime: true, totalAmount: true, cost: true },
                 orderBy: [
                   { startDateTime: { sort: 'asc', nulls: 'last' } },
@@ -808,11 +835,7 @@ export async function getPastDueInvoicesReport() {
   )
 }
 
-export async function getVehicleReport(params: {
-  vehicleId: string
-  startDate?: string
-  endDate?: string
-}) {
+export async function getVehicleReport(params: ReportRange & { vehicleId: string }) {
   return withAuth(
     async ({ organizationId }) => {
       const { tz, start, end } = await reportWindow(organizationId, params)
@@ -837,6 +860,7 @@ export async function getVehicleReport(params: {
           vehicleId: params.vehicleId,
           organizationId,
           startDateTime: { gte: start, lt: end },
+          ...statusScope(params.completedOnly),
         },
         select: {
           id: true,
@@ -972,7 +996,7 @@ export async function getVehicleReport(params: {
   )
 }
 
-export async function getTaxReport(params: { startDate?: string; endDate?: string }) {
+export async function getTaxReport(params: ReportRange) {
   return withAuth(
     async ({ organizationId }) => {
       const { tz, start, end } = await reportWindow(organizationId, params)
@@ -981,6 +1005,7 @@ export async function getTaxReport(params: { startDate?: string; endDate?: strin
         where: {
           organizationId,
           startDateTime: { gte: start, lt: end },
+          ...statusScope(params.completedOnly),
         },
         select: {
           serviceDate: true,

+ 47 - 0
src/lib/completed-only-preference.ts

@@ -0,0 +1,47 @@
+/**
+ * Remembered "completed only" switch for billing and reports.
+ *
+ * A workshop that only wants finished jobs in its invoice list and its figures
+ * wants that every time, not once per visit. A cookie, like the remembered
+ * sort, so the billing page can read it on the server and render the right
+ * list first time.
+ */
+
+export type CompletedOnlyKey = 'billing' | 'reports'
+
+export const COMPLETED_ONLY_COOKIE = 'completedOnly'
+
+/** A year, the same as the remembered sort. */
+const MAX_AGE = 60 * 60 * 24 * 365
+
+/** `billing,reports`: the pages the switch is on for. Anything else reads as off. */
+export function parseCompletedOnlyCookie(raw: string | undefined): Set<CompletedOnlyKey> {
+  const on = new Set<CompletedOnlyKey>()
+  for (const part of (raw ?? '').split(',')) {
+    if (part === 'billing' || part === 'reports') on.add(part)
+  }
+  return on
+}
+
+/** Remembers the switch for one page, keeping what the other page has. */
+export function rememberCompletedOnly(key: CompletedOnlyKey, value: boolean): void {
+  if (typeof document === 'undefined') return
+  const raw = document.cookie
+    .split('; ')
+    .find((c) => c.startsWith(`${COMPLETED_ONLY_COOKIE}=`))
+    ?.slice(COMPLETED_ONLY_COOKIE.length + 1)
+  const on = parseCompletedOnlyCookie(raw ? decodeURIComponent(raw) : undefined)
+  if (value) on.add(key)
+  else on.delete(key)
+  document.cookie = `${COMPLETED_ONLY_COOKIE}=${encodeURIComponent([...on].join(','))}; path=/; max-age=${MAX_AGE}; samesite=lax`
+}
+
+/** Reads the switch in the browser, for pages that fetch their data there. */
+export function readCompletedOnly(key: CompletedOnlyKey): boolean {
+  if (typeof document === 'undefined') return false
+  const raw = document.cookie
+    .split('; ')
+    .find((c) => c.startsWith(`${COMPLETED_ONLY_COOKIE}=`))
+    ?.slice(COMPLETED_ONLY_COOKIE.length + 1)
+  return parseCompletedOnlyCookie(raw ? decodeURIComponent(raw) : undefined).has(key)
+}