From e40dc64485cf9de96bdd9b53d4a274303c91dcab Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:43:30 +0200 Subject: [PATCH] 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 * 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 --------- Co-authored-by: Claude Fable 5 --- app/(dashboard)/invoices/page.tsx | 261 ++++++++++++++---- .../invoice-list-query-shape.test.ts | 18 ++ .../__tests__/invoice-list-sort.test.ts | 143 ++++++++++ lib/invoices/invoice-list-sort.ts | 113 ++++++++ messages/en.json | 1 + messages/sv.json | 1 + 6 files changed, 483 insertions(+), 54 deletions(-) create mode 100644 lib/invoices/__tests__/invoice-list-query-shape.test.ts create mode 100644 lib/invoices/__tests__/invoice-list-sort.test.ts create mode 100644 lib/invoices/invoice-list-sort.ts diff --git a/app/(dashboard)/invoices/page.tsx b/app/(dashboard)/invoices/page.tsx index 0de10d9a..7012ca49 100644 --- a/app/(dashboard)/invoices/page.tsx +++ b/app/(dashboard)/invoices/page.tsx @@ -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 ( + + + + ) +} + export default function InvoicesPage() { const { company } = useCompany() const { canWrite } = useCanWrite() @@ -92,6 +156,7 @@ export default function InvoicesPage() { const [oreRounding, setOreRounding] = useState(true) const [isLoading, setIsLoading] = useState(true) const [searchTerm, setSearchTerm] = useState('') + const [sort, setSort] = useState(null) const [activeTab, setActiveTab] = useState(() => { // 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( + ({ 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() { - - - - - + + + + + {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} )} - - {chip.label} - + @@ -449,7 +602,7 @@ export default function InvoicesPage() { )} - {!isLoading && visibleCount < filteredInvoices.length && ( + {!isLoading && visibleCount < sortedInvoices.length && (
{t('th_nr')}{t('th_customer')}{t('th_due')}{t('th_amount')}{t('th_status')}