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:
co-authored by
Claude Fable 5
parent
a2f7132c94
commit
e40dc64485
@@ -1,13 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import Link from 'next/link'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { RowStatus, type RowStatusDescriptor } from '@/components/ui/row-status'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
|
||||
@@ -23,11 +25,26 @@ import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { invoiceDisplayNumber } from '@/lib/invoices/display'
|
||||
import { getDisplayTotal } from '@/lib/invoices/rounding'
|
||||
import { Plus, Search, ReceiptText, Repeat, FileInput, FileDown } from 'lucide-react'
|
||||
import {
|
||||
sortInvoiceList,
|
||||
type InvoiceListSort,
|
||||
type InvoiceListSortColumn,
|
||||
} from '@/lib/invoices/invoice-list-sort'
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ArrowUpDown,
|
||||
Plus,
|
||||
Search,
|
||||
ReceiptText,
|
||||
Repeat,
|
||||
FileInput,
|
||||
FileDown,
|
||||
} from 'lucide-react'
|
||||
import { EmptyInvoices } from '@/components/ui/empty-state'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import type { FiscalPeriod, Invoice, InvoiceStatus } from '@/types'
|
||||
import type { FiscalPeriod, Invoice } from '@/types'
|
||||
|
||||
function NewInvoiceDialogLoading() {
|
||||
const t = useTranslations('invoices')
|
||||
@@ -83,6 +100,53 @@ function daysOverdue(dueDateStr: string): number {
|
||||
return Math.round((today.getTime() - dueDate.getTime()) / (1000 * 60 * 60 * 24))
|
||||
}
|
||||
|
||||
interface SortableHeaderProps {
|
||||
label: string
|
||||
sortLabel: string
|
||||
column: InvoiceListSortColumn
|
||||
sort: InvoiceListSort | null
|
||||
onSort: (column: InvoiceListSortColumn) => 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'}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'-mx-2 inline-flex min-h-10 items-center gap-1 rounded-sm px-2 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 InvoicesPage() {
|
||||
const { company } = useCompany()
|
||||
const { canWrite } = useCanWrite()
|
||||
@@ -92,6 +156,7 @@ export default function InvoicesPage() {
|
||||
const [oreRounding, setOreRounding] = useState<boolean>(true)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [sort, setSort] = useState<InvoiceListSort | null>(null)
|
||||
const [activeTab, setActiveTab] = useState<ListTab>(() => {
|
||||
// Deep links from the worklist and older bookmarks: ?status= / ?tab=.
|
||||
const param = searchParams.get('status') ?? searchParams.get('tab')
|
||||
@@ -130,12 +195,18 @@ export default function InvoicesPage() {
|
||||
async function fetchInvoices() {
|
||||
if (!company) return
|
||||
setIsLoading(true)
|
||||
const [invoicesResult, settingsResult] = await Promise.all([
|
||||
supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(name)')
|
||||
.eq('company_id', company.id)
|
||||
.order('invoice_date', { ascending: false }),
|
||||
const [invoicesResult, settingsResult] = await Promise.allSettled([
|
||||
fetchAllRows<Invoice>(
|
||||
({ from, to }) =>
|
||||
supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(name)')
|
||||
.eq('company_id', company.id)
|
||||
.order('invoice_date', { ascending: false })
|
||||
.order('id', { ascending: false })
|
||||
.range(from, to),
|
||||
{ dedupeBy: (invoice) => invoice.id },
|
||||
),
|
||||
supabase
|
||||
.from('company_settings')
|
||||
.select('ore_rounding')
|
||||
@@ -143,16 +214,20 @@ export default function InvoicesPage() {
|
||||
.maybeSingle(),
|
||||
])
|
||||
|
||||
if (invoicesResult.error) {
|
||||
if (invoicesResult.status === 'rejected') {
|
||||
toast({
|
||||
title: t('load_failed_title'),
|
||||
description: t('load_failed_description'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} else {
|
||||
setInvoices(invoicesResult.data || [])
|
||||
setInvoices(invoicesResult.value)
|
||||
}
|
||||
setOreRounding(settingsResult.data?.ore_rounding ?? true)
|
||||
setOreRounding(
|
||||
settingsResult.status === 'fulfilled'
|
||||
? (settingsResult.value.data?.ore_rounding ?? true)
|
||||
: true,
|
||||
)
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
@@ -161,32 +236,60 @@ export default function InvoicesPage() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const filteredInvoices = invoices.filter((invoice) => {
|
||||
const matchesSearch =
|
||||
(invoice.invoice_number ?? '').toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(invoice.external_invoice_number ?? '').toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(invoice.customer as { name: string })?.name?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
const normalizedSearch = searchTerm.trim().toLocaleLowerCase('sv-SE')
|
||||
const filteredInvoices = useMemo(
|
||||
() =>
|
||||
invoices.filter((invoice) => {
|
||||
const matchesSearch =
|
||||
(invoice.invoice_number ?? '').toLocaleLowerCase('sv-SE').includes(normalizedSearch) ||
|
||||
(invoice.external_invoice_number ?? '')
|
||||
.toLocaleLowerCase('sv-SE')
|
||||
.includes(normalizedSearch) ||
|
||||
(invoice.customer as { name: string })?.name
|
||||
?.toLocaleLowerCase('sv-SE')
|
||||
.includes(normalizedSearch)
|
||||
|
||||
const matchesFy =
|
||||
!fyPeriod ||
|
||||
(invoice.invoice_date >= fyPeriod.period_start && invoice.invoice_date <= fyPeriod.period_end)
|
||||
const matchesFy =
|
||||
!fyPeriod ||
|
||||
(invoice.invoice_date >= fyPeriod.period_start &&
|
||||
invoice.invoice_date <= fyPeriod.period_end)
|
||||
|
||||
const isCreditNote = !!invoice.credited_invoice_id
|
||||
const docType = (invoice as Invoice & { document_type?: string }).document_type || 'invoice'
|
||||
const matchesTab =
|
||||
(activeTab === 'all' && invoice.status !== 'cancelled') ||
|
||||
(activeTab === 'unpaid' && ['sent', 'overdue'].includes(invoice.status) && !isCreditNote && docType === 'invoice') ||
|
||||
(activeTab === 'overdue' && invoice.status === 'overdue' && !isCreditNote && docType === 'invoice') ||
|
||||
(activeTab === 'draft' && invoice.status === 'draft' && docType === 'invoice' && !isCreditNote) ||
|
||||
(activeTab === 'paid' && invoice.status === 'paid') ||
|
||||
(activeTab === 'credit' && isCreditNote) ||
|
||||
(activeTab === 'proforma' && docType === 'proforma' && invoice.status !== 'cancelled') ||
|
||||
(activeTab === 'delivery_note' && docType === 'delivery_note' && invoice.status !== 'cancelled') ||
|
||||
(activeTab === 'cancelled' && invoice.status === 'cancelled')
|
||||
const isCreditNote = !!invoice.credited_invoice_id
|
||||
const docType =
|
||||
(invoice as Invoice & { document_type?: string }).document_type || 'invoice'
|
||||
const matchesTab =
|
||||
(activeTab === 'all' && invoice.status !== 'cancelled') ||
|
||||
(activeTab === 'unpaid' &&
|
||||
['sent', 'overdue'].includes(invoice.status) &&
|
||||
!isCreditNote &&
|
||||
docType === 'invoice') ||
|
||||
(activeTab === 'overdue' &&
|
||||
invoice.status === 'overdue' &&
|
||||
!isCreditNote &&
|
||||
docType === 'invoice') ||
|
||||
(activeTab === 'draft' &&
|
||||
invoice.status === 'draft' &&
|
||||
docType === 'invoice' &&
|
||||
!isCreditNote) ||
|
||||
(activeTab === 'paid' && invoice.status === 'paid') ||
|
||||
(activeTab === 'credit' && isCreditNote) ||
|
||||
(activeTab === 'proforma' &&
|
||||
docType === 'proforma' &&
|
||||
invoice.status !== 'cancelled') ||
|
||||
(activeTab === 'delivery_note' &&
|
||||
docType === 'delivery_note' &&
|
||||
invoice.status !== 'cancelled') ||
|
||||
(activeTab === 'cancelled' && invoice.status === 'cancelled')
|
||||
|
||||
return matchesSearch && matchesFy && matchesTab
|
||||
})
|
||||
const visibleInvoices = filteredInvoices.slice(0, visibleCount)
|
||||
return matchesSearch && matchesFy && matchesTab
|
||||
}),
|
||||
[activeTab, fyPeriod, invoices, normalizedSearch],
|
||||
)
|
||||
const sortedInvoices = useMemo(
|
||||
() => (sort ? sortInvoiceList(filteredInvoices, sort, oreRounding) : filteredInvoices),
|
||||
[filteredInvoices, oreRounding, sort],
|
||||
)
|
||||
const visibleInvoices = sortedInvoices.slice(0, visibleCount)
|
||||
|
||||
const overdueCount = invoices.filter(
|
||||
(i) => i.status === 'overdue' && !i.credited_invoice_id,
|
||||
@@ -194,6 +297,15 @@ export default function InvoicesPage() {
|
||||
|
||||
const resetPaging = () => setVisibleCount(INITIAL_VISIBLE_ROWS)
|
||||
|
||||
const updateSort = (column: InvoiceListSortColumn) => {
|
||||
setSort((current) => ({
|
||||
column,
|
||||
direction:
|
||||
current?.column === column && current.direction === 'asc' ? 'desc' : 'asc',
|
||||
}))
|
||||
resetPaging()
|
||||
}
|
||||
|
||||
const createOptions: SplitButtonOption[] = [
|
||||
{
|
||||
key: 'faktura',
|
||||
@@ -225,35 +337,43 @@ export default function InvoicesPage() {
|
||||
// One derivable status chip per row (concept scene 15). Doc-type markers
|
||||
// (proforma/följesedel/självfaktura) only appear in views where the type
|
||||
// isn't already implied.
|
||||
function statusChip(invoice: Invoice): { label: string; variant: 'secondary' | 'outline' | 'success' | 'warning' | 'destructive' } {
|
||||
function statusDescriptor(invoice: Invoice): RowStatusDescriptor {
|
||||
const isCreditNote = !!invoice.credited_invoice_id
|
||||
if (invoice.status === 'cancelled') return { label: t('status_cancelled'), variant: 'secondary' }
|
||||
if (isCreditNote && invoice.status !== 'paid') return { label: t('badge_credit'), variant: 'destructive' }
|
||||
if (invoice.status === 'credited') return { label: t('status_credited'), variant: 'secondary' }
|
||||
if (invoice.status === 'cancelled') {
|
||||
return { label: t('status_cancelled'), exception: true, variant: 'secondary' }
|
||||
}
|
||||
if (isCreditNote && invoice.status !== 'paid') {
|
||||
return { label: t('badge_credit'), exception: true, variant: 'destructive' }
|
||||
}
|
||||
if (invoice.status === 'credited') {
|
||||
return { label: t('status_credited'), exception: true, variant: 'secondary' }
|
||||
}
|
||||
if (invoice.status === 'draft') {
|
||||
const docType = (invoice as Invoice & { document_type?: string }).document_type || 'invoice'
|
||||
const isUnsent =
|
||||
!!invoice.invoice_number && docType === 'invoice' && !isCreditNote && !invoice.is_self_billed
|
||||
return isUnsent
|
||||
? { label: t('status_unsent'), variant: 'outline' }
|
||||
: { label: t('status_draft'), variant: 'secondary' }
|
||||
? { label: t('status_unsent'), exception: true, variant: 'outline' }
|
||||
: { label: t('status_draft'), exception: true, variant: 'secondary' }
|
||||
}
|
||||
if (invoice.status === 'paid') {
|
||||
return {
|
||||
label: invoice.paid_at
|
||||
? t('status_paid_date', { date: formatDate(invoice.paid_at) })
|
||||
: t('status_paid'),
|
||||
variant: 'success',
|
||||
}
|
||||
}
|
||||
if (invoice.status === 'partially_paid') return { label: t('status_partially_paid'), variant: 'warning' }
|
||||
if (invoice.status === 'partially_paid') {
|
||||
return { label: t('status_partially_paid'), exception: true, variant: 'warning' }
|
||||
}
|
||||
if (invoice.status === 'overdue' && invoice.due_date) {
|
||||
return {
|
||||
label: t('status_overdue_days', { days: Math.max(1, daysOverdue(invoice.due_date)) }),
|
||||
exception: true,
|
||||
variant: 'warning',
|
||||
}
|
||||
}
|
||||
return { label: t('status_sent'), variant: 'outline' }
|
||||
return { label: t('status_sent') }
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -360,16 +480,51 @@ export default function InvoicesPage() {
|
||||
<table className="w-full border-collapse text-[13px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={TH_CLASS}>{t('th_nr')}</th>
|
||||
<th className={cn(TH_CLASS, 'w-full')}>{t('th_customer')}</th>
|
||||
<th className={cn(TH_CLASS, 'hidden text-right sm:table-cell')}>{t('th_due')}</th>
|
||||
<th className={cn(TH_CLASS, 'text-right')}>{t('th_amount')}</th>
|
||||
<th className={TH_CLASS}>{t('th_status')}</th>
|
||||
<SortableHeader
|
||||
label={t('th_nr')}
|
||||
sortLabel={t('sort_by', { column: t('th_nr') })}
|
||||
column="number"
|
||||
sort={sort}
|
||||
onSort={updateSort}
|
||||
/>
|
||||
<SortableHeader
|
||||
label={t('th_customer')}
|
||||
sortLabel={t('sort_by', { column: t('th_customer') })}
|
||||
column="customer"
|
||||
sort={sort}
|
||||
onSort={updateSort}
|
||||
className="w-full"
|
||||
/>
|
||||
<SortableHeader
|
||||
label={t('th_due')}
|
||||
sortLabel={t('sort_by', { column: t('th_due') })}
|
||||
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_status')}
|
||||
sortLabel={t('sort_by', { column: t('th_status') })}
|
||||
column="status"
|
||||
sort={sort}
|
||||
onSort={updateSort}
|
||||
/>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="stagger-enter">
|
||||
{visibleInvoices.map((invoice) => {
|
||||
const chip = statusChip(invoice)
|
||||
const status = statusDescriptor(invoice)
|
||||
const isCreditNote = !!invoice.credited_invoice_id
|
||||
const docType = (invoice as Invoice & { document_type?: string }).document_type || 'invoice'
|
||||
const displayedTotal = getDisplayTotal(
|
||||
@@ -436,9 +591,7 @@ export default function InvoicesPage() {
|
||||
{typeMarker}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant={chip.variant} className="font-normal">
|
||||
{chip.label}
|
||||
</Badge>
|
||||
<RowStatus status={status} />
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -449,7 +602,7 @@ export default function InvoicesPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && visibleCount < filteredInvoices.length && (
|
||||
{!isLoading && visibleCount < sortedInvoices.length && (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -5375,6 +5375,7 @@
|
||||
"th_due": "Due",
|
||||
"th_amount": "Amount",
|
||||
"th_status": "Status",
|
||||
"sort_by": "Sort by {column}",
|
||||
"create_invoice_desc": "Regular customer invoice",
|
||||
"create_recurring": "Recurring invoice",
|
||||
"create_recurring_desc": "Created automatically, e.g. every month",
|
||||
|
||||
@@ -5375,6 +5375,7 @@
|
||||
"th_due": "Förfaller",
|
||||
"th_amount": "Belopp",
|
||||
"th_status": "Status",
|
||||
"sort_by": "Sortera efter {column}",
|
||||
"create_invoice_desc": "Vanlig kundfaktura",
|
||||
"create_recurring": "Återkommande faktura",
|
||||
"create_recurring_desc": "Skapas automatiskt, till exempel varje månad",
|
||||
|
||||
Reference in New Issue
Block a user