feat(invoices): invoice list column sorting and row-level status styling (#1375)

* feat(invoices): add column sorting and row-level status styling to invoice list

Client-side sorting on number, customer, due date, amount and status with
Swedish collation, null-last ordering and stable date/id tie-breaks. Status
chips move to the shared RowStatus descriptor so normal states stay muted and
exceptions carry semantic color. Invoice fetch now pages past the PostgREST
1000-row cap via fetchAllRows so sorting covers the full list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(invoices): make rounded-amount sort assertions non-degenerate

Distinct rounded totals now prove the comparator orders by displayed value;
the rounded tie case is kept as an explicit tie-breaker test since integer
rounding is monotonic and can only create ties, never reorder.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-03 18:43:30 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent a2f7132c94
commit e40dc64485
6 changed files with 483 additions and 54 deletions
@@ -0,0 +1,18 @@
import fs from 'node:fs'
import path from 'node:path'
import { describe, expect, it } from 'vitest'
const source = fs.readFileSync(
path.resolve(__dirname, '../../../app/(dashboard)/invoices/page.tsx'),
'utf8',
)
describe('invoice list query shape', () => {
it('paginates beyond the PostgREST row cap with a stable total order', () => {
expect(source).toContain('fetchAllRows<Invoice>')
expect(source).toContain(".order('invoice_date', { ascending: false })")
expect(source).toContain(".order('id', { ascending: false })")
expect(source).toContain('.range(from, to)')
expect(source).toContain('dedupeBy: (invoice) => invoice.id')
})
})
@@ -0,0 +1,143 @@
import { describe, expect, it } from 'vitest'
import { sortInvoiceList, type InvoiceListSort } from '@/lib/invoices/invoice-list-sort'
import { makeInvoice } from '@/tests/helpers'
import type { Invoice } from '@/types'
function sort(invoices: Invoice[], sortBy: InvoiceListSort, oreRounding = true) {
return sortInvoiceList(invoices, sortBy, oreRounding)
}
describe('sortInvoiceList', () => {
it('sorts displayed invoice numbers naturally and keeps missing numbers last', () => {
const invoices = [
makeInvoice({ id: '10', invoice_number: 'F-10' }),
makeInvoice({ id: '2', invoice_number: 'F-2' }),
makeInvoice({ id: 'external', invoice_number: null, external_invoice_number: 'SB-3' }),
makeInvoice({ id: 'missing', invoice_number: null, external_invoice_number: null }),
]
expect(sort(invoices, { column: 'number', direction: 'asc' }).map((i) => i.id)).toEqual([
'2',
'10',
'external',
'missing',
])
expect(sort(invoices, { column: 'number', direction: 'desc' }).map((i) => i.id)).toEqual([
'external',
'10',
'2',
'missing',
])
})
it('uses Swedish customer collation and keeps missing customers last', () => {
const invoices = [
makeInvoice({ id: 'aker', customer: { name: 'Åker AB' } as Invoice['customer'] }),
makeInvoice({ id: 'alpha', customer: { name: 'Alpha AB' } as Invoice['customer'] }),
makeInvoice({ id: 'zulu', customer: { name: 'Zulu AB' } as Invoice['customer'] }),
makeInvoice({ id: 'missing', customer: undefined }),
]
expect(sort(invoices, { column: 'customer', direction: 'asc' }).map((i) => i.id)).toEqual([
'alpha',
'zulu',
'aker',
'missing',
])
expect(sort(invoices, { column: 'customer', direction: 'desc' }).map((i) => i.id)).toEqual([
'aker',
'zulu',
'alpha',
'missing',
])
})
it('sorts displayed due dates and keeps draft and credit-note blanks last', () => {
const invoices = [
makeInvoice({ id: 'august', status: 'sent', due_date: '2024-08-01' }),
makeInvoice({ id: 'june', status: 'sent', due_date: '2024-06-01' }),
makeInvoice({ id: 'draft', status: 'draft', due_date: '2024-01-01' }),
makeInvoice({ id: 'credit', status: 'sent', due_date: '2024-02-01', credited_invoice_id: 'original' }),
]
const ascending = sort(invoices, { column: 'due', direction: 'asc' }).map((i) => i.id)
const descending = sort(invoices, { column: 'due', direction: 'desc' }).map((i) => i.id)
expect(ascending.slice(0, 2)).toEqual(['june', 'august'])
expect(new Set(ascending.slice(2))).toEqual(new Set(['draft', 'credit']))
expect(descending.slice(0, 2)).toEqual(['august', 'june'])
expect(new Set(descending.slice(2))).toEqual(new Set(['draft', 'credit']))
})
it('sorts the rounded amount displayed in the list', () => {
const invoices = [
makeInvoice({ id: 'larger', invoice_date: '2024-07-02', total: 11.6 }),
makeInvoice({ id: 'smaller', invoice_date: '2024-07-01', total: 10.4 }),
]
expect(sort(invoices, { column: 'amount', direction: 'asc' }, true).map((i) => i.id)).toEqual([
'smaller',
'larger',
])
expect(sort(invoices, { column: 'amount', direction: 'desc' }, true).map((i) => i.id)).toEqual([
'larger',
'smaller',
])
})
it('breaks rounded-amount ties by newest invoice date while unrounded totals still order', () => {
const invoices = [
makeInvoice({ id: 'newer', invoice_date: '2024-07-02', total: 10.49 }),
makeInvoice({ id: 'older', invoice_date: '2024-07-01', total: 10.01 }),
]
expect(sort(invoices, { column: 'amount', direction: 'asc' }, true).map((i) => i.id)).toEqual([
'newer',
'older',
])
expect(sort(invoices, { column: 'amount', direction: 'asc' }, false).map((i) => i.id)).toEqual([
'older',
'newer',
])
})
it('sorts lifecycle statuses and ranks a cancelled credit note as cancelled', () => {
const invoices = [
makeInvoice({ id: 'cancelled-credit', status: 'cancelled', credited_invoice_id: 'original' }),
makeInvoice({ id: 'credited', status: 'credited' }),
makeInvoice({ id: 'credit', status: 'sent', credited_invoice_id: 'original' }),
makeInvoice({ id: 'paid', status: 'paid' }),
makeInvoice({ id: 'overdue', status: 'overdue' }),
makeInvoice({ id: 'partial', status: 'partially_paid' }),
makeInvoice({ id: 'sent', status: 'sent' }),
makeInvoice({ id: 'draft', status: 'draft' }),
]
expect(sort(invoices, { column: 'status', direction: 'asc' }).map((i) => i.id)).toEqual([
'draft',
'sent',
'partial',
'overdue',
'paid',
'credit',
'credited',
'cancelled-credit',
])
})
it('does not mutate input and uses newest date then id as stable tie-breakers', () => {
const invoices = [
makeInvoice({ id: 'b', invoice_date: '2024-07-01', customer: { name: 'Same' } as Invoice['customer'] }),
makeInvoice({ id: 'a', invoice_date: '2024-07-01', customer: { name: 'Same' } as Invoice['customer'] }),
makeInvoice({ id: 'newer', invoice_date: '2024-07-02', customer: { name: 'Same' } as Invoice['customer'] }),
]
const originalOrder = invoices.map((invoice) => invoice.id)
expect(sort(invoices, { column: 'customer', direction: 'asc' }).map((i) => i.id)).toEqual([
'newer',
'a',
'b',
])
expect(invoices.map((invoice) => invoice.id)).toEqual(originalOrder)
})
})
+113
View File
@@ -0,0 +1,113 @@
import { getDisplayTotal } from '@/lib/invoices/rounding'
import type { Invoice, InvoiceStatus } from '@/types'
export type InvoiceListSortColumn = 'number' | 'customer' | 'due' | 'amount' | 'status'
export type InvoiceListSortDirection = 'asc' | 'desc'
export interface InvoiceListSort {
column: InvoiceListSortColumn
direction: InvoiceListSortDirection
}
const swedishCollator = new Intl.Collator('sv', {
numeric: true,
sensitivity: 'base',
})
const statusRank: Record<InvoiceStatus, number> = {
draft: 0,
sent: 1,
partially_paid: 2,
overdue: 3,
paid: 4,
credited: 6,
cancelled: 7,
}
function displayedNumber(invoice: Invoice): string | null {
return invoice.invoice_number ?? invoice.external_invoice_number ?? null
}
function displayedCustomer(invoice: Invoice): string | null {
return invoice.customer?.name ?? null
}
function displayedDueDate(invoice: Invoice): string | null {
if (invoice.credited_invoice_id || invoice.status === 'draft') return null
return invoice.due_date || null
}
function displayedStatusRank(invoice: Invoice): number {
if (invoice.status === 'cancelled') return statusRank.cancelled
if (invoice.status === 'paid') return statusRank.paid
if (invoice.credited_invoice_id) return 5
return statusRank[invoice.status]
}
function compareNullable<T>(
left: T | null,
right: T | null,
direction: InvoiceListSortDirection,
compare: (a: T, b: T) => number,
): number {
if (left === null) return right === null ? 0 : 1
if (right === null) return -1
const result = compare(left, right)
return direction === 'asc' ? result : -result
}
function comparePrimary(
left: Invoice,
right: Invoice,
sort: InvoiceListSort,
oreRounding: boolean,
): number {
switch (sort.column) {
case 'number':
return compareNullable(
displayedNumber(left),
displayedNumber(right),
sort.direction,
swedishCollator.compare,
)
case 'customer':
return compareNullable(
displayedCustomer(left),
displayedCustomer(right),
sort.direction,
swedishCollator.compare,
)
case 'due':
return compareNullable(
displayedDueDate(left),
displayedDueDate(right),
sort.direction,
(a, b) => a.localeCompare(b),
)
case 'amount': {
const leftTotal = getDisplayTotal(left, { ore_rounding: oreRounding }).displayed
const rightTotal = getDisplayTotal(right, { ore_rounding: oreRounding }).displayed
const result = leftTotal - rightTotal
return sort.direction === 'asc' ? result : -result
}
case 'status': {
const result = displayedStatusRank(left) - displayedStatusRank(right)
return sort.direction === 'asc' ? result : -result
}
}
}
export function sortInvoiceList(
invoices: Invoice[],
sort: InvoiceListSort,
oreRounding: boolean,
): Invoice[] {
return [...invoices].sort((left, right) => {
const primary = comparePrimary(left, right, sort, oreRounding)
if (primary !== 0) return primary
const dateTieBreak = right.invoice_date.localeCompare(left.invoice_date)
if (dateTieBreak !== 0) return dateTieBreak
return left.id.localeCompare(right.id)
})
}