feat(supplier-invoices): sortable list columns matching the customer invoice list (#2091)

Adds client-side sorting to /supplier-invoices: all seven columns get the
same accessible SortableHeader as /invoices, backed by a pure comparator
(Swedish collation, nulls last, displayed-value semantics, stable
invoice_date/id tie-break). The click cycle is tri-state (asc, desc, back
to the API default of due date ascending) per the verifikat-list
precedent, since this list has a meaningful default order to return to.
The detail-pager list context follows the sorted order.


Claude-Session: https://claude.ai/code/session_01DZSVsZqbx8vDnC2SXunNaR

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-09-01 09:23:31 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 50f13cf198
commit a47d54f1d7
5 changed files with 401 additions and 12 deletions
@@ -0,0 +1,148 @@
import { describe, expect, it } from 'vitest'
import {
sortSupplierInvoiceList,
type SupplierInvoiceListSort,
} from '@/lib/supplier-invoices/supplier-invoice-list-sort'
import { makeSupplierInvoice } from '@/tests/helpers'
import type { SupplierInvoice } from '@/types'
function make(
overrides: Partial<SupplierInvoice> = {},
supplierName?: string,
): SupplierInvoice {
const invoice = makeSupplierInvoice(overrides)
if (supplierName !== undefined) {
invoice.supplier = { id: 'supplier-1', name: supplierName } as SupplierInvoice['supplier']
}
return invoice
}
function sort(invoices: SupplierInvoice[], sortBy: SupplierInvoiceListSort) {
return sortSupplierInvoiceList(invoices, sortBy)
}
describe('sortSupplierInvoiceList', () => {
it('uses Swedish supplier collation and keeps missing suppliers last', () => {
const invoices = [
make({ id: 'aker' }, 'Åkeriet AB'),
make({ id: 'alpha' }, 'Alpha AB'),
make({ id: 'zulu' }, 'Zulu AB'),
make({ id: 'missing' }),
]
expect(sort(invoices, { column: 'supplier', direction: 'asc' }).map((i) => i.id)).toEqual([
'alpha',
'zulu',
'aker',
'missing',
])
expect(sort(invoices, { column: 'supplier', direction: 'desc' }).map((i) => i.id)).toEqual([
'aker',
'zulu',
'alpha',
'missing',
])
})
it('sorts invoice numbers naturally', () => {
const invoices = [
make({ id: '10', supplier_invoice_number: 'F-10' }),
make({ id: '2', supplier_invoice_number: 'F-2' }),
make({ id: '1', supplier_invoice_number: 'F-1' }),
]
expect(sort(invoices, { column: 'number', direction: 'asc' }).map((i) => i.id)).toEqual([
'1',
'2',
'10',
])
})
it('sorts invoice and due dates in both directions', () => {
const invoices = [
make({ id: 'late', invoice_date: '2024-08-01', due_date: '2024-09-01' }),
make({ id: 'early', invoice_date: '2024-06-01', due_date: '2024-07-01' }),
]
expect(sort(invoices, { column: 'invoice_date', direction: 'asc' }).map((i) => i.id)).toEqual([
'early',
'late',
])
expect(sort(invoices, { column: 'due', direction: 'desc' }).map((i) => i.id)).toEqual([
'late',
'early',
])
})
it('sorts the displayed rounded amount, honoring the per-invoice öresavrundning flag', () => {
const invoices = [
// 100.6 rounds to 101 when the per-invoice flag is on.
make({ id: 'rounded', total: 100.6, ore_rounding: true, invoice_date: '2024-06-02' }),
// 100.9 stays 100.9 with the flag off (null resolves to off for supplier invoices).
make({ id: 'raw', total: 100.9, ore_rounding: null, invoice_date: '2024-06-01' }),
]
expect(sort(invoices, { column: 'amount', direction: 'asc' }).map((i) => i.id)).toEqual([
'raw',
'rounded',
])
expect(sort(invoices, { column: 'amount', direction: 'desc' }).map((i) => i.id)).toEqual([
'rounded',
'raw',
])
})
it('sorts by remaining amount', () => {
const invoices = [
make({ id: 'open', remaining_amount: 5000 }),
make({ id: 'settled', remaining_amount: 0 }),
make({ id: 'partial', remaining_amount: 2500 }),
]
expect(sort(invoices, { column: 'remaining', direction: 'asc' }).map((i) => i.id)).toEqual([
'settled',
'partial',
'open',
])
})
it('ranks statuses in lifecycle order', () => {
const invoices = [
make({ id: 'reversed', status: 'reversed' }),
make({ id: 'credited', status: 'credited' }),
make({ id: 'paid', status: 'paid' }),
make({ id: 'disputed', status: 'disputed' }),
make({ id: 'overdue', status: 'overdue' }),
make({ id: 'partial', status: 'partially_paid' }),
make({ id: 'approved', status: 'approved' }),
make({ id: 'registered', status: 'registered' }),
]
expect(sort(invoices, { column: 'status', direction: 'asc' }).map((i) => i.id)).toEqual([
'registered',
'approved',
'partial',
'overdue',
'disputed',
'paid',
'credited',
'reversed',
])
})
it('does not mutate input and uses newest date then id as stable tie-breakers', () => {
const invoices = [
make({ id: 'b', invoice_date: '2024-07-01' }, 'Same AB'),
make({ id: 'a', invoice_date: '2024-07-01' }, 'Same AB'),
make({ id: 'newer', invoice_date: '2024-07-02' }, 'Same AB'),
]
const originalOrder = invoices.map((invoice) => invoice.id)
expect(sort(invoices, { column: 'supplier', direction: 'asc' }).map((i) => i.id)).toEqual([
'newer',
'a',
'b',
])
expect(invoices.map((invoice) => invoice.id)).toEqual(originalOrder)
})
})
@@ -0,0 +1,118 @@
import { getDisplayTotal } from '@/lib/invoices/rounding'
import type { SupplierInvoice, SupplierInvoiceStatus } from '@/types'
// Mirrors lib/invoices/invoice-list-sort.ts: pure comparators over the values
// the list actually displays, Swedish collation for text, nulls always last,
// and a stable newest-date/id tie-break. Kept separate because the column set
// and status lifecycle differ from customer invoices.
export type SupplierInvoiceListSortColumn =
| 'supplier'
| 'number'
| 'invoice_date'
| 'due'
| 'amount'
| 'remaining'
| 'status'
export type SupplierInvoiceListSortDirection = 'asc' | 'desc'
export interface SupplierInvoiceListSort {
column: SupplierInvoiceListSortColumn
direction: SupplierInvoiceListSortDirection
}
const swedishCollator = new Intl.Collator('sv', {
numeric: true,
sensitivity: 'base',
})
// Lifecycle order: waiting-for-attest first, then the payment queue, with
// exception and terminal states at the end.
const statusRank: Record<SupplierInvoiceStatus, number> = {
registered: 0,
approved: 1,
partially_paid: 2,
overdue: 3,
disputed: 4,
paid: 5,
credited: 6,
reversed: 7,
}
function displayedSupplier(invoice: SupplierInvoice): string | null {
return invoice.supplier?.name || null
}
function displayedNumber(invoice: SupplierInvoice): string | null {
return invoice.supplier_invoice_number || null
}
// The Belopp cell resolves öresavrundning from the per-invoice flag with the
// company fallback pinned off (supplier invoices never had a company-wide
// rounding setting); sorting must rank the same displayed value.
function displayedAmount(invoice: SupplierInvoice): number {
return getDisplayTotal(
{ total: invoice.total, currency: invoice.currency, ore_rounding: invoice.ore_rounding },
{ ore_rounding: false },
).displayed
}
function compareNullable<T>(
left: T | null,
right: T | null,
direction: SupplierInvoiceListSortDirection,
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: SupplierInvoice,
right: SupplierInvoice,
sort: SupplierInvoiceListSort,
): number {
const sign = sort.direction === 'asc' ? 1 : -1
switch (sort.column) {
case 'supplier':
return compareNullable(
displayedSupplier(left),
displayedSupplier(right),
sort.direction,
swedishCollator.compare,
)
case 'number':
return compareNullable(
displayedNumber(left),
displayedNumber(right),
sort.direction,
swedishCollator.compare,
)
case 'invoice_date':
return sign * left.invoice_date.localeCompare(right.invoice_date)
case 'due':
return sign * left.due_date.localeCompare(right.due_date)
case 'amount':
return sign * (displayedAmount(left) - displayedAmount(right))
case 'remaining':
return sign * (left.remaining_amount - right.remaining_amount)
case 'status':
return sign * (statusRank[left.status] - statusRank[right.status])
}
}
export function sortSupplierInvoiceList<T extends SupplierInvoice>(
invoices: T[],
sort: SupplierInvoiceListSort,
): T[] {
return [...invoices].sort((left, right) => {
const primary = comparePrimary(left, right, sort)
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)
})
}