feat(invoices): ROT/RUT begäran status as a column and filter in the invoice list (#2434)

An invoice's begäran state (Att begära, Skapad, Uppladdad, Beviljad, Delvis
beviljad, Avslagen) was only visible one invoice at a time or inside the
payout dialog. The list now embeds the begäran behind each invoice, shows
the state in a ROT/RUT column and filters on it through a third
ContextPicker (?rotrut=), both gated on rot_rut_enabled or an invoiced
deduction. One predicate (lib/invoices/rot-rut-list-status.ts) feeds the
column, the filter and its counts. Normal states read as muted text; only
a partial approval and an avslag get a chip.

Closes #2426


Claude-Session: https://claude.ai/code/session_0179bdetHyofL6ATfQxB5wP5

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-08 20:56:52 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5.1
parent 9782f80db0
commit d5373cd66c
7 changed files with 353 additions and 4 deletions
+113 -4
View File
@@ -51,6 +51,15 @@ import {
type InvoiceListSortColumn,
} from '@/lib/invoices/invoice-list-sort'
import { invoiceRowTone, type InvoiceRowTone } from '@/lib/invoices/invoice-list-row-tone'
import {
ROT_RUT_LIST_FILTERS,
matchesRotRutListFilter,
parseRotRutListFilter,
rotRutListStateOf,
type RotRutListFilter,
type RotRutListItem,
type RotRutListState,
} from '@/lib/invoices/rot-rut-list-status'
import { listContextKey, writeListContext } from '@/lib/navigation/list-context'
import {
ArrowDown,
@@ -166,6 +175,31 @@ const TAB_LABEL_KEYS: Record<ListTab, string> = {
cancelled: 'tab_cancelled',
}
/** A list row: the invoice plus the begäran embed the ROT/RUT column reads. */
type ListInvoice = Invoice & { rot_rut_items?: RotRutListItem[] | null }
const ROT_RUT_STATE_LABEL_KEYS: Record<NonNullable<RotRutListState>, string> = {
claimable: 'rot_rut_status_claimable',
generated: 'rot_rut_status_generated',
submitted: 'rot_rut_status_submitted',
paid: 'rot_rut_status_paid',
partially_paid: 'rot_rut_status_partially_paid',
rejected: 'rot_rut_status_rejected',
cancelled: 'rot_rut_status_cancelled',
}
const rotRutFilterLabelKey = (filter: RotRutListFilter): string =>
filter === 'all' ? 'rot_rut_filter_all' : ROT_RUT_STATE_LABEL_KEYS[filter]
/** Chips mark exceptions (convention 5): a begäran on its way or approved is
* the normal state of a ROT/RUT invoice and reads as muted text; only a
* partial approval and an avslag deviate. */
const ROT_RUT_EXCEPTION_VARIANT: Partial<
Record<NonNullable<RotRutListState>, RowStatusDescriptor['variant']>
> = {
partially_paid: 'warning',
rejected: 'destructive',
}
function daysOverdue(dueDateStr: string): number {
const today = new Date()
today.setHours(0, 0, 0, 0)
@@ -228,7 +262,7 @@ export default function InvoicesPage() {
const { canWrite } = useCanWrite()
const router = useRouter()
const searchParams = useSearchParams()
const [invoices, setInvoices] = useState<Invoice[]>([])
const [invoices, setInvoices] = useState<ListInvoice[]>([])
// Settings-driven gates from the session-cached settings row
// (lib/reference-data), derived instead of copied into state.
const { settings: companySettings } = useCompanySettings()
@@ -255,6 +289,10 @@ export default function InvoicesPage() {
const param = searchParams.get('group')
return param && GROUP_MODES.includes(param as never) ? (param as GroupMode) : 'none'
})
// ROT/RUT begäran filter (#2426), ?rotrut=. 'all' is the URL-less default.
const [rotRutFilter, setRotRutFilter] = useState<RotRutListFilter>(
() => parseRotRutListFilter(searchParams.get('rotrut')) ?? 'all',
)
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_ROWS)
// Fiscal-year scope (convention 8): null = all years.
const [fyPeriodId, setFyPeriodId] = useState<string | null>(null)
@@ -341,6 +379,9 @@ export default function InvoicesPage() {
// ones. ?rot-rut=1 keeps working regardless, so nothing is unreachable.
const showRotRutAction =
rotRutEnabled || invoices.some((invoice) => (invoice.deduction_total ?? 0) > 0)
// The begäran column and its filter share that gate: a company without
// ROT/RUT never sees an empty column or a picker with nothing to pick.
const showRotRut = showRotRutAction
// Invoice-register coverage (see lib/invoices/invoice-register-coverage.ts):
// a migrated or backfilled company has invoices that live only as verifikat,
@@ -378,7 +419,11 @@ export default function InvoicesPage() {
({ from, to }) =>
supabase
.from('invoices')
.select('*, customer:customers(name)')
// The begäran embed feeds the ROT/RUT column and filter; the
// items index on invoice_id keeps the reverse join cheap.
.select(
'*, customer:customers(name), rot_rut_items:rot_rut_payout_request_items(request:rot_rut_payout_requests(id, status, created_at))',
)
.eq('company_id', company.id)
.order('invoice_date', { ascending: false })
.order('id', { ascending: false })
@@ -408,7 +453,7 @@ export default function InvoicesPage() {
// Search + fiscal-year scope, before the status view: the per-view counts
// in the ContextPicker are computed on this same base so they always equal
// the rows the view would show.
const scopedInvoices = useMemo(
const searchScopedInvoices = useMemo(
() =>
invoices.filter((invoice) => {
const matchesSearch = matchesInvoiceSearch(invoice, normalizedSearch)
@@ -422,6 +467,15 @@ export default function InvoicesPage() {
}),
[fyPeriod, invoices, normalizedSearch],
)
// The ROT/RUT filter sits between search/FY and the status view, so the
// status counts honour it while its own counts are taken one level up.
const scopedInvoices = useMemo(
() =>
rotRutFilter === 'all'
? searchScopedInvoices
: searchScopedInvoices.filter((invoice) => matchesRotRutListFilter(invoice, rotRutFilter)),
[rotRutFilter, searchScopedInvoices],
)
const filteredInvoices = useMemo(
() => scopedInvoices.filter((invoice) => matchesListTab(invoice, activeTab)),
[activeTab, scopedInvoices],
@@ -507,6 +561,19 @@ export default function InvoicesPage() {
return counts
}, [scopedInvoices])
const rotRutCounts = useMemo(() => {
const counts = Object.fromEntries(ROT_RUT_LIST_FILTERS.map((f) => [f, 0])) as Record<
RotRutListFilter,
number
>
for (const invoice of searchScopedInvoices) {
const state = rotRutListStateOf(invoice)
counts.all += 1
if (state && state in counts) counts[state as RotRutListFilter] += 1
}
return counts
}, [searchScopedInvoices])
const resetPaging = () => setVisibleCount(INITIAL_VISIBLE_ROWS)
const updateSort = (column: InvoiceListSortColumn) => {
@@ -544,6 +611,16 @@ export default function InvoicesPage() {
router.replace(qs ? `/invoices?${qs}` : '/invoices', { scroll: false })
}
const updateRotRut = (filter: RotRutListFilter) => {
setRotRutFilter(filter)
resetPaging()
const params = new URLSearchParams(searchParams.toString())
if (filter === 'all') params.delete('rotrut')
else params.set('rotrut', filter)
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");
@@ -736,6 +813,11 @@ export default function InvoicesPage() {
return { label: t('status_sent') }
}
function rotRutDescriptor(state: NonNullable<RotRutListState>): RowStatusDescriptor {
const variant = ROT_RUT_EXCEPTION_VARIANT[state]
return { label: t(ROT_RUT_STATE_LABEL_KEYS[state]), exception: variant !== undefined, variant }
}
return (
<div className="space-y-8">
{/* Page header (concept scene 15): title + invoice actions */}
@@ -793,6 +875,24 @@ export default function InvoicesPage() {
label: t(GROUP_LABEL_KEYS[mode]),
}))}
/>
{showRotRut && (
<ContextPicker
value={rotRutFilter}
onChange={(id) => updateRotRut(id as RotRutListFilter)}
ariaLabel={t('rot_rut_filter_aria')}
triggerLabel={
rotRutFilter !== 'all' && rotRutCounts[rotRutFilter] > 0
? `${t('rot_rut_filter_label')} · ${t(rotRutFilterLabelKey(rotRutFilter))} · ${rotRutCounts[rotRutFilter]}`
: `${t('rot_rut_filter_label')} · ${t(rotRutFilterLabelKey(rotRutFilter))}`
}
items={ROT_RUT_LIST_FILTERS.map((filter) => ({
id: filter,
label: t(rotRutFilterLabelKey(filter)),
annotation:
filter !== 'all' && rotRutCounts[filter] > 0 ? String(rotRutCounts[filter]) : undefined,
}))}
/>
)}
<ToolbarSearch
containerClassName="min-w-[190px]"
placeholder={t('search_placeholder')}
@@ -941,6 +1041,9 @@ export default function InvoicesPage() {
className="text-right"
align="right"
/>
{showRotRut && (
<th className={cn(TH_CLASS, 'whitespace-nowrap')}>{t('th_rot_rut')}</th>
)}
<SortableHeader
label={t('th_status')}
sortLabel={t('sort_by', { column: t('th_status') })}
@@ -955,6 +1058,7 @@ export default function InvoicesPage() {
const prevKey = index > 0 ? visibleRows[index - 1].groupKey : undefined
const showHeader = showGroupHeaders && groupKey !== null && groupKey !== prevKey
const status = statusDescriptor(invoice)
const rotRutState = showRotRut ? rotRutListStateOf(invoice) : null
const isCreditNote = !!invoice.credited_invoice_id
const docType = (invoice as Invoice & { document_type?: string }).document_type || 'invoice'
const displayedTotal = getDisplayTotal(
@@ -982,7 +1086,7 @@ export default function InvoicesPage() {
{showHeader && (
<tr data-no-stagger>
<td
colSpan={showSelection ? 6 : 5}
colSpan={(showSelection ? 6 : 5) + (showRotRut ? 1 : 0)}
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',
@@ -1067,6 +1171,11 @@ export default function InvoicesPage() {
>
{formatCurrency(displayedTotal, invoice.currency)}
</td>
{showRotRut && (
<td className={cn(TD_CLASS, 'whitespace-nowrap')}>
{rotRutState && <RowStatus status={rotRutDescriptor(rotRutState)} />}
</td>
)}
<td className={cn(TD_CLASS, 'whitespace-nowrap')}>
<span className="inline-flex items-center gap-1.5">
{typeMarker && (