304b50b5eb
* 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>
73 lines
2.6 KiB
TypeScript
73 lines
2.6 KiB
TypeScript
/**
|
|
* 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 }
|
|
}
|