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:
co-authored by
Claude Fable 5
parent
50f13cf198
commit
a47d54f1d7
@@ -14,7 +14,7 @@ import { TH_CLASS, TD_CLASS, QUIET_LINK_CLASS } from '@/components/ui/dry-table'
|
||||
import { FyPicker } from '@/components/common/FyPicker'
|
||||
import { ContextPicker } from '@/components/common/ContextPicker'
|
||||
import { HelpPopover } from '@/components/ui/help-popover'
|
||||
import { Plus, FileInput, Lock } from 'lucide-react'
|
||||
import { Plus, FileInput, Lock, ArrowUp, ArrowDown, ArrowUpDown } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { DialogLoadingSkeleton } from '@/components/ui/dialog-loading-skeleton'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
@@ -23,6 +23,11 @@ import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { cn, formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { getDisplayTotal } from '@/lib/invoices/rounding'
|
||||
import { canApproveSupplierInvoice } from '@/lib/supplier-invoices/lifecycle'
|
||||
import {
|
||||
sortSupplierInvoiceList,
|
||||
type SupplierInvoiceListSort,
|
||||
type SupplierInvoiceListSortColumn,
|
||||
} from '@/lib/supplier-invoices/supplier-invoice-list-sort'
|
||||
import { listContextKey, writeListContext } from '@/lib/navigation/list-context'
|
||||
import { useCompanyOptional } from '@/contexts/CompanyContext'
|
||||
import type { FiscalPeriod, SupplierInvoice } from '@/types'
|
||||
@@ -86,6 +91,59 @@ const TAB_LABEL_KEYS: Record<ListTab, string> = {
|
||||
paid: 'tab_paid',
|
||||
}
|
||||
|
||||
// Same shape as the invoices list header (app/(dashboard)/invoices/page.tsx).
|
||||
// Like the verifikat list (and unlike /invoices, which starts unsorted), this
|
||||
// list has a meaningful default order (förfallodatum stigande from the API),
|
||||
// so the click cycle is tri-state: asc → desc → back to the default.
|
||||
interface SortableHeaderProps {
|
||||
label: string
|
||||
sortLabel: string
|
||||
column: SupplierInvoiceListSortColumn
|
||||
sort: SupplierInvoiceListSort | null
|
||||
onSort: (column: SupplierInvoiceListSortColumn) => void
|
||||
className?: string
|
||||
align?: 'left' | 'right'
|
||||
}
|
||||
|
||||
function SortableHeader({
|
||||
label,
|
||||
sortLabel,
|
||||
column,
|
||||
sort,
|
||||
onSort,
|
||||
className,
|
||||
align = 'left',
|
||||
}: SortableHeaderProps) {
|
||||
const active = sort?.column === column
|
||||
const direction = active ? sort.direction : null
|
||||
const SortIcon = direction === 'asc' ? ArrowUp : direction === 'desc' ? ArrowDown : ArrowUpDown
|
||||
|
||||
return (
|
||||
<th
|
||||
className={cn(TH_CLASS, className)}
|
||||
aria-sort={direction === 'asc' ? 'ascending' : direction === 'desc' ? 'descending' : 'none'}
|
||||
>
|
||||
{/* Preflight sets text-transform: none on buttons, which would drop the
|
||||
TH_CLASS uppercase idiom inside the sort control. */}
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'-mx-2 inline-flex min-h-10 items-center gap-1 rounded-sm px-2 uppercase focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
align === 'right' && 'ml-auto justify-end',
|
||||
)}
|
||||
aria-label={sortLabel}
|
||||
onClick={() => onSort(column)}
|
||||
>
|
||||
<span>{label}</span>
|
||||
<SortIcon
|
||||
aria-hidden="true"
|
||||
className={cn('h-3.5 w-3.5 shrink-0', !active && 'text-muted-foreground/60')}
|
||||
/>
|
||||
</button>
|
||||
</th>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SupplierInvoicesPage() {
|
||||
const t = useTranslations('supplier_invoices')
|
||||
const { canWrite } = useCanWrite()
|
||||
@@ -97,6 +155,8 @@ export default function SupplierInvoicesPage() {
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState<ListTab>('all')
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
// null = the API's default order (förfallodatum stigande).
|
||||
const [sort, setSort] = useState<SupplierInvoiceListSort | null>(null)
|
||||
// Fiscal-year scope (convention 8): null = all years.
|
||||
const [fyPeriodId, setFyPeriodId] = useState<string | null>(null)
|
||||
const [fyPeriod, setFyPeriod] = useState<FiscalPeriod | null>(null)
|
||||
@@ -207,11 +267,21 @@ export default function SupplierInvoicesPage() {
|
||||
return matchesTab && matchesSearch && matchesFy
|
||||
})
|
||||
|
||||
// Detail-pager context: the filtered list as rendered, written when the
|
||||
// user navigates into a row.
|
||||
// Tri-state cycle: asc → desc → back to the API default (due date asc).
|
||||
const updateSort = (column: SupplierInvoiceListSortColumn) => {
|
||||
setSort((current) => {
|
||||
if (current?.column !== column) return { column, direction: 'asc' }
|
||||
return current.direction === 'asc' ? { column, direction: 'desc' } : null
|
||||
})
|
||||
}
|
||||
|
||||
const sortedInvoices = sort ? sortSupplierInvoiceList(filteredInvoices, sort) : filteredInvoices
|
||||
|
||||
// Detail-pager context: the list as rendered (filtered + sorted), written
|
||||
// when the user navigates into a row.
|
||||
const rememberListContext = () => {
|
||||
writeListContext(listContextKey('supplier-invoices', company?.id), {
|
||||
ids: filteredInvoices.map((inv) => inv.id),
|
||||
ids: sortedInvoices.map((inv) => inv.id),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -419,18 +489,69 @@ export default function SupplierInvoicesPage() {
|
||||
<thead>
|
||||
<tr>
|
||||
{canWrite && <th className={cn(TH_CLASS, 'w-[26px] !pl-1')} aria-hidden="true"></th>}
|
||||
<th className={cn(TH_CLASS, 'w-full')}>{t('th_supplier')}</th>
|
||||
<th className={TH_CLASS}>{t('th_invoice_number')}</th>
|
||||
<th className={cn(TH_CLASS, 'hidden text-right md:table-cell')}>{t('th_invoice_date')}</th>
|
||||
<th className={cn(TH_CLASS, 'hidden text-right sm:table-cell')}>{t('th_due_date')}</th>
|
||||
<th className={cn(TH_CLASS, 'text-right')}>{t('th_amount')}</th>
|
||||
<th className={cn(TH_CLASS, 'hidden text-right lg:table-cell')}>{t('th_remaining')}</th>
|
||||
<th className={TH_CLASS}>{t('th_status')}</th>
|
||||
<SortableHeader
|
||||
label={t('th_supplier')}
|
||||
sortLabel={t('sort_by', { column: t('th_supplier') })}
|
||||
column="supplier"
|
||||
sort={sort}
|
||||
onSort={updateSort}
|
||||
className="w-full"
|
||||
/>
|
||||
<SortableHeader
|
||||
label={t('th_invoice_number')}
|
||||
sortLabel={t('sort_by', { column: t('th_invoice_number') })}
|
||||
column="number"
|
||||
sort={sort}
|
||||
onSort={updateSort}
|
||||
/>
|
||||
<SortableHeader
|
||||
label={t('th_invoice_date')}
|
||||
sortLabel={t('sort_by', { column: t('th_invoice_date') })}
|
||||
column="invoice_date"
|
||||
sort={sort}
|
||||
onSort={updateSort}
|
||||
className="hidden text-right md:table-cell"
|
||||
align="right"
|
||||
/>
|
||||
<SortableHeader
|
||||
label={t('th_due_date')}
|
||||
sortLabel={t('sort_by', { column: t('th_due_date') })}
|
||||
column="due"
|
||||
sort={sort}
|
||||
onSort={updateSort}
|
||||
className="hidden text-right sm:table-cell"
|
||||
align="right"
|
||||
/>
|
||||
<SortableHeader
|
||||
label={t('th_amount')}
|
||||
sortLabel={t('sort_by', { column: t('th_amount') })}
|
||||
column="amount"
|
||||
sort={sort}
|
||||
onSort={updateSort}
|
||||
className="text-right"
|
||||
align="right"
|
||||
/>
|
||||
<SortableHeader
|
||||
label={t('th_remaining')}
|
||||
sortLabel={t('sort_by', { column: t('th_remaining') })}
|
||||
column="remaining"
|
||||
sort={sort}
|
||||
onSort={updateSort}
|
||||
className="hidden text-right lg:table-cell"
|
||||
align="right"
|
||||
/>
|
||||
<SortableHeader
|
||||
label={t('th_status')}
|
||||
sortLabel={t('sort_by', { column: t('th_status') })}
|
||||
column="status"
|
||||
sort={sort}
|
||||
onSort={updateSort}
|
||||
/>
|
||||
<th className={cn(TH_CLASS, 'w-[96px]')} aria-hidden="true"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="stagger-enter">
|
||||
{filteredInvoices.map((inv) => {
|
||||
{sortedInvoices.map((inv) => {
|
||||
const chipVariant = STATUS_VARIANTS[inv.status] || 'secondary'
|
||||
const chipLabel =
|
||||
inv.status === 'paid' && inv.paid_at
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -918,6 +918,7 @@
|
||||
"th_amount": "Amount",
|
||||
"th_remaining": "Remaining",
|
||||
"th_status": "Status",
|
||||
"sort_by": "Sort by {column}",
|
||||
"status_registered": "Registered",
|
||||
"status_approved": "Approved",
|
||||
"status_paid": "Paid",
|
||||
|
||||
@@ -918,6 +918,7 @@
|
||||
"th_amount": "Belopp",
|
||||
"th_remaining": "Kvar att betala",
|
||||
"th_status": "Status",
|
||||
"sort_by": "Sortera efter {column}",
|
||||
"status_registered": "Registrerad",
|
||||
"status_approved": "Godkänd",
|
||||
"status_paid": "Betald",
|
||||
|
||||
Reference in New Issue
Block a user