diff --git a/app/(dashboard)/supplier-invoices/page.tsx b/app/(dashboard)/supplier-invoices/page.tsx index 8c63e6e6..b26391bd 100644 --- a/app/(dashboard)/supplier-invoices/page.tsx +++ b/app/(dashboard)/supplier-invoices/page.tsx @@ -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 = { 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 ( + + {/* Preflight sets text-transform: none on buttons, which would drop the + TH_CLASS uppercase idiom inside the sort control. */} + + + ) +} + 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('all') const [searchTerm, setSearchTerm] = useState('') + // null = the API's default order (förfallodatum stigande). + const [sort, setSort] = useState(null) // Fiscal-year scope (convention 8): null = all years. const [fyPeriodId, setFyPeriodId] = useState(null) const [fyPeriod, setFyPeriod] = useState(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() { {canWrite && } - {t('th_supplier')} - {t('th_invoice_number')} - {t('th_invoice_date')} - {t('th_due_date')} - {t('th_amount')} - {t('th_remaining')} - {t('th_status')} + + + + + + + - {filteredInvoices.map((inv) => { + {sortedInvoices.map((inv) => { const chipVariant = STATUS_VARIANTS[inv.status] || 'secondary' const chipLabel = inv.status === 'paid' && inv.paid_at diff --git a/lib/supplier-invoices/__tests__/supplier-invoice-list-sort.test.ts b/lib/supplier-invoices/__tests__/supplier-invoice-list-sort.test.ts new file mode 100644 index 00000000..afb8b5f3 --- /dev/null +++ b/lib/supplier-invoices/__tests__/supplier-invoice-list-sort.test.ts @@ -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 = {}, + 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) + }) +}) diff --git a/lib/supplier-invoices/supplier-invoice-list-sort.ts b/lib/supplier-invoices/supplier-invoice-list-sort.ts new file mode 100644 index 00000000..3d4bb7ef --- /dev/null +++ b/lib/supplier-invoices/supplier-invoice-list-sort.ts @@ -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 = { + 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( + 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( + 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) + }) +} diff --git a/messages/en.json b/messages/en.json index 5f437869..ad96905e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/sv.json b/messages/sv.json index ec92336f..a6a6a145 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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",