From d463ee1c870a4a53a375ea16968baca3ad6174f8 Mon Sep 17 00:00:00 2001 From: Joakim Hansson Date: Fri, 4 Sep 2026 16:44:54 +0200 Subject: [PATCH] feat(supplier-invoices): payment-queue sections and a grouping picker (#2102) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(supplier-invoices): payment-queue sections and a grouping picker The list groups into Väntar på betalning / Betalda och avslutade sections by default so the payment queue is never buried; a toolbar picker switches grouping to supplier, month, or none, written back to the URL as ?group=. The tri-state column sort governs order inside each group, and with no sort active the status sections run newest first. Both message catalogs carry the new strings. Co-Authored-By: Claude Fable 5 * fix: group by supplier_id, not display name (mirrors #2101 review) Co-Authored-By: Claude Fable 5 * fix(supplier-invoices): address review: shared helper, API order kept, queue includes partially_paid - keep CHECKBOX_REVEAL_CLASS and useRangeSelect from main (the PR had reverted #2093 and the region now carries #2117) - default the grouping picker to 'none' - render sections from the shared lib/lists/group-rows.ts helper, the same implementation #2101 uses, instead of a second copy of the bucketing - drop the byNewestFirst re-sort: for a payment queue the API's forfallodatum-ascending default is the order that matters, so grouping no longer changes it - isAwaitingPayment now mirrors PAYABLE_STATUSES and includes partially_paid, so a partially paid invoice stays in the queue - colSpan follows the column count: canWrite ? 9 : 8 - replace the banned em dash placeholders with an UNKNOWN_GROUP_KEY sentinel and a translated label - section headers carry data-no-stagger, and the header row uses the same index-based prevKey shape as #2101 instead of an IIFE Co-Authored-By: Claude Opus 5 (1M context) * fix(supplier-invoices): flat is the URL-less default; range select walks the grouped order (review) Only 'none' owns the URL-less state so a chosen grouping survives reload. Shift-click ranges now index the sectioned order the table renders instead of the pre-grouping sort, as useRangeSelect requires. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014XUxGhBBwQSMu59bWq6Vrf --------- Co-authored-by: Claude Fable 5 Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> --- app/(dashboard)/supplier-invoices/page.tsx | 157 +++++++++++++++++++-- messages/en.json | 11 +- messages/sv.json | 11 +- 3 files changed, 168 insertions(+), 11 deletions(-) diff --git a/app/(dashboard)/supplier-invoices/page.tsx b/app/(dashboard)/supplier-invoices/page.tsx index 70db1598..6da5ef8b 100644 --- a/app/(dashboard)/supplier-invoices/page.tsx +++ b/app/(dashboard)/supplier-invoices/page.tsx @@ -1,9 +1,10 @@ 'use client' -import { useState, useEffect, useRef } from 'react' +import { Fragment, useMemo, useState, useEffect, useRef } from 'react' import dynamic from 'next/dynamic' import { useRouter, useSearchParams } from 'next/navigation' -import { useTranslations } from 'next-intl' +import { useLocale, useTranslations } from 'next-intl' +import { groupRows } from '@/lib/lists/group-rows' import { Skeleton } from '@/components/ui/skeleton' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' @@ -145,8 +146,30 @@ function SortableHeader({ ) } + +// Row grouping (same pattern as the customer invoice list): 'none' (the flat +// list) is the default; the other modes are opt-in via ?group=, and 'status' +// reproduces the payment-queue sections. +/** Mirrors PAYABLE_STATUSES in lib/invoices/bulk-reconcile-supplier-vouchers.ts: + * a partially paid invoice still belongs in the payment queue. */ +const AWAITING_PAYMENT_STATUSES = ['registered', 'approved', 'overdue', 'partially_paid'] +const isAwaitingPayment = (status: string | null | undefined) => + !!status && AWAITING_PAYMENT_STATUSES.includes(status) +const STATUS_SECTION_ORDER = ['awaiting', 'settled'] as const +/** Sentinel bucket for rows with no supplier or no date. */ +const UNKNOWN_GROUP_KEY = 'unknown' +const GROUP_MODES = ['status', 'supplier', 'month', 'none'] as const +type GroupMode = (typeof GROUP_MODES)[number] +const GROUP_LABEL_KEYS: Record = { + status: 'group_status', + supplier: 'group_supplier', + month: 'group_month', + none: 'group_none', +} + export default function SupplierInvoicesPage() { const t = useTranslations('supplier_invoices') + const locale = useLocale() const { canWrite } = useCanWrite() const { toast } = useToast() const router = useRouter() @@ -158,6 +181,10 @@ export default function SupplierInvoicesPage() { const [searchTerm, setSearchTerm] = useState('') // null = the API's default order (förfallodatum stigande). const [sort, setSort] = useState(null) + const [groupMode, setGroupMode] = useState(() => { + const param = searchParams.get('group') + return param && GROUP_MODES.includes(param as never) ? (param as GroupMode) : 'none' + }) // Fiscal-year scope (convention 8): null = all years. const [fyPeriodId, setFyPeriodId] = useState(null) const [fyPeriod, setFyPeriod] = useState(null) @@ -271,6 +298,7 @@ export default function SupplierInvoicesPage() { return matchesTab && matchesSearch && matchesFy }) + // Tri-state cycle: asc → desc → back to the API default (due date asc). const updateSort = (column: SupplierInvoiceListSortColumn) => { setSort((current) => { @@ -281,11 +309,91 @@ export default function SupplierInvoicesPage() { const sortedInvoices = sort ? sortSupplierInvoiceList(filteredInvoices, sort) : filteredInvoices - // Detail-pager context: the list as rendered (filtered + sorted), written - // when the user navigates into a row. + // Grouping: bucket the sorted rows through the shared helper and flatten + // back so paging, range selection and the detail pager walk the exact + // rendered order. Order is never touched here: for a payment queue the + // API's forfallodatum-ascending default is the order that matters (what + // falls due first goes first), and an active column sort governs the rest. + const { orderedInvoices, rowGroupKeys, groupMeta } = useMemo(() => { + const keys = new Map() + if (groupMode === 'none') { + for (const inv of sortedInvoices) keys.set(inv.id, null) + return { + orderedInvoices: sortedInvoices, + rowGroupKeys: keys, + groupMeta: new Map(), + } + } + const grouped = + groupMode === 'status' + ? groupRows(sortedInvoices, { + keyOf: (inv) => { + const key = isAwaitingPayment(inv.status) ? 'awaiting' : 'settled' + return { key, label: key } + }, + order: STATUS_SECTION_ORDER, + }) + : groupMode === 'supplier' + ? groupRows(sortedInvoices, { + keyOf: (inv) => { + const label = inv.supplier?.name ?? UNKNOWN_GROUP_KEY + // Bucket by id, not display name: two suppliers can share one. + return { key: inv.supplier_id ?? label, label } + }, + order: (a, b) => a.label.localeCompare(b.label, 'sv'), + }) + : groupRows(sortedInvoices, { + keyOf: (inv) => { + const key = (inv.invoice_date ?? '').slice(0, 7) || UNKNOWN_GROUP_KEY + return { key, label: key } + }, + order: (a, b) => b.key.localeCompare(a.key), + }) + const flat: typeof sortedInvoices = [] + for (const entry of grouped.rows) { + keys.set(entry.row.id, entry.groupKey) + flat.push(entry.row) + } + return { orderedInvoices: flat, rowGroupKeys: keys, groupMeta: grouped.meta } + }, [groupMode, sortedInvoices]) + // Status sections only earn headers when there is more than one of them; + // supplier/month grouping is an explicit ask, so headers always show. + const showGroupHeaders = groupMode !== 'none' && (groupMode !== 'status' || groupMeta.size > 1) + + const monthFormatter = useMemo( + () => new Intl.DateTimeFormat(locale === 'en' ? 'en-GB' : 'sv-SE', { month: 'long', year: 'numeric' }), + [locale], + ) + function groupHeaderLabel(key: string): string { + const meta = groupMeta.get(key) + const count = meta?.count ?? 0 + if (groupMode === 'status') { + return key === 'awaiting' ? t('section_awaiting', { count }) : t('section_settled', { count }) + } + if (groupMode === 'month' && key !== UNKNOWN_GROUP_KEY) { + const label = monthFormatter.format(new Date(`${key}-01T00:00:00`)) + return `${label.charAt(0).toLocaleUpperCase('sv-SE')}${label.slice(1)} (${count})` + } + if (key === UNKNOWN_GROUP_KEY) return `${t('group_unknown')} (${count})` + return `${meta?.label ?? key} (${count})` + } + + const updateGroup = (mode: GroupMode) => { + setGroupMode(mode) + const params = new URLSearchParams(searchParams.toString()) + // Flat is the default, so it owns the URL-less state; every other mode + // is written out so it round-trips through reload and back-navigation. + if (mode === 'none') params.delete('group') + else params.set('group', mode) + const qs = params.toString() + router.replace(qs ? `/supplier-invoices?${qs}` : '/supplier-invoices', { scroll: false }) + } + + // Detail-pager context: the list as rendered (filtered + sectioned + + // sorted), written when the user navigates into a row. const rememberListContext = () => { writeListContext(listContextKey('supplier-invoices', company?.id), { - ids: sortedInvoices.map((inv) => inv.id), + ids: orderedInvoices.map((inv) => inv.id), }) } @@ -298,9 +406,10 @@ export default function SupplierInvoicesPage() { const allSelectableSelected = selectableInvoices.length > 0 && selectableInvoices.every((inv) => selectedIds.has(inv.id)) - // Ranges walk the selectable rows in rendered (sorted) order. + // Ranges walk the selectable rows in rendered order: sorted, then sectioned + // by the active grouping, which is what the user sees on screen. const range = useRangeSelect({ - visibleIds: sortedInvoices.filter(isBatchSelectable).map((inv) => inv.id), + visibleIds: orderedInvoices.filter(isBatchSelectable).map((inv) => inv.id), selectedIds, setSelectedIds, }) @@ -411,6 +520,16 @@ export default function SupplierInvoicesPage() { : undefined, }))} /> + updateGroup(id as GroupMode)} + ariaLabel={t('group_picker_aria')} + triggerLabel={`${t('group_by')} · ${t(GROUP_LABEL_KEYS[groupMode])}`} + items={GROUP_MODES.map((mode) => ({ + id: mode, + label: t(GROUP_LABEL_KEYS[mode]), + }))} + /> - {sortedInvoices.map((inv) => { + {orderedInvoices.map((inv, rowIndex) => { const chipVariant = STATUS_VARIANTS[inv.status] || 'secondary' const chipLabel = inv.status === 'paid' && inv.paid_at @@ -574,9 +693,28 @@ export default function SupplierInvoicesPage() { const canApprove = canApproveSupplierInvoice(inv) && !inv.is_credit_note && canWrite const selectable = canWrite && isBatchSelectable(inv) + // Same shape as the customer list: the section header is a + // sibling row decided from the previous row's key. + const groupKey = rowGroupKeys.get(inv.id) ?? null + const prevKey = + rowIndex > 0 ? rowGroupKeys.get(orderedInvoices[rowIndex - 1].id) ?? null : undefined + const showHeader = showGroupHeaders && groupKey !== null && groupKey !== prevKey return ( + + {showHeader && ( + + + {groupHeaderLabel(groupKey)} + + + )} + ) })} diff --git a/messages/en.json b/messages/en.json index 7ac97baf..5c3be5cc 100644 --- a/messages/en.json +++ b/messages/en.json @@ -832,7 +832,16 @@ "bulk_clear": "Clear", "bulk_select_row": "Select invoice for payment file", "in_batch_chip": "In payment file", - "payment_files_link": "Payment files" + "payment_files_link": "Payment files", + "section_awaiting": "Awaiting payment ({count})", + "section_settled": "Paid and settled ({count})", + "group_picker_aria": "Group supplier invoices", + "group_by": "Group", + "group_status": "Status", + "group_supplier": "Supplier", + "group_month": "Month", + "group_none": "No grouping", + "group_unknown": "Missing" }, "supplier_payment_files": { "dialog_title": "Create payment file", diff --git a/messages/sv.json b/messages/sv.json index 9d5cb502..3656d196 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -832,7 +832,16 @@ "bulk_clear": "Rensa", "bulk_select_row": "Välj faktura för betalfil", "in_batch_chip": "I betalfil", - "payment_files_link": "Betalfiler" + "payment_files_link": "Betalfiler", + "section_awaiting": "Väntar på betalning ({count})", + "section_settled": "Betalda och avslutade ({count})", + "group_picker_aria": "Gruppera leverantörsfakturor", + "group_by": "Gruppera", + "group_status": "Status", + "group_supplier": "Leverantör", + "group_month": "Månad", + "group_none": "Ingen gruppering", + "group_unknown": "Saknas" }, "supplier_payment_files": { "dialog_title": "Skapa betalfil",