فهرست منبع

Show on the billing list whether an invoice has been opened (#353)

The view counter existed but only on an invoice's own page, so finding the
ones nobody has read meant opening them one at a time. That is the opposite
of what the counter is for: an invoice sent a week ago and never opened has
probably landed in a spam folder and wants chasing.

The list now carries a Delivery column, sortable, grouping the never sent,
the sent, and the opened, with the last view date on the badge. Alongside the
payment tabs there is a "Not viewed" filter for the one question worth asking
in a hurry: what went out and was never read. It is its own axis rather than
another payment tab, because unpaid and unopened are different questions and
a workshop chasing one often wants both.

Only a link can be seen to be opened, so an invoice emailed with the PDF
attached reads as sent rather than unread.
Bernt Christian Egeland 1 ماه پیش
والد
کامیت
064b75effc

+ 7 - 1
messages/de/billing.json

@@ -19,7 +19,13 @@
     "columnTotal": "Gesamt",
     "columnPaid": "Bezahlt",
     "columnBalance": "Saldo",
-    "noRecords": "Keine Abrechnungseinträge gefunden."
+    "noRecords": "Keine Abrechnungseinträge gefunden.",
+    "columnDelivery": "Zustellung",
+    "deliverySent": "Gesendet",
+    "deliveryViewed": "Geöffnet",
+    "deliveryViewedOn": "Zuletzt geöffnet {date}",
+    "filterUnviewed": "Nicht geöffnet",
+    "filterUnviewedHint": "Rechnungen, die gesendet, aber nie geöffnet wurden"
   },
   "recurring": {
     "title": "Wiederkehrend",

+ 7 - 1
messages/en/billing.json

@@ -19,7 +19,13 @@
     "columnTotal": "Total",
     "columnPaid": "Paid",
     "columnBalance": "Balance",
-    "noRecords": "No billing records found."
+    "noRecords": "No billing records found.",
+    "columnDelivery": "Delivery",
+    "deliverySent": "Sent",
+    "deliveryViewed": "Viewed",
+    "deliveryViewedOn": "Last opened {date}",
+    "filterUnviewed": "Not viewed",
+    "filterUnviewedHint": "Invoices that were sent but have never been opened"
   },
   "recurring": {
     "title": "Recurring",

+ 7 - 1
messages/es/billing.json

@@ -19,7 +19,13 @@
     "columnTotal": "Total",
     "columnPaid": "Pagado",
     "columnBalance": "Saldo",
-    "noRecords": "No se encontraron registros de facturación."
+    "noRecords": "No se encontraron registros de facturación.",
+    "columnDelivery": "Entrega",
+    "deliverySent": "Enviada",
+    "deliveryViewed": "Vista",
+    "deliveryViewedOn": "Abierta por última vez el {date}",
+    "filterUnviewed": "Sin ver",
+    "filterUnviewedHint": "Facturas enviadas que nunca se han abierto"
   },
   "recurring": {
     "title": "Recurrente",

+ 7 - 1
messages/fr/billing.json

@@ -19,7 +19,13 @@
     "columnTotal": "Total",
     "columnPaid": "Payé",
     "columnBalance": "Solde",
-    "noRecords": "Aucun enregistrement de facturation trouvé."
+    "noRecords": "Aucun enregistrement de facturation trouvé.",
+    "columnDelivery": "Envoi",
+    "deliverySent": "Envoyée",
+    "deliveryViewed": "Consultée",
+    "deliveryViewedOn": "Dernière ouverture le {date}",
+    "filterUnviewed": "Non consultée",
+    "filterUnviewedHint": "Factures envoyées mais jamais ouvertes"
   },
   "recurring": {
     "title": "Récurrent",

+ 7 - 1
messages/it/billing.json

@@ -19,7 +19,13 @@
     "columnTotal": "Totale",
     "columnPaid": "Pagato",
     "columnBalance": "Saldo",
-    "noRecords": "Nessun record di fatturazione trovato."
+    "noRecords": "Nessun record di fatturazione trovato.",
+    "columnDelivery": "Consegna",
+    "deliverySent": "Inviata",
+    "deliveryViewed": "Aperta",
+    "deliveryViewedOn": "Ultima apertura il {date}",
+    "filterUnviewed": "Non aperte",
+    "filterUnviewedHint": "Fatture inviate ma mai aperte"
   },
   "recurring": {
     "title": "Ricorrente",

+ 7 - 1
messages/lt/billing.json

@@ -19,7 +19,13 @@
     "columnTotal": "Suma",
     "columnPaid": "Apmokėta",
     "columnBalance": "Likutis",
-    "noRecords": "Atsiskaitymų įrašų nerasta."
+    "noRecords": "Atsiskaitymų įrašų nerasta.",
+    "columnDelivery": "Pristatymas",
+    "deliverySent": "Išsiųsta",
+    "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"
   },
   "recurring": {
     "title": "Periodinės",

+ 7 - 1
messages/nb/billing.json

@@ -19,7 +19,13 @@
     "columnTotal": "Totalt",
     "columnPaid": "Betalt",
     "columnBalance": "Saldo",
-    "noRecords": "Ingen faktureringsoppføringer funnet."
+    "noRecords": "Ingen faktureringsoppføringer funnet.",
+    "columnDelivery": "Levering",
+    "deliverySent": "Sendt",
+    "deliveryViewed": "Åpnet",
+    "deliveryViewedOn": "Sist åpnet {date}",
+    "filterUnviewed": "Ikke åpnet",
+    "filterUnviewedHint": "Fakturaer som er sendt, men aldri åpnet"
   },
   "recurring": {
     "title": "Gjentakende",

+ 7 - 1
messages/nl/billing.json

@@ -19,7 +19,13 @@
     "columnTotal": "Totaal",
     "columnPaid": "Betaald",
     "columnBalance": "Saldo",
-    "noRecords": "Geen factureringsgegevens gevonden."
+    "noRecords": "Geen factureringsgegevens gevonden.",
+    "columnDelivery": "Bezorging",
+    "deliverySent": "Verzonden",
+    "deliveryViewed": "Bekeken",
+    "deliveryViewedOn": "Laatst geopend op {date}",
+    "filterUnviewed": "Niet bekeken",
+    "filterUnviewedHint": "Facturen die zijn verzonden maar nooit geopend"
   },
   "recurring": {
     "title": "Terugkerend",

+ 7 - 1
messages/pl/billing.json

@@ -19,7 +19,13 @@
     "columnTotal": "Suma",
     "columnPaid": "Opłacone",
     "columnBalance": "Saldo",
-    "noRecords": "Nie znaleziono wpisów rozliczeniowych."
+    "noRecords": "Nie znaleziono wpisów rozliczeniowych.",
+    "columnDelivery": "Dostarczenie",
+    "deliverySent": "Wysłana",
+    "deliveryViewed": "Otwarta",
+    "deliveryViewedOn": "Ostatnio otwarta {date}",
+    "filterUnviewed": "Nieotwarte",
+    "filterUnviewedHint": "Faktury wysłane, ale nigdy nieotwarte"
   },
   "recurring": {
     "title": "Cykliczne",

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

@@ -19,7 +19,13 @@
     "columnTotal": "Total",
     "columnPaid": "Pago",
     "columnBalance": "Saldo",
-    "noRecords": "Nenhum registro de faturamento encontrado."
+    "noRecords": "Nenhum registro de faturamento encontrado.",
+    "columnDelivery": "Entrega",
+    "deliverySent": "Enviada",
+    "deliveryViewed": "Aberta",
+    "deliveryViewedOn": "Aberta pela última vez em {date}",
+    "filterUnviewed": "Não abertas",
+    "filterUnviewedHint": "Faturas enviadas que nunca foram abertas"
   },
   "recurring": {
     "title": "Recorrente",

+ 7 - 1
messages/ru/billing.json

@@ -19,7 +19,13 @@
     "columnTotal": "Итого",
     "columnPaid": "Оплачено",
     "columnBalance": "Остаток",
-    "noRecords": "Записи счетов не найдены."
+    "noRecords": "Записи счетов не найдены.",
+    "columnDelivery": "Отправка",
+    "deliverySent": "Отправлен",
+    "deliveryViewed": "Открыт",
+    "deliveryViewedOn": "Последний просмотр {date}",
+    "filterUnviewed": "Не открыты",
+    "filterUnviewedHint": "Счета, которые отправлены, но ни разу не открыты"
   },
   "recurring": {
     "title": "Периодические",

+ 7 - 1
messages/tr/billing.json

@@ -19,7 +19,13 @@
     "columnTotal": "Toplam",
     "columnPaid": "Ödenen",
     "columnBalance": "Bakiye",
-    "noRecords": "Fatura kaydı bulunamadı."
+    "noRecords": "Fatura kaydı bulunamadı.",
+    "columnDelivery": "Teslim",
+    "deliverySent": "Gönderildi",
+    "deliveryViewed": "Görüntülendi",
+    "deliveryViewedOn": "Son açılma {date}",
+    "filterUnviewed": "Görüntülenmeyen",
+    "filterUnviewedHint": "Gönderilen ancak hiç açılmayan faturalar"
   },
   "recurring": {
     "title": "Tekrarlayan",

+ 26 - 0
src/__tests__/features/billing/delivery-state.test.ts

@@ -0,0 +1,26 @@
+import { describe, expect, it } from 'vitest'
+import { deliveryState } from '@/features/billing/Lib/deliveryState'
+
+describe('deliveryState', () => {
+  it('is unsent while the invoice has not left the workshop', () => {
+    expect(deliveryState({ sentAt: null, viewCount: 0 })).toBe('unsent')
+  })
+
+  it('is sent once it has gone out but nobody has opened it', () => {
+    expect(deliveryState({ sentAt: new Date('2026-09-01'), viewCount: 0 })).toBe('sent')
+  })
+
+  it('is viewed once the share link has been opened', () => {
+    expect(deliveryState({ sentAt: new Date('2026-09-01'), viewCount: 3 })).toBe('viewed')
+  })
+
+  it('counts a view even on an invoice with no send stamp', () => {
+    // A link handed over in person stamps sentAt too, but an older row from
+    // before that stamp existed should still read as seen rather than unsent.
+    expect(deliveryState({ sentAt: null, viewCount: 1 })).toBe('viewed')
+  })
+
+  it('reads a date that arrived from the server as a string', () => {
+    expect(deliveryState({ sentAt: '2026-09-01T10:00:00.000Z', viewCount: 0 })).toBe('sent')
+  })
+})

+ 72 - 1
src/app/(authenticated)/billing/billing-client.tsx

@@ -31,8 +31,10 @@ import {
   ArrowDown,
   ArrowUp,
   ArrowUpDown,
+  EyeOff,
 } from 'lucide-react'
 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'
 
@@ -45,6 +47,10 @@ interface BillingRecord {
   totalAmount: number
   totalPaid: number
   status: string
+  /** When the invoice first reached the customer, by mail or by link. */
+  sentAt: string | Date | null
+  viewCount: number
+  lastViewedAt: string | Date | null
   vehicle: {
     id: string
     make: string
@@ -79,6 +85,7 @@ interface BillingClientProps {
   currencyCode?: string
   search: string
   statusFilter: string
+  deliveryFilter: string
   sortBy?: string
   sortOrder?: 'asc' | 'desc'
 }
@@ -95,6 +102,7 @@ export default function BillingClient({
   currencyCode = 'USD',
   search,
   statusFilter,
+  deliveryFilter,
   sortBy = '',
   sortOrder = 'desc',
 }: BillingClientProps) {
@@ -192,6 +200,19 @@ export default function BillingClient({
 
   const fmt = (amount: number) => formatCurrency(amount, currencyCode)
 
+  // Its own axis, kept out of the payment tabs: "unpaid" and "never opened"
+  // are different questions and a workshop chasing one often wants both.
+  const handleDeliveryToggle = () => {
+    startTransition(() => {
+      router.push(
+        `${pathname}?${createQueryString({
+          delivery: deliveryFilter === 'unviewed' ? '' : 'unviewed',
+          page: '1',
+        })}`
+      )
+    })
+  }
+
   const getStatusBadge = (status: string) => {
     switch (status.toLowerCase()) {
       case 'paid':
@@ -217,6 +238,33 @@ export default function BillingClient({
     }
   }
 
+  /**
+   * How far the invoice got to the customer. Payment status answers whether
+   * the money arrived; this answers whether the invoice was ever opened, so
+   * one that went out days ago and has never been read can be chased.
+   *
+   * A mail with the PDF attached is read in the mail client and leaves no
+   * trace here, so "sent" on those means sent, not unread.
+   */
+  const getDeliveryBadge = (record: BillingRecord) => {
+    const state = deliveryState(record)
+    if (state === 'viewed') {
+      const seen = record.lastViewedAt ? formatDate(new Date(record.lastViewedAt)) : ''
+      return (
+        <Badge
+          className="bg-sky-100 text-sky-800 hover:bg-sky-100"
+          title={seen ? t('history.deliveryViewedOn', { date: seen }) : undefined}
+        >
+          {t('history.deliveryViewed')}
+        </Badge>
+      )
+    }
+    if (state === 'sent') {
+      return <Badge variant="outline">{t('history.deliverySent')}</Badge>
+    }
+    return <span className="text-muted-foreground">{'\u2014'}</span>
+  }
+
   const getBalanceColor = (status: string) => {
     switch (status.toLowerCase()) {
       case 'paid':
@@ -313,6 +361,17 @@ export default function BillingClient({
               )}
             </Button>
           ))}
+          <Button
+            variant={deliveryFilter === 'unviewed' ? 'default' : 'outline'}
+            size="sm"
+            className="h-9 shrink-0 sm:h-8"
+            onClick={handleDeliveryToggle}
+            disabled={isPending}
+            title={t('history.filterUnviewedHint')}
+          >
+            <EyeOff className="mr-1 h-3.5 w-3.5" />
+            {t('history.filterUnviewed')}
+          </Button>
         </div>
 
         <form onSubmit={handleSearch} className="flex gap-2">
@@ -377,6 +436,7 @@ export default function BillingClient({
                 </div>
                 <div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-xs">
                   {getStatusBadge(record.status)}
+                  {getDeliveryBadge(record)}
                   <span className={cn('font-medium', getBalanceColor(record.status))}>
                     {t('history.columnBalance')}: {fmt(balance)}
                   </span>
@@ -461,6 +521,16 @@ export default function BillingClient({
                   <SortIcon column="total" />
                 </button>
               </TableHead>
+              <TableHead className="w-[110px]">
+                <button
+                  type="button"
+                  className="flex items-center hover:text-foreground"
+                  onClick={() => handleSort('delivery')}
+                >
+                  {t('history.columnDelivery')}
+                  <SortIcon column="delivery" />
+                </button>
+              </TableHead>
               <TableHead className="w-[100px] text-right">{t('history.columnPaid')}</TableHead>
               <TableHead className="w-[180px] text-right">{t('history.columnBalance')}</TableHead>
             </TableRow>
@@ -468,7 +538,7 @@ export default function BillingClient({
           <TableBody>
             {data.records.length === 0 ? (
               <TableRow>
-                <TableCell colSpan={8} className="h-24 text-center">
+                <TableCell colSpan={9} className="h-24 text-center">
                   {t('history.noRecords')}
                 </TableCell>
               </TableRow>
@@ -505,6 +575,7 @@ export default function BillingClient({
                     </TableCell>
                     <TableCell>{formatDate(new Date(record.serviceDate))}</TableCell>
                     <TableCell className="text-right">{fmt(record.totalAmount)}</TableCell>
+                    <TableCell>{getDeliveryBadge(record)}</TableCell>
                     <TableCell className="text-right">{fmt(record.totalPaid)}</TableCell>
                     <TableCell
                       className={cn(

+ 12 - 1
src/app/(authenticated)/billing/page.tsx

@@ -14,6 +14,7 @@ export default async function BillingPage({
     pageSize?: string
     search?: string
     status?: string
+    delivery?: string
     sortBy?: string
     sortOrder?: string
   }>
@@ -23,6 +24,7 @@ export default async function BillingPage({
   const pageSize = Number(params.pageSize) || 20
   const search = params.search || ''
   const statusFilter = params.status || 'all'
+  const deliveryFilter = params.delivery === 'unviewed' ? 'unviewed' : ''
   // 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' })
@@ -30,7 +32,15 @@ export default async function BillingPage({
   const sortOrder = sort.sortOrder
 
   const [result, settingsResult] = await Promise.all([
-    getBillingHistory({ page, pageSize, search, status: statusFilter, sortBy, sortOrder }),
+    getBillingHistory({
+      page,
+      pageSize,
+      search,
+      status: statusFilter,
+      delivery: deliveryFilter,
+      sortBy,
+      sortOrder,
+    }),
     getSettings([SETTING_KEYS.CURRENCY_CODE]),
   ])
 
@@ -57,6 +67,7 @@ export default async function BillingPage({
           currencyCode={currencyCode}
           search={search}
           statusFilter={statusFilter}
+          deliveryFilter={deliveryFilter}
           sortBy={sortBy}
           sortOrder={sortOrder}
         />

+ 37 - 5
src/features/billing/Actions/billingActions.ts

@@ -64,6 +64,8 @@ export async function getBillingHistory(params: {
   pageSize?: number
   search?: string
   status?: string
+  /** 'unviewed' keeps only invoices that went out and were never opened. */
+  delivery?: string
   sortBy?: string
   sortOrder?: 'asc' | 'desc'
 }) {
@@ -99,6 +101,13 @@ export async function getBillingHistory(params: {
           statusCondition = 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 =
+          params.delivery === 'unviewed'
+            ? Prisma.sql`AND sent_at IS NOT NULL AND view_count = 0`
+            : Prisma.empty
+
         const countRows = await db.$queryRaw<{ cnt: bigint }[]>(Prisma.sql`
         SELECT COUNT(*) AS cnt FROM (
           SELECT
@@ -108,13 +117,15 @@ export async function getBillingHistory(params: {
             CASE WHEN sr."manuallyPaid" = true
               THEN CASE WHEN sr."totalAmount" > 0 THEN sr."totalAmount" ELSE sr.cost END
               ELSE COALESCE((SELECT SUM(p.amount) FROM payments p WHERE p."serviceRecordId" = sr.id), 0)
-            END AS paid_amount
+            END AS paid_amount,
+            sr."sentAt" AS sent_at,
+            sr."viewCount" AS view_count
           FROM service_records sr
           LEFT JOIN vehicles v ON v.id = sr."vehicleId"
           WHERE sr."organizationId" = ${organizationId}
           ${searchCondition}
         ) sub
-        WHERE 1=1 ${statusCondition}
+        WHERE 1=1 ${statusCondition} ${deliveryCondition}
       `)
 
         const total = Number(countRows[0]?.cnt ?? 0)
@@ -137,6 +148,9 @@ export async function getBillingHistory(params: {
             vehicle_license_plate: string | null
             customer_id: string | null
             customer_name: string | null
+            sent_at: Date | null
+            view_count: number
+            last_viewed_at: Date | null
           }[]
         >(Prisma.sql`
         SELECT
@@ -155,7 +169,10 @@ export async function getBillingHistory(params: {
           sub.vehicle_year,
           sub.vehicle_license_plate,
           sub.customer_id,
-          sub.customer_name
+          sub.customer_name,
+          sub.sent_at,
+          sub.view_count,
+          sub.last_viewed_at
         FROM (
           SELECT
             sr.id,
@@ -176,14 +193,17 @@ export async function getBillingHistory(params: {
             v.year AS vehicle_year,
             v."licensePlate" AS vehicle_license_plate,
             c.id AS customer_id,
-            c.name AS customer_name
+            c.name AS customer_name,
+            sr."sentAt" AS sent_at,
+            sr."viewCount" AS view_count,
+            sr."lastViewedAt" AS last_viewed_at
           FROM service_records sr
           LEFT JOIN vehicles v ON v.id = sr."vehicleId"
           LEFT JOIN customers c ON c.id = COALESCE(sr."customerId", v."customerId")
           WHERE sr."organizationId" = ${organizationId}
           ${searchCondition}
         ) sub
-        WHERE 1=1 ${statusCondition}
+        WHERE 1=1 ${statusCondition} ${deliveryCondition}
         ORDER BY ${(() => {
           const dir = params.sortOrder === 'asc' ? Prisma.sql`ASC` : Prisma.sql`DESC`
           switch (params.sortBy) {
@@ -199,6 +219,15 @@ export async function getBillingHistory(params: {
               return Prisma.sql`sub.effective_total ${dir}`
             case 'date':
               return Prisma.sql`${effectiveDateSql('sub')} ${dir}`
+            // Grouped rather than by date: what the column is for is finding
+            // the invoices that went out and were never opened, and those sit
+            // between the ones never sent and the ones already read.
+            case 'delivery':
+              return Prisma.sql`CASE
+                WHEN sub.view_count > 0 THEN 2
+                WHEN sub.sent_at IS NOT NULL THEN 1
+                ELSE 0
+              END ${dir}, sub.last_viewed_at ${dir} NULLS LAST`
             default:
               return Prisma.sql`${effectiveDateSql('sub')} DESC`
           }
@@ -236,6 +265,9 @@ export async function getBillingHistory(params: {
                   }
                 : null,
               customer: r.customer_id ? { id: r.customer_id, name: r.customer_name || '' } : null,
+              sentAt: r.sent_at,
+              viewCount: Number(r.view_count ?? 0),
+              lastViewedAt: r.last_viewed_at,
             }
           }),
           total,

+ 20 - 0
src/features/billing/Lib/deliveryState.ts

@@ -0,0 +1,20 @@
+/**
+ * How far an invoice got towards the customer.
+ *
+ * Payment status answers whether the money arrived. This answers whether the
+ * invoice was ever opened, which is what says who to chase: one sent a week
+ * ago and never read has probably landed in a spam folder.
+ *
+ * Only a link can be seen to have been opened. An invoice emailed with the
+ * PDF attached is read in the mail client and leaves no trace, so "sent"
+ * means sent rather than unread.
+ */
+export type DeliveryState = 'unsent' | 'sent' | 'viewed'
+
+export function deliveryState(record: {
+  sentAt: string | Date | null
+  viewCount: number
+}): DeliveryState {
+  if (record.viewCount > 0) return 'viewed'
+  return record.sentAt ? 'sent' : 'unsent'
+}