feat(invoices): grouped sections and a grouping picker on the customer invoice list (#2101)
* feat(invoices): grouped sections and a grouping picker on the customer invoice list Default view groups rows into Utkast / Väntar på betalning / Betalda och avslutade sections; a toolbar picker switches grouping to customer, month, or none, written back to the URL as ?group= so views stay shareable. Column sorting applies within each section and cycles asc / desc / default so an applied sort can be released; paging and the detail pager follow the rendered group order. Both message catalogs carry the new strings. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: group by customer_id, not display name (review) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): address review: shared bucketing helper, flat default, no #2093 revert - keep CHECKBOX_REVEAL_CLASS and useRangeSelect from main: the PR had re-inlined the old hidden-until-hover classes, reverting #2093, and the same region now carries #2117's shift-click range selection - default the grouping picker to 'none': sections on the most-used list page are a design change, so grouping stays an explicit choice - extract the ~90 lines of bucketing into lib/lists/group-rows.ts, a pure helper with vitest coverage that both list pages now render from - derive statusGroupOf from matchesListTab so the sections and the tabs cannot drift apart - replace the banned em dash placeholders with an UNKNOWN_GROUP_KEY sentinel and a translated 'Saknas' label - section headers carry data-no-stagger so they skip the row animation Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(invoices): flat is the URL-less default; drop the sort cycle (review) Picking Status stripped the group param while the initialiser falls back to the flat list, so the choice was lost on reload; now only 'none' owns the URL-less state. The header sort returns to main's two-state toggle (DECISIONS 2026-08-11). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014XUxGhBBwQSMu59bWq6Vrf --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
This commit is contained in:
co-authored by
Claude Opus 5
Jakob Wennberg
parent
4eb1626129
commit
304b50b5eb
@@ -1,11 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useMemo, useRef } from 'react'
|
||||
import { Fragment, useState, useEffect, useMemo, useRef } from 'react'
|
||||
import { useCompanySettings } from '@/lib/reference-data/hooks'
|
||||
import { groupRows, ungrouped } from '@/lib/lists/group-rows'
|
||||
import dynamic from 'next/dynamic'
|
||||
import Link from 'next/link'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -131,6 +132,31 @@ function matchesListTab(invoice: Invoice, tab: ListTab): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
// Row grouping: sections in the table body. 'none' (the flat list) is the
|
||||
// default; the other modes are opt-in via ?group= and mirror the
|
||||
// supplier-invoices layout (payment queue as its own section).
|
||||
const GROUP_MODES = ['status', 'customer', 'month', 'none'] as const
|
||||
type GroupMode = (typeof GROUP_MODES)[number]
|
||||
const STATUS_SECTION_ORDER = ['drafts', 'awaiting', 'settled'] as const
|
||||
/** Sentinel bucket for rows with no customer or no date. Not a display
|
||||
* string: the header renders t('group_unknown') for it. */
|
||||
const UNKNOWN_GROUP_KEY = 'unknown'
|
||||
const GROUP_LABEL_KEYS: Record<GroupMode, string> = {
|
||||
status: 'group_status',
|
||||
customer: 'group_customer',
|
||||
month: 'group_month',
|
||||
none: 'group_none',
|
||||
}
|
||||
|
||||
type StatusGroup = 'drafts' | 'awaiting' | 'settled'
|
||||
/** Sections reuse the tab predicates so the two can never drift: what the
|
||||
* Utkast and Obetalda tabs show is what the sections bucket. */
|
||||
function statusGroupOf(invoice: Invoice): StatusGroup {
|
||||
if (matchesListTab(invoice, 'draft')) return 'drafts'
|
||||
if (matchesListTab(invoice, 'unpaid')) return 'awaiting'
|
||||
return 'settled'
|
||||
}
|
||||
|
||||
const TAB_LABEL_KEYS: Record<ListTab, string> = {
|
||||
all: 'tab_all',
|
||||
unpaid: 'tab_unpaid',
|
||||
@@ -233,6 +259,10 @@ export default function InvoicesPage() {
|
||||
? (candidate as ListTab)
|
||||
: 'all'
|
||||
})
|
||||
const [groupMode, setGroupMode] = useState<GroupMode>(() => {
|
||||
const param = searchParams.get('group')
|
||||
return param && GROUP_MODES.includes(param as never) ? (param as GroupMode) : 'none'
|
||||
})
|
||||
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_ROWS)
|
||||
// Fiscal-year scope (convention 8): null = all years.
|
||||
const [fyPeriodId, setFyPeriodId] = useState<string | null>(null)
|
||||
@@ -240,6 +270,7 @@ export default function InvoicesPage() {
|
||||
const { toast } = useToast()
|
||||
const supabase = createClient()
|
||||
const t = useTranslations('invoices')
|
||||
const locale = useLocale()
|
||||
const tCommon = useTranslations('common')
|
||||
const tStart = useTranslations('start_cards')
|
||||
const { uiState, loaded: uiStateLoaded } = useUiState()
|
||||
@@ -409,13 +440,69 @@ export default function InvoicesPage() {
|
||||
() => (sort ? sortInvoiceList(filteredInvoices, sort, oreRounding) : filteredInvoices),
|
||||
[filteredInvoices, oreRounding, sort],
|
||||
)
|
||||
const visibleInvoices = sortedInvoices.slice(0, visibleCount)
|
||||
// Grouping: bucket the sorted rows through the shared helper, then flatten
|
||||
// back to one list so paging, range selection and the detail pager walk the
|
||||
// exact rendered order. Sorting stays above; the helper only sections.
|
||||
const { flatRows, groupMeta } = useMemo(() => {
|
||||
if (groupMode === 'none') {
|
||||
const flat = ungrouped(sortedInvoices)
|
||||
return { flatRows: flat.rows, groupMeta: flat.meta }
|
||||
}
|
||||
const grouped =
|
||||
groupMode === 'status'
|
||||
? groupRows(sortedInvoices, {
|
||||
keyOf: (invoice) => ({ key: statusGroupOf(invoice), label: statusGroupOf(invoice) }),
|
||||
order: STATUS_SECTION_ORDER,
|
||||
})
|
||||
: groupMode === 'customer'
|
||||
? groupRows(sortedInvoices, {
|
||||
keyOf: (invoice) => {
|
||||
const label = (invoice.customer as { name: string })?.name ?? UNKNOWN_GROUP_KEY
|
||||
// Bucket by id, not display name: two customers can share one.
|
||||
return { key: invoice.customer_id ?? label, label }
|
||||
},
|
||||
order: (a, b) => a.label.localeCompare(b.label, 'sv'),
|
||||
})
|
||||
: groupRows(sortedInvoices, {
|
||||
keyOf: (invoice) => {
|
||||
const key = (invoice.invoice_date ?? '').slice(0, 7) || UNKNOWN_GROUP_KEY
|
||||
return { key, label: key }
|
||||
},
|
||||
order: (a, b) => b.key.localeCompare(a.key),
|
||||
})
|
||||
return { flatRows: grouped.rows, groupMeta: grouped.meta }
|
||||
}, [groupMode, sortedInvoices])
|
||||
|
||||
// Detail-pager context: the FULL sorted list (not the visible slice), so
|
||||
const visibleRows = flatRows.slice(0, visibleCount)
|
||||
// Status sections only earn headers when there is more than one of them;
|
||||
// customer/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') {
|
||||
if (key === 'drafts') return t('section_drafts', { count })
|
||||
if (key === 'awaiting') return t('section_awaiting', { count })
|
||||
return 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})`
|
||||
}
|
||||
|
||||
// Detail-pager context: the FULL grouped list (not the visible slice), so
|
||||
// prev/next on the detail page can walk past the paging boundary.
|
||||
const rememberListContext = () => {
|
||||
writeListContext(listContextKey('invoices', company?.id), {
|
||||
ids: sortedInvoices.map((invoice) => invoice.id),
|
||||
ids: flatRows.map((entry) => entry.row.id),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -454,6 +541,18 @@ export default function InvoicesPage() {
|
||||
router.replace(qs ? `/invoices?${qs}` : '/invoices', { scroll: false })
|
||||
}
|
||||
|
||||
const updateGroup = (mode: GroupMode) => {
|
||||
setGroupMode(mode)
|
||||
resetPaging()
|
||||
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 ? `/invoices?${qs}` : '/invoices', { scroll: false })
|
||||
}
|
||||
|
||||
// Bulk Bokför eligibility. Kontantmetoden books at payment, so no row is
|
||||
// selectable (the checkbox column is hidden entirely). Accrual companies
|
||||
// that book at issue select drafts ("Bokför och markera som skickade");
|
||||
@@ -478,7 +577,9 @@ export default function InvoicesPage() {
|
||||
// Ranges walk the selectable rows that are actually rendered: the list is
|
||||
// sorted and cut at visibleCount, so rows below the fold are not in range.
|
||||
const range = useRangeSelect({
|
||||
visibleIds: visibleInvoices.filter(isBulkSelectable).map((inv) => inv.id),
|
||||
visibleIds: visibleRows
|
||||
.filter((entry) => isBulkSelectable(entry.row))
|
||||
.map((entry) => entry.row.id),
|
||||
selectedIds,
|
||||
setSelectedIds,
|
||||
})
|
||||
@@ -693,6 +794,16 @@ export default function InvoicesPage() {
|
||||
annotation: tabCounts[tab] > 0 ? String(tabCounts[tab]) : undefined,
|
||||
}))}
|
||||
/>
|
||||
<ContextPicker
|
||||
value={groupMode}
|
||||
onChange={(id) => 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]),
|
||||
}))}
|
||||
/>
|
||||
<ToolbarSearch
|
||||
containerClassName="min-w-[190px]"
|
||||
placeholder={t('search_placeholder')}
|
||||
@@ -849,7 +960,9 @@ export default function InvoicesPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="stagger-enter">
|
||||
{visibleInvoices.map((invoice) => {
|
||||
{visibleRows.map(({ row: invoice, groupKey }, index) => {
|
||||
const prevKey = index > 0 ? visibleRows[index - 1].groupKey : undefined
|
||||
const showHeader = showGroupHeaders && groupKey !== null && groupKey !== prevKey
|
||||
const status = statusDescriptor(invoice)
|
||||
const isCreditNote = !!invoice.credited_invoice_id
|
||||
const docType = (invoice as Invoice & { document_type?: string }).document_type || 'invoice'
|
||||
@@ -874,8 +987,21 @@ export default function InvoicesPage() {
|
||||
: null
|
||||
: null
|
||||
return (
|
||||
<Fragment key={invoice.id}>
|
||||
{showHeader && (
|
||||
<tr data-no-stagger>
|
||||
<td
|
||||
colSpan={showSelection ? 6 : 5}
|
||||
className={cn(
|
||||
'border-b border-border px-1 pb-1.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground',
|
||||
index === 0 ? 'pt-4' : 'pt-6',
|
||||
)}
|
||||
>
|
||||
{groupHeaderLabel(groupKey)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr
|
||||
key={invoice.id}
|
||||
className={cn(
|
||||
'group cursor-pointer transition-colors duration-150 hover:bg-secondary/35',
|
||||
selectedIds.has(invoice.id) && 'bg-secondary/40',
|
||||
@@ -960,6 +1086,7 @@ export default function InvoicesPage() {
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
@@ -967,7 +1094,7 @@ export default function InvoicesPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && visibleCount < sortedInvoices.length && (
|
||||
{!isLoading && visibleCount < flatRows.length && (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { groupRows, ungrouped } from '../group-rows'
|
||||
|
||||
interface Row {
|
||||
id: string
|
||||
customerId: string | null
|
||||
customerName: string
|
||||
month: string
|
||||
}
|
||||
|
||||
const rows: Row[] = [
|
||||
{ id: 'a', customerId: 'c1', customerName: 'Ådalen AB', month: '2026-08' },
|
||||
{ id: 'b', customerId: 'c2', customerName: 'Bolag AB', month: '2026-09' },
|
||||
{ id: 'c', customerId: 'c1', customerName: 'Ådalen AB', month: '2026-09' },
|
||||
{ id: 'd', customerId: 'c3', customerName: 'Ådalen AB', month: '2026-08' },
|
||||
]
|
||||
|
||||
const byCustomer = {
|
||||
keyOf: (row: Row) => ({ key: row.customerId ?? row.customerName, label: row.customerName }),
|
||||
order: (a: { label: string }, b: { label: string }) => a.label.localeCompare(b.label, 'sv'),
|
||||
}
|
||||
|
||||
describe('ungrouped', () => {
|
||||
it('keeps the order and marks every row keyless', () => {
|
||||
const result = ungrouped(rows)
|
||||
expect(result.rows.map((r) => r.row.id)).toEqual(['a', 'b', 'c', 'd'])
|
||||
expect(result.rows.every((r) => r.groupKey === null)).toBe(true)
|
||||
expect(result.meta.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('groupRows', () => {
|
||||
it('buckets by key and preserves the incoming order inside each bucket', () => {
|
||||
const { rows: flat, meta } = groupRows(rows, byCustomer)
|
||||
|
||||
// Sections ordered by label (sv collation puts Å last), rows keep a-c / b / d order.
|
||||
expect(flat.map((r) => r.row.id)).toEqual(['b', 'a', 'c', 'd'])
|
||||
expect([...meta.keys()]).toEqual(['c2', 'c1', 'c3'])
|
||||
expect(meta.get('c1')).toEqual({ label: 'Ådalen AB', count: 2 })
|
||||
})
|
||||
|
||||
it('separates two customers sharing a display name', () => {
|
||||
const { meta } = groupRows(rows, byCustomer)
|
||||
expect(meta.get('c1')?.label).toBe('Ådalen AB')
|
||||
expect(meta.get('c3')?.label).toBe('Ådalen AB')
|
||||
expect(meta.get('c1')?.count).toBe(2)
|
||||
expect(meta.get('c3')?.count).toBe(1)
|
||||
})
|
||||
|
||||
it('follows a fixed order and drops keys it does not list', () => {
|
||||
const { rows: flat, meta } = groupRows(rows, {
|
||||
keyOf: (row) => ({ key: row.month, label: row.month }),
|
||||
order: ['2026-09', '2026-07'],
|
||||
})
|
||||
expect([...meta.keys()]).toEqual(['2026-09'])
|
||||
expect(flat.map((r) => r.row.id)).toEqual(['b', 'c'])
|
||||
})
|
||||
|
||||
it('every flattened row carries the key of its section', () => {
|
||||
const { rows: flat } = groupRows(rows, byCustomer)
|
||||
for (const entry of flat) {
|
||||
expect(entry.groupKey).toBe(entry.row.customerId)
|
||||
}
|
||||
})
|
||||
|
||||
it('handles an empty list', () => {
|
||||
const { rows: flat, meta } = groupRows([], byCustomer)
|
||||
expect(flat).toEqual([])
|
||||
expect(meta.size).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Bucket an already-sorted list into labelled sections and flatten it back
|
||||
* into one array, so paging, range selection and the detail pager all walk
|
||||
* the exact order the table renders.
|
||||
*
|
||||
* Sorting stays the caller's job: rows arrive sorted and keep that order
|
||||
* inside every bucket. This only decides which bucket a row belongs to, what
|
||||
* the section is called, and in which order the sections appear.
|
||||
*/
|
||||
|
||||
export interface GroupedRow<T> {
|
||||
row: T
|
||||
/** null when grouping is off: the caller renders one flat list. */
|
||||
groupKey: string | null
|
||||
}
|
||||
|
||||
export interface GroupMeta {
|
||||
label: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface GroupRowsResult<T> {
|
||||
rows: GroupedRow<T>[]
|
||||
meta: Map<string, GroupMeta>
|
||||
}
|
||||
|
||||
export interface GroupRowsOptions<T> {
|
||||
/** Bucket identity plus its display label. Bucket by id, never by a name:
|
||||
* two customers can share one. */
|
||||
keyOf: (row: T) => { key: string; label: string }
|
||||
/**
|
||||
* Section order. A fixed array pins a semantic order (status sections);
|
||||
* a comparator sorts the keys that actually occurred (customer by label,
|
||||
* month descending). Keys missing from a fixed array are dropped, so it
|
||||
* doubles as a whitelist.
|
||||
*/
|
||||
order: readonly string[] | ((a: GroupMeta & { key: string }, b: GroupMeta & { key: string }) => number)
|
||||
}
|
||||
|
||||
/** Grouping off: every row in one flat section with no key. */
|
||||
export function ungrouped<T>(rows: readonly T[]): GroupRowsResult<T> {
|
||||
return { rows: rows.map((row) => ({ row, groupKey: null })), meta: new Map() }
|
||||
}
|
||||
|
||||
export function groupRows<T>(rows: readonly T[], options: GroupRowsOptions<T>): GroupRowsResult<T> {
|
||||
const buckets = new Map<string, { label: string; rows: T[] }>()
|
||||
for (const row of rows) {
|
||||
const { key, label } = options.keyOf(row)
|
||||
const bucket = buckets.get(key) ?? { label, rows: [] }
|
||||
bucket.rows.push(row)
|
||||
buckets.set(key, bucket)
|
||||
}
|
||||
|
||||
const keys = Array.isArray(options.order)
|
||||
? (options.order as readonly string[]).filter((key) => buckets.has(key))
|
||||
: [...buckets.keys()].sort((a, b) => {
|
||||
const compare = options.order as Exclude<GroupRowsOptions<T>['order'], readonly string[]>
|
||||
return compare(
|
||||
{ key: a, label: buckets.get(a)!.label, count: buckets.get(a)!.rows.length },
|
||||
{ key: b, label: buckets.get(b)!.label, count: buckets.get(b)!.rows.length },
|
||||
)
|
||||
})
|
||||
|
||||
const flat: GroupedRow<T>[] = []
|
||||
const meta = new Map<string, GroupMeta>()
|
||||
for (const key of keys) {
|
||||
const bucket = buckets.get(key)!
|
||||
meta.set(key, { label: bucket.label, count: bucket.rows.length })
|
||||
for (const row of bucket.rows) flat.push({ row, groupKey: key })
|
||||
}
|
||||
return { rows: flat, meta }
|
||||
}
|
||||
@@ -6568,6 +6568,16 @@
|
||||
"status_overdue_days": "Overdue {days} d",
|
||||
"status_paid_date": "Paid {date}",
|
||||
"status_picker_aria": "Filter by status",
|
||||
"group_picker_aria": "Group invoices",
|
||||
"group_by": "Group",
|
||||
"group_status": "Status",
|
||||
"group_customer": "Customer",
|
||||
"group_month": "Month",
|
||||
"group_none": "No grouping",
|
||||
"group_unknown": "Missing",
|
||||
"section_drafts": "Drafts ({count})",
|
||||
"section_awaiting": "Awaiting payment ({count})",
|
||||
"section_settled": "Paid and settled ({count})",
|
||||
"coverage_notice": "Invoices before {date} may exist only as posted journal entries and are not shown in this list."
|
||||
},
|
||||
"notices": {
|
||||
|
||||
@@ -6568,6 +6568,16 @@
|
||||
"status_overdue_days": "Förfallen {days} dgr",
|
||||
"status_paid_date": "Betald {date}",
|
||||
"status_picker_aria": "Filtrera på status",
|
||||
"group_picker_aria": "Gruppera fakturor",
|
||||
"group_by": "Gruppera",
|
||||
"group_status": "Status",
|
||||
"group_customer": "Kund",
|
||||
"group_month": "Månad",
|
||||
"group_none": "Ingen gruppering",
|
||||
"group_unknown": "Saknas",
|
||||
"section_drafts": "Utkast ({count})",
|
||||
"section_awaiting": "Väntar på betalning ({count})",
|
||||
"section_settled": "Betalda och avslutade ({count})",
|
||||
"coverage_notice": "Fakturor före {date} kan ligga som bokförda verifikat och visas inte i den här listan."
|
||||
},
|
||||
"notices": {
|
||||
|
||||
Reference in New Issue
Block a user