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:
Joakim Hansson
2026-09-04 16:44:49 +02:00
committed by GitHub
co-authored by Claude Opus 5 Jakob Wennberg
parent 4eb1626129
commit 304b50b5eb
5 changed files with 299 additions and 9 deletions
+71
View File
@@ -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)
})
})
+72
View File
@@ -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 }
}