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
+207 -54
View File
@@ -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"