Bookkeeping financial dashboard
bookkeeping/ui/bookkeeping-financial-dashboardlive
| Kind | frontend |
|---|---|
| Surface | SPA |
| Maturity | staging |
| Reusable | extract |
| Design system | alchemy-ui |
| Stack | react-19, react-router-dom-7, tailwindcss-3.4, recharts-3, lucide-react, axios-1 |
| Depends on | _shared/design-system-and-tokens, _shared/react-app-skeleton |
| Renders | bookkeeping/chart-of-accounts-and-double-entry-engine, bookkeeping/journal-entries-and-recurring-templates, bookkeeping/customer-invoicing-and-qr-bill-pdf, bookkeeping/vat-reporting-and-rates, bookkeeping/bank-statement-import-and-reconciliation, bookkeeping/financial-reports-and-ledger |
| Entry | alchemy_labs/frontend/src/pages/accounting/AccountingDashboard.tsx |
| Reference implementation |
|
| Measured on | alchemy_labs main 62ba4fc41009b454e1c033e5036c7e8577db2061 2026-09-12 |
| Version | 1 |
Purpose
The bookkeeping domain's home surface: a tenant opens /accounting and sees the current fiscal year's revenue, expenses and profit, a monthly revenue-versus-expenses chart, an expense breakdown by account, the open invoices still waiting on payment, an AI-written financial summary, and four quick actions into the rest of the module, all in one screen. The invariant that makes it trustworthy: the KPI numbers, the two charts and the invoice list are all a straight read of the backend's own aggregation (GET /api/accounting/dashboard-data, computed from posted and draft journal entry lines grouped by account type) and a straight read of the invoice list (GET /api/accounting/invoices, filtered client-side to sent/overdue), never a number this page computes from something else.
It makes visible five of this wave's six backend recipes: the chart of accounts and double-entry engine that every journal line's account type is checked against (chart-of-accounts-and-double-entry-engine), the journal entries whose debit/credit lines the monthly chart and the expense breakdown are summed from (journal-entries-and-recurring-templates), the invoices the open-invoices list and the "offene Rechnungen" KPI are read from (customer-invoicing-and-qr-bill-pdf), and, indirectly, the VAT and bank-reconciliation recipes (vat-reporting-and-rates, bank-statement-import-and-reconciliation) in that their postings flow into the same journal entries this page's totals are summed from without a dedicated card of their own. financial-reports-and-ledger is named in backend_modules for the profit-and-loss read the AI insight route reuses, but the dashboard-data route this page actually calls lives beside it in the same core accounting router, not inside that recipe, a seam this recipe's Gotchas records rather than glossing over. The Finanzanalyse card's own backend, POST /api/accounting/ai-insight (a Prima-routed call), is outside this wave's six backend recipes entirely: this page renders it as a loading-shimmer/populated block and nothing more, never claiming an insight generator this wave did not ship.
When to use
Trigger phrases: "the accounting dashboard", "Voila home", "the bookkeeping overview", "my revenue and expenses", "open invoices at a glance", "the Finanzanalyse card", "Buchhaltung Uebersicht". Surface: SPA, a private Labs page behind PrivateRoute, reached at /accounting (the "Overview" item under the "Money" domain, nav.tsx line 206 to 228 at fd8558f, marked adminOnly in the navigation model only, not enforced by the route or the backend, Security and tenancy).
Not this recipe: the eight sub-pages this dashboard's quick actions and card headers link out to (/accounting/kontenplan, /buchungen, /rechnungen, /belege, /berichte, /kapital, /einstellungen, /bankabgleich, each its own component and route, App.tsx line 83 to 91 and 529 to 537), none of them rendered here; the AI insight generator itself (POST /api/accounting/ai-insight, a Prima-routed call outside this wave's six backend recipes, Purpose); and the six backend recipes this page is a window onto (backend_modules above), none of which a tenant reaches directly.
Stack assumptions
S1 tokens and S2 skeleton assumed; pinned per the source at 62ba4fc: react ^19.1.1, react-router-dom ^7.8.2, tailwindcss ^3.4.17, recharts ^3.3.0, lucide-react ^0.563.0, axios ^1.11.0. Component library: none directly. Unlike most of the rest of Labs, this file imports no @prima-modules/alchemy-ui atom at all; every card, button and badge is a hand-styled div/button from two page-local files, i18n/accounting.ts (translations, formatMoney, formatMonth) and components/accounting/shared.tsx (KpiCard, PageHeader, StatusBadge, MoneyDisplay, DataTable, AmountInput, AccountSelector, DateDisplay). rendering.design_system is still alchemy-ui here, not because this file uses its atoms, but because it renders inside Navigation = AppShell, which is alchemy-ui-based, and because the ink/ink-60/ink-40/line/purple classes this file's own markup uses are the same @prima-modules/tokens layer alchemy-ui bundles (both generated from frontend/tailwind.config.js, decision D1a); a page that never imports an atom from the library it is still built on the tokens of is itself worth a line, not silently smoothed over (Gotchas). Data-fetch style: the api singleton (S2), the Bearer header from accessToken plus X-Workspace-ID from currentWorkspaceId in localStorage (services/api.ts line 32 to 43), unlike billing-credits/ui/subscription-manager's user-only routes, this page's routes are workspace-scoped (WorkspaceID dependency, Security and tenancy). Copy language: German, de-CH, every string on the page (the AI insight text itself is server-generated German too, per its own prompt, accounting_router.py line 215 to 227).
Data model
Client state (AccountingDashboard.tsx line 50 to 54), all in the page component, nothing persisted beyond S2's tokens and currentWorkspaceId:
interface DashboardData { // GET /api/accounting/dashboard-data's body, unchanged
monthly: { month: string; revenue: number; expenses: number }[]; // month as "YYYY-MM"
expense_by_account: { name: string; value: number }[]; // top 5 by absolute value plus "Uebriges"
kpi: {
revenue: number; expenses: number; profit: number;
open_invoices_count: number; open_invoices_amount: number;
pending_review: number; // sourced from AccountingExpense, not this page's own read (Gotchas)
};
period: { start: string; end: string; fiscal_year: string } | null; // null when the workspace has no fiscal year yet
}
dashData: DashboardData | null; insight: string; insightLoading: boolean (initially true);
dataLoading: boolean (initially true); openInvoices: Invoice[] (initially []);
The demo's props (demo.tsx): { viewer, workspace, dataLoading, period, kpi, monthly, expenseByAccount, invoices, insightLoading, insight }. invoices is deliberately the unfiltered list, the same shape listInvoices() returns; the demo's Dashboard component keeps only sent/overdue itself (AccountingDashboard.tsx line 65, Data model above), so the same client-side filter the source runs is real code in the demo, not flattened away into a prop the recipe would otherwise be claiming for free.
Browser storage: nothing of its own, relies on S2's accessToken/refreshToken and currentWorkspaceId in localStorage; voila_lang (i18n/accounting.ts line 493 to 498) is a second, page-suite-wide key this page's own t() calls read but never write (no language switch control exists anywhere in this file).
API surface
Contracts consumed, two reads on mount plus one independent read:
| Call (line) | Route | Auth / tenancy | Parsed | Rendered as |
|---|---|---|---|---|
accountingApi.dashboardData() (60) | GET /api/accounting/dashboard-data | ActiveUser + WorkspaceID (header or single-workspace fallback) | the whole body into dashData, or null on any throw (.catch(() => ({data: null})), no error surfaced) | the header's fiscal-year line, all four KPI cards, both charts |
accountingApi.listInvoices() (61) | GET /api/accounting/invoices | ActiveUser + WorkspaceID | the array, or [] on any throw, filtered client-side to status === 'sent' || status === 'overdue' (65) | the open-invoices list, capped to the first 5 (281) |
accountingApi.aiInsight() (75) | POST /api/accounting/ai-insight | ActiveUser + WorkspaceID | response.data.insight, or the fixed sentence "Finanzanalyse konnte nicht generiert werden." on any throw (78) | the Finanzanalyse card, in place of the shimmer |
Error handling is unlike billing-credits/ui/subscription-manager's: this page never reads err.response?.data?.detail or .error at all. Every one of the three calls above is individually .catch()-guarded to a safe default (null, [], the fixed sentence), so a genuinely failed read and a genuinely empty workspace render identically, an Invariant below, not merely a Gotcha. No WebSocket, no postMessage.
Application logic
AccountingDashboardmounts withdataLoading: true,insightLoading: true,dashData: null,insight: ''; oneuseEffectfires bothfetchDashboard()andfetchInsight()at once, independently (line 83 to 86). WhiledataLoading && !dashData, the page returns<Navigation><div class="h-64"><spinner/></div></Navigation>, so only the whole content area is a spinner, nothing else renders yet, not even the header (line 96 to 104).fetchDashboardrunsdashboardData()andlistInvoices()in parallel (Promise.all, line 59 to 62), each individually caught; it stores the dashboard body asdashDataand the invoices filtered tosent/overdueasopenInvoices;dataLoadinggoes false either way (line 56 to 70).fetchInsightruns independently of step 2 and on its own timer; it can resolve before, after, or (typically) after the dashboard read, since the dashboard-data query does more work server-side. While it is in flight, the Finanzanalyse card shows five pulsing bars instead of text, regardless of whether the rest of the page has already painted (insight-generatingstate);insightLoadinggoes false either way, the fixed failure sentence standing in for a genuine error the same way the three reads above do (line 72 to 81).monthlyandperiodare derived, not stored twice:monthlymapsdashData.monthlythroughformatMonthat render time (line 89 to 92); anullperiod(no fiscal year yet) suppresses the header's second line and, because the same backend early return also yields emptymonthly/expense_by_accountand a zeroedkpi, drives every "no data yet" branch on the page at once (empty-workspacestate, Data model,accounting_router.pyline 71 to 81).- The "Neue Analyse" button re-runs
fetchInsight()only; it does not touchdashDataoropenInvoices(line 128 to 135). - Each of the four quick actions, the pending-review alert and each invoice row calls
navigate(path)on click (line 160 to 168, 200 to 210, 282 to 285); none of them mutate anything, they only change route. - The pending-review alert renders only when
kpi.pending_review > 0, with singular/plural copy ("Beleg" vs "Belege", line 206 to 208);pending_reviewis a count ofAccountingExpenserows withneeds_review = trueandstatus = pending, a model this page's own sixbackend_modulesdo not include (Data model, Gotchas), so this one KPI is sourced from a domain adjacent to, not covered by, this wave.
Invariants
- A failed read and an empty workspace look the same: all three reads (
dashboardData,listInvoices,aiInsight) are individually caught to a safe default with no error ever surfaced to the screen, so there is no way for a tenant, or for this recipe's own states, to tell "the reports read is down" from "this workspace genuinely has no fiscal year yet" (empty-workspacecovers both). - The balance shown is the balance summed server-side:
revenue,expensesandprofitare never computed in this component; they arrive pre-summed inkpifrom the same aggregation query that producesmonthly(accounting_router.pyline 296 to 306), so the KPI cards and the bar chart cannot drift from each other on this page, only from whatever the backend computed. - The insight card's own failure mode is a sentence, never a blank:
fetchInsight's catch setsinsightto a fixed fallback string rather than leaving it empty, so the card always shows either the shimmer, a real analysis, or that one sentence, never nothing (line 77 to 79). pending_reviewand the open-invoices list are independent signals that happen to render on the same screen: one counts receipts needing review (a domain outside this wave's six recipes), the other lists unpaid invoices (customer-invoicing-and-qr-bill-pdf); nothing on this page implies a causal link between the two, even though both read as "something needs attention".
Frontend
The component, rebuilt from the demo in the kit with the design system the recipe names, one frame per state. What is in the frames is what the recipe describes and what Primacode builds from; it is not a picture of the running application.
loading server rendered, hydrated in view default server rendered, hydrated in view insight-generating server rendered, hydrated in view pending-review-alert server rendered, hydrated in view empty-workspace server rendered, hydrated in view bookkeeping/ui/bookkeeping-financial-dashboard/demo.tsx
/**
* Live demo for bookkeeping/ui/bookkeeping-financial-dashboard.
*
* Labs' AccountingDashboard.tsx (Voila, main, 62ba4fc, 306 lines) with its one
* combined read (dashboard-data plus a raw invoice list, fetched together on
* mount, lines 56 to 70) and its second, independent read (the AI insight,
* lines 72 to 81) replaced by props: `dataLoading` for the page's own single
* loading gate, `period`/`kpi`/`monthly`/`expenseByAccount` for the reports
* read, `invoices` for the raw invoice list this component itself filters to
* open ones (line 65, kept as real logic below, not flattened into a prop),
* and `insightLoading`/`insight` for the independent Finanzanalyse fetch.
*
* What is kept byte-faithful: the header and its conditional fiscal-year
* line, the Finanzanalyse card's gradient, refresh button and five-bar
* shimmer, the quick-actions grid (icons, colors, labels, paths as copy),
* the four KPI cards, the pending-review alert and its singular/plural copy,
* the two chart cards' headers and empty-state sentences, the open-invoices
* list with its `slice(0, 5)` cap, and `formatMoney`/`formatMonth`, both
* reimplemented verbatim from `i18n/accounting.ts` (they are plain
* functions, not part of any package this demo may import from).
*
* What is different, and why:
* - `recharts` (`BarChart`, `PieChart`, line 7 to 10) is not one of the
* four packages a demo may import from (AUTHORING.md "The exports"), and
* the gate's esbuild bundle only externalizes react, react-dom,
* react-router-dom and lucide-react (tooling/verify/render-smoke.mjs),
* so bundling it would fail to resolve. `MiniBarChart` and
* `MiniDonutChart` below are a from-scratch inline-SVG rebuild: the bar
* chart keeps the real colors, opacity, rounded tops and the literal
* `#1E293B` dashed gridline and `#94A3B8` tick color (Gotchas, these are
* dark-theme slate tones sitting inside a white card, reproduced as
* measured); the donut keeps the 60/100 inner/outer radius ratio as a
* ring stroke, but its `${name} ${percent}%` labels (line 258) move from
* recharts' inline leader lines to a legend list below the ring, since
* leader-line placement needs the layout engine recharts provides and
* this demo has none. Neither chart's hover `Tooltip` (line 230, 264)
* is rendered; a static server-rendered frame has no hover to show it on.
* - `t()` (`i18n/accounting.ts`) is not one of the four packages either, so
* every `t('key')` call below is inlined to the literal German string
* the `de` dictionary returns at this commit (`copy_language: de-CH`,
* the kit ships one committed language, same as every other demo here).
* - `useNavigate` (line 6, 49) is dropped: the quick-action buttons, the
* pending-review alert and each invoice row keep their real onClick
* shape but call nothing, the same convention
* billing-credits/ui/subscription-manager's demo uses for the buttons
* that would otherwise leave the frame.
* - `StatusBadge` and `MoneyDisplay` (`components/accounting/shared.tsx`)
* are page-local, not part of the packages a demo may import from, so
* they are ported below rather than imported. `StatusBadge`'s label
* lookup is a real, measured bug and is reproduced, not fixed: see the
* comment on `StatusBadge` below and Gotchas.
*/
import React from 'react';
import { AlertCircle, ArrowRight, Calculator, DollarSign, FileText, Receipt, RefreshCw, Settings, Sparkles, TrendingDown, TrendingUp } from 'lucide-react';
import { defineStates } from '@prima-modules/demo';
import { Shell, type ShellUser, type ShellWorkspace } from '../../../ui-design-system/ui/appshell-navigation/demo';
import { DOMAINS } from '../../../ui-design-system/ui/appshell-navigation/nav';
/* ---- props --------------------------------------------------------------- */
const CHART_COLORS = ['#22C55E', '#3B82F6', '#F59E0B', '#EF4444', '#8B5CF6', '#6366F1'];
export interface DashboardKpi {
revenue: number;
expenses: number;
profit: number;
open_invoices_count: number;
open_invoices_amount: number;
/** Sourced from AccountingExpense.needs_review (the receipts/Belege domain), not from any of this wave's six backend recipes either (Purpose, Gotchas). */
pending_review: number;
}
export interface MonthlyPoint {
/** Raw "YYYY-MM", as the reports read returns it; formatMonth (below) turns it into the German abbreviation the source applies at render time (line 89 to 92). */
month: string;
revenue: number;
expenses: number;
}
export interface ExpenseSlice {
name: string;
value: number;
}
export interface FiscalPeriod {
start: string;
end: string;
fiscal_year: string;
}
/** The fields this page reads off an invoice; the full shape belongs to bookkeeping/customer-invoicing-and-qr-bill-pdf. */
export interface DashboardInvoice {
id: string;
invoice_number: string;
recipient_name: string;
status: 'draft' | 'sent' | 'paid' | 'overdue' | 'cancelled' | 'credited';
total_amount: number;
}
export interface Props {
viewer: ShellUser;
workspace: ShellWorkspace;
/** Gates the whole page on first mount only (line 96 to 104, `dataLoading && !dashData`); a later refetch never re-shows this. */
dataLoading: boolean;
/** Null when the workspace has no fiscal year yet, the dashboard-data read's own early return; kpi/monthly/expenseByAccount are then zero/empty too (empty-workspace state). */
period: FiscalPeriod | null;
kpi: DashboardKpi | null;
monthly: MonthlyPoint[];
expenseByAccount: ExpenseSlice[];
/** Unfiltered, as listInvoices() returns it; the page keeps only sent/overdue itself (line 65), reproduced in Dashboard below. */
invoices: DashboardInvoice[];
insightLoading: boolean;
insight: string;
}
/* ---- sample data ----------------------------------------------------------- */
const viewer: ShellUser = { name: 'Fabienne Roos', email: '[email protected]', isAdmin: true };
const workspace: ShellWorkspace = { id: 'ws_1', name: 'Atelier Nordwind', slug: 'atelier-nordwind' };
const period: FiscalPeriod = { start: '2026-01-01', end: '2026-12-31', fiscal_year: '2026' };
const monthly: MonthlyPoint[] = [
{ month: '2026-04', revenue: 18200, expenses: 11400 },
{ month: '2026-05', revenue: 21300, expenses: 12800 },
{ month: '2026-06', revenue: 19800, expenses: 13650 },
{ month: '2026-07', revenue: 23750, expenses: 14200 },
{ month: '2026-08', revenue: 20100, expenses: 11980 },
{ month: '2026-09', revenue: 25300, expenses: 12290 },
];
const expenseByAccount: ExpenseSlice[] = [
{ name: 'Löhne', value: 34500 },
{ name: 'Miete', value: 14800 },
{ name: 'Material', value: 9200 },
{ name: 'Versicherungen', value: 6100 },
{ name: 'Marketing', value: 4900 },
{ name: 'Übriges', value: 6820 },
];
const kpi: DashboardKpi = {
revenue: 128450,
expenses: 76320,
profit: 52130,
open_invoices_count: 6,
open_invoices_amount: 14840,
pending_review: 0,
};
/** 8 rows, only 6 of them sent/overdue, so the demo's own filter (mirroring line 65) and the list's slice(0, 5) cap both have something real to do. */
const invoices: DashboardInvoice[] = [
{ id: 'inv_1', invoice_number: 'RE-2026-041', recipient_name: 'Atelier Feingold', status: 'paid', total_amount: 1200 },
{ id: 'inv_2', invoice_number: 'RE-2026-042', recipient_name: 'Studio Nordlicht AG', status: 'sent', total_amount: 3200 },
{ id: 'inv_3', invoice_number: 'RE-2026-043', recipient_name: 'Café Lindenhof', status: 'overdue', total_amount: 890 },
{ id: 'inv_4', invoice_number: 'RE-2026-044', recipient_name: 'Bergbau Consulting GmbH', status: 'sent', total_amount: 5400 },
{ id: 'inv_5', invoice_number: 'RE-2026-045', recipient_name: 'Waldrand Bau', status: 'draft', total_amount: 4100 },
{ id: 'inv_6', invoice_number: 'RE-2026-046', recipient_name: 'Praxis am See', status: 'sent', total_amount: 2750 },
{ id: 'inv_7', invoice_number: 'RE-2026-047', recipient_name: 'Nordwind Logistik', status: 'overdue', total_amount: 1620 },
{ id: 'inv_8', invoice_number: 'RE-2026-048', recipient_name: 'Kiosk am Platz', status: 'sent', total_amount: 980 },
];
/** The real ai-insight prompt asks for `**Lage**` style markdown headings (accounting_router.py 215 to 227); the page renders `insight` into a plain
* `whitespace-pre-line` div with no markdown parser (line 146 to 148), so the asterisks show up literally on screen. Reproduced, not fixed (Gotchas). */
const insight =
"**Lage**\nDie Firma steht solide da: Bei einem Umsatz von CHF 128'450.00 liegt der Gewinn bei CHF 52'130.00, das sind rund 40% vom Umsatz.\n\n" +
"**Handlungsbedarf**\n6 offene Rechnungen im Wert von CHF 14'840.00 warten auf Zahlung, davon 2 bereits überfällig (Café Lindenhof, Nordwind Logistik). Dort zuerst nachfassen.\n\n" +
"**Grösste Kostentreiber**\nLöhne (CHF 34'500.00) und Miete (CHF 14'800.00) machen zusammen über 60% des Aufwands aus, im Verhältnis zum Umsatz im üblichen Rahmen.\n\n" +
"**Trend**\nUmsatz und Aufwand steigen beide leicht über die letzten sechs Monate, der Abstand zwischen beiden bleibt stabil.\n\n" +
"**Nächster Schritt**\nSende noch heute eine Zahlungserinnerung an die zwei überfälligen Rechnungen.";
/** The ai-insight route's own early return (accounting_router.py 231 to 232) when a workspace has no fiscal year yet: the same sentence reaches the
* screen unmodified, so a fresh workspace's insight card is not a shimmer or an error, it is this one line of copy. */
const noFiscalYearInsight = 'Noch keine Buchhaltungsdaten vorhanden. Erstelle zuerst ein Geschäftsjahr und erfasse Buchungen.';
const base: Props = {
viewer,
workspace,
dataLoading: false,
period,
kpi,
monthly,
expenseByAccount,
invoices,
insightLoading: false,
insight,
};
export const states = defineStates<Props>({
'loading': { label: 'First mount: the single spinner before any balance is known (line 96 to 104), the shell already on screen', props: { ...base, dataLoading: true, period: null, kpi: null, monthly: [], expenseByAccount: [], invoices: [], insightLoading: true, insight: '' }, height: 640, viewport: 1440 },
'default': { label: 'A fiscal year with six months of activity: KPIs, both charts, six open invoices (one past the 5-row cap), Finanzanalyse populated', props: base, height: 1900, viewport: 1440 },
'insight-generating': { label: 'The dashboard read has resolved but the independent Finanzanalyse read has not yet (line 72 to 86 run in parallel): the five-bar shimmer over real KPIs and charts', props: { ...base, insightLoading: true, insight: '' }, height: 1900, viewport: 1440 },
'pending-review-alert': { label: 'The same period, three receipts flagged needs_review: the amber alert row between the KPIs and the charts, plural copy ("Belege")', props: { ...base, kpi: { ...kpi, pending_review: 3 } }, height: 1980, viewport: 1440 },
'empty-workspace': { label: 'A fresh workspace with no fiscal year yet: no subtitle, both chart placeholders, "Keine Daten", the insight route\'s own onboarding sentence', props: { ...base, period: null, kpi: { revenue: 0, expenses: 0, profit: 0, open_invoices_count: 0, open_invoices_amount: 0, pending_review: 0 }, monthly: [], expenseByAccount: [], invoices: [], insight: noFiscalYearInsight }, height: 1500, viewport: 1440 },
});
/* ---- helpers (i18n/accounting.ts, reimplemented verbatim) ----------------- */
const MONTH_LABELS: Record<string, string> = {
'01': 'Jan', '02': 'Feb', '03': 'Mär', '04': 'Apr',
'05': 'Mai', '06': 'Jun', '07': 'Jul', '08': 'Aug',
'09': 'Sep', '10': 'Okt', '11': 'Nov', '12': 'Dez',
};
function formatMonth(ym: string): string {
const parts = ym.split('-');
return MONTH_LABELS[parts[1]] || parts[1];
}
/** Swiss thousands apostrophe, two decimals, CHF prefix (i18n/accounting.ts 514 to 520). */
function formatMoney(amount: number | string, currency = 'CHF'): string {
const num = typeof amount === 'string' ? parseFloat(amount) : amount;
if (isNaN(num)) return `${currency} 0.00`;
const parts = num.toFixed(2).split('.');
const intPart = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, "'");
return `${currency} ${intPart}.${parts[1]}`;
}
/* ---- helpers (components/accounting/shared.tsx, ported, page-local) ------- */
const INVOICE_STATUS_COLORS: Record<string, string> = {
draft: 'bg-stone-200 text-stone-600',
sent: 'bg-blue-100 text-blue-700',
paid: 'bg-emerald-100 text-emerald-700',
overdue: 'bg-amber-100 text-amber-700',
cancelled: 'bg-red-100 text-red-700',
credited: 'bg-purple-100 text-purple-700',
};
/** `entries.status` (the journal-entry dictionary) only has draft/posted/void. shared.tsx's real lookup is
* `t(entries.status.X) || t(invoices.status.X) || t(expenses.status.X) || status`, but `t()` never returns
* an empty string, it falls back to the key itself when a key is missing, so the first call always wins and
* the chain never reaches invoices.status. Every invoice status this list actually shows (sent, overdue)
* is not in entries.status, so the badge renders the raw untranslated key, not the German word. Reproduced
* as measured (Gotchas), not corrected; the colors, keyed directly by the raw status, are unaffected. */
const ENTRIES_STATUS_DE: Record<string, string> = { draft: 'Entwurf', posted: 'Gebucht', void: 'Storniert' };
function StatusBadge({ status }: { status: string }) {
const colors = INVOICE_STATUS_COLORS[status] || 'bg-stone-200 text-stone-600';
const label = ENTRIES_STATUS_DE[status] ?? `entries.status.${status}`;
return <span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colors}`}>{label}</span>;
}
function MoneyDisplay({ amount, className = '' }: { amount: number | string; className?: string }) {
const num = typeof amount === 'string' ? parseFloat(amount) : amount;
return <span className={`tabular-nums ${num < 0 ? 'text-red-400' : ''} ${className}`}>{formatMoney(amount)}</span>;
}
function KpiCard({ title, value, subtitle, icon: Icon }: { title: string; value: string; subtitle?: string; icon: React.ComponentType<{ className?: string }> }) {
return (
<div className="bg-white border border-line rounded-xl p-5 hover:border-stone-300 transition-colors">
<div className="flex items-center justify-between mb-3">
<span className="text-sm text-ink-60">{title}</span>
<Icon className="w-5 h-5 text-ink-40" />
</div>
<div className="text-2xl font-semibold text-ink tabular-nums">{value}</div>
{subtitle && (
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-ink-60">{subtitle}</span>
</div>
)}
</div>
);
}
/* ---- charts: from-scratch inline SVG, recharts is not an allowed import (header comment) --- */
function MiniBarChart({ data }: { data: MonthlyPoint[] }) {
const W = 600;
const H = 220;
const max = Math.max(1, ...data.flatMap((d) => [d.revenue, d.expenses]));
const groupW = W / data.length;
const barW = Math.min(28, groupW / 3);
return (
<svg viewBox={`0 0 ${W} ${H + 24}`} width="100%" height={280} preserveAspectRatio="none" role="img" aria-label="Umsatz vs. Aufwand">
{[0, 0.25, 0.5, 0.75, 1].map((f) => (
<line key={f} x1={0} x2={W} y1={H - H * f} y2={H - H * f} stroke="#1E293B" strokeDasharray="3 3" strokeWidth={1} />
))}
{data.map((d, i) => {
const label = formatMonth(d.month);
const x = i * groupW + groupW / 2;
const revH = (d.revenue / max) * H;
const expH = (d.expenses / max) * H;
return (
<g key={d.month}>
<rect x={x - barW - 2} y={H - revH} width={barW} height={revH} fill="#22C55E" rx={4} />
<rect x={x + 2} y={H - expH} width={barW} height={expH} fill="#EF4444" fillOpacity={0.7} rx={4} />
<text x={x} y={H + 16} textAnchor="middle" fontSize={12} fill="#94A3B8">{label}</text>
</g>
);
})}
</svg>
);
}
function MiniDonutChart({ data }: { data: ExpenseSlice[] }) {
const total = data.reduce((s, d) => s + d.value, 0) || 1;
const cx = 100;
const cy = 100;
const r = 70;
const strokeW = 40;
const circumference = 2 * Math.PI * r;
let offset = 0;
return (
<div className="flex flex-col items-center gap-4">
<svg viewBox="0 0 200 200" width={200} height={200} role="img" aria-label="Aufwand nach Kategorie">
{data.map((d, i) => {
const dash = (d.value / total) * circumference;
const el = (
<circle
key={d.name}
cx={cx}
cy={cy}
r={r}
fill="none"
stroke={CHART_COLORS[i % CHART_COLORS.length]}
strokeWidth={strokeW}
strokeDasharray={`${dash} ${circumference - dash}`}
strokeDashoffset={-offset}
transform={`rotate(-90 ${cx} ${cy})`}
/>
);
offset += dash;
return el;
})}
</svg>
<ul className="w-full space-y-1.5">
{data.map((d, i) => (
<li key={d.name} className="flex items-center justify-between text-xs">
<span className="flex items-center gap-2 text-ink-60">
<span className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ background: CHART_COLORS[i % CHART_COLORS.length] }} />
{d.name}
</span>
<span className="text-ink-60">{((d.value / total) * 100).toFixed(0)}%</span>
</li>
))}
</ul>
</div>
);
}
/* ---- the page -------------------------------------------------------------- */
const QUICK_ACTIONS = [
{ label: 'Beleg erfassen', icon: Receipt, color: 'text-emerald-400' },
{ label: 'Rechnung erstellen', icon: FileText, color: 'text-blue-400' },
{ label: 'Buchung erfassen', icon: Calculator, color: 'text-amber-400' },
{ label: 'Einstellungen', icon: Settings, color: 'text-purple-400' },
];
function Dashboard({ dataLoading, period, kpi, monthly: monthlyRaw, expenseByAccount, invoices, insightLoading, insight }: Omit<Props, 'viewer' | 'workspace'>) {
if (dataLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="w-8 h-8 border-2 border-[#6844C4] border-t-transparent rounded-full animate-spin" />
</div>
);
}
/* Line 65: the page itself keeps only sent/overdue, not the reports read. */
const openInvoices = invoices.filter((i) => i.status === 'sent' || i.status === 'overdue');
return (
<div className="p-6 max-w-7xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-semibold text-ink">Voila</h1>
{period && <p className="text-sm text-ink-60 mt-0.5">Geschäftsjahr {period.fiscal_year}</p>}
</div>
</div>
<div className="bg-gradient-to-br from-purple-50 via-white to-white border border-[#6844C4]/20 rounded-xl p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Sparkles className="w-5 h-5 text-purple" />
<h2 className="text-sm font-medium text-purple">Finanzanalyse</h2>
</div>
<button
disabled={insightLoading}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-purple hover:text-purple-dark bg-[#6844C4]/10 hover:bg-[#6844C4]/20 rounded-lg transition-colors disabled:opacity-50"
>
<RefreshCw className={`w-3.5 h-3.5 ${insightLoading ? 'animate-spin' : ''}`} />
Neue Analyse
</button>
</div>
{insightLoading ? (
<div className="space-y-3">
<div className="h-4 bg-white rounded animate-pulse w-full" />
<div className="h-4 bg-white rounded animate-pulse w-5/6" />
<div className="h-4 bg-white rounded animate-pulse w-4/6" />
<div className="h-4 bg-white rounded animate-pulse w-full" />
<div className="h-4 bg-white rounded animate-pulse w-3/6" />
</div>
) : (
<div className="text-sm text-ink-60 leading-relaxed whitespace-pre-line">{insight}</div>
)}
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-8">
{QUICK_ACTIONS.map((action) => (
<button
key={action.label}
className="flex items-center gap-3 bg-white border border-line rounded-xl p-4 hover:border-stone-300 hover:bg-white transition-all group text-left"
>
<action.icon className={`w-5 h-5 ${action.color} flex-shrink-0`} />
<span className="text-sm text-ink-60 group-hover:text-white transition-colors">{action.label}</span>
<ArrowRight className="w-3.5 h-3.5 text-ink-40 group-hover:text-ink-60 ml-auto transition-colors" />
</button>
))}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<KpiCard title="Umsatz" value={formatMoney(kpi?.revenue || 0)} subtitle="Diese Periode" icon={TrendingUp} />
<KpiCard title="Aufwand" value={formatMoney(kpi?.expenses || 0)} subtitle="Diese Periode" icon={TrendingDown} />
<KpiCard title="Gewinn" value={formatMoney(kpi?.profit || 0)} icon={DollarSign} />
<KpiCard title="Offene Rechnungen" value={formatMoney(kpi?.open_invoices_amount || 0)} subtitle={`${kpi?.open_invoices_count || 0} Rechnungen`} icon={FileText} />
</div>
{(kpi?.pending_review || 0) > 0 && (
<button className="w-full flex items-center gap-3 bg-amber-900/20 border border-amber-800/30 rounded-xl p-4 mb-8 hover:bg-amber-900/30 transition-colors text-left">
<AlertCircle className="w-5 h-5 text-amber-400 flex-shrink-0" />
<span className="text-sm text-amber-300">
{kpi!.pending_review} {kpi!.pending_review === 1 ? 'Beleg' : 'Belege'} zur Prüfung
</span>
<ArrowRight className="w-3.5 h-3.5 text-amber-600 ml-auto" />
</button>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
<div className="lg:col-span-2 bg-white border border-line rounded-xl p-5">
<h3 className="text-sm font-medium text-ink-60 mb-4">Umsatz vs. Aufwand</h3>
{monthlyRaw.length === 0 ? (
<div className="flex items-center justify-center h-[280px] text-ink-40 text-sm">Noch keine Buchungsdaten vorhanden</div>
) : (
<MiniBarChart data={monthlyRaw} />
)}
</div>
<div className="bg-white border border-line rounded-xl p-5">
<h3 className="text-sm font-medium text-ink-60 mb-4">Aufwand nach Kategorie</h3>
{expenseByAccount.length === 0 ? (
<div className="flex items-center justify-center h-[280px] text-ink-40 text-sm">Noch keine Aufwanddaten</div>
) : (
<MiniDonutChart data={expenseByAccount} />
)}
</div>
</div>
<div className="bg-white border border-line rounded-xl p-5">
<h3 className="text-sm font-medium text-ink-60 mb-4">Offene Rechnungen</h3>
{openInvoices.length === 0 ? (
<p className="text-ink-60 text-sm py-4">Keine Daten</p>
) : (
<div className="space-y-2">
{openInvoices.slice(0, 5).map((inv) => (
<div key={inv.id} className="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-white transition-colors cursor-pointer">
<div className="flex items-center gap-3">
<FileText className="w-4 h-4 text-ink-60" />
<div>
<span className="text-sm text-ink">{inv.invoice_number}</span>
<span className="text-xs text-ink-60 ml-2">{inv.recipient_name}</span>
</div>
</div>
<div className="flex items-center gap-3">
<StatusBadge status={inv.status} />
<MoneyDisplay amount={inv.total_amount} className="text-sm font-medium" />
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
export default function Demo({ viewer, workspace: ws, ...page }: Props) {
return (
<Shell domains={DOMAINS} user={viewer} workspaces={[ws]} currentWorkspaceId={ws.id} activePath="/accounting" hasTour={false}>
<Dashboard {...page} />
</Shell>
);
}
Component tree, from the recipe
<Route /accounting> PrivateRoute (children)
Navigation = AppShell
AccountingDashboard
[dataLoading && !dashData] div.h-64 > spinner (purple border, no header, no shell content below it, line 96 to 104)
Header h1 "Voila" + [period] Text "Geschaeftsjahr {fiscal_year}" (line 109 to 119)
Finanzanalyse div gradient purple-50/white
header row Sparkles + Heading "Finanzanalyse" + button "Neue Analyse" (RefreshCw, insightLoading spin, disabled)
[insightLoading] 5 x shimmer bar (animate-pulse, varying widths) (line 138 to 144)
[else] Text whitespace-pre-line {insight}, no markdown rendering (Gotchas) (line 145 to 149)
Quick Actions grid 2/4 cols, 4 buttons: Beleg erfassen, Rechnung erstellen, Buchung erfassen, Einstellungen (icon + label + ArrowRight)
KPI Cards grid 1/2/4 cols, 4 x KpiCard: Umsatz, Aufwand, Gewinn, Offene Rechnungen (with count subtitle)
[kpi.pending_review > 0] amber alert button: AlertCircle + "{n} Beleg|Belege zur Pruefung" + ArrowRight (line 199 to 211)
Charts row grid 1/3 cols
Umsatz vs. Aufwand (col-span-2) Card: Heading
[monthly.length === 0] "Noch keine Buchungsdaten vorhanden"
[else] BarChart: Umsatz green, Aufwand red at 70% opacity, rounded tops
Aufwand nach Kategorie Card: Heading "Aufwand nach Kategorie"
[expenseByAccount.length === 0] "Noch keine Aufwanddaten"
[else] PieChart donut (inner 60 / outer 100) + per-slice "{name} {percent}%" label
Offene Rechnungen Card: Heading
[openInvoices.length === 0] Text "Keine Daten"
[else] list.slice(0, 5): FileText + invoice_number + recipient_name + StatusBadge + MoneyDisplay, row onClick The rendering is the live rebuild bookkeeping-financial-dashboard/demo.tsx, built from @prima-modules/alchemy-ui, @prima-modules/tokens and plain inline SVG, rendered by the docs host as one frame per state. The page's three reads are props (dataLoading, period, kpi, monthly, expenseByAccount, invoices, insightLoading, insight); the tree is the frontmatter's. The console is rendered inside the shell rebuild (ui-design-system/ui/appshell-navigation/demo.tsx, imported relatively the way billing-credits/ui/subscription-manager's demo imports it) with an admin viewer, since the "Money" domain is adminOnly in the navigation model, on /accounting, so "Money" is the active rail entry.
States (rendering.states, in order): loading (the single spinner, the shell already painted, nothing else); default (a fiscal year with six months of activity, all four KPIs, both charts populated, six open invoices of which one is past the list's own slice(0, 5) cap, Finanzanalyse already generated); insight-generating (identical data to default, but the Finanzanalyse card still shimmering, the genuine, independent race between the two reads named in Application logic step 3); pending-review-alert (identical to default with pending_review: 3, the amber alert row between the KPIs and the charts, plural copy); empty-workspace (no fiscal year yet: the header's second line gone, both chart placeholders, "Keine Daten" in the invoices card, and the AI insight route's own onboarding sentence rather than a generated analysis, since that route hits the identical _get_current_fiscal_year early return, accounting_router.py line 231 to 232). A genuine gap this recipe does not invent a sixth state for: the Tooltip recharts shows on hover over either chart, not reproduced at all (Frontend, below), since a static server-rendered frame has no hover to show it on.
Tokens and classes as the source uses them: white cards (bg-white border-line) throughout, not the Card glass/default variants billing-credits/ui/subscription-manager's demo uses, because this file imports no alchemy-ui atom (Stack assumptions); the Finanzanalyse card's from-purple-50 via-white to-white gradient and its border-[#6844C4]/20 border are an arbitrary hex value rather than the purple token that already exists in the same tokens layer ('purple': '#6844C4', Gotchas); the bar chart's grid (#1E293B dashed) and axis ticks (#94A3B8) and both charts' (unrendered) tooltip background (#0F172A) are dark-theme slate tones with no dark mode anywhere in Labs to justify them, the same kind of stray palette billing-credits/ui/subscription-manager's Gotchas names for its own amber cards, here inside chart code instead of a card.
Responsive: grid-cols-2 lg:grid-cols-4 for the quick actions, grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 for the KPI cards, grid-cols-1 lg:grid-cols-3 for the charts row (the bar chart at lg:col-span-2); below lg everything stacks to one column. Empty state: empty-workspace, covering the header, both charts and the invoices list at once (Application logic step 4). Error state: none as such, per the Invariants above, a failed read renders exactly like empty-workspace. Loading state: loading for the very first mount only; the "Neue Analyse" click has its own scoped insight-generating look and no equivalent exists for a dashData refetch, because nothing on this page ever re-triggers one after mount.
What the demo deliberately does not render: recharts' hover Tooltip on either chart (Stack assumptions, Frontend); the donut's inline ${name} ${percent}% leader-line labels, moved to a legend list below the ring (demo header comment); useNavigate, so every quick action, the pending-review alert and each invoice row are inert, the same convention billing-credits/ui/subscription-manager's demo established for page-local navigation with no router destination inside a demo frame; the language switch t()/setLanguage() machinery, since the kit ships one committed language per copy_language. All are documented in the demo's own header comment.
Security & tenancy
What the browser may hold: S2's tokens and currentWorkspaceId only, never a payment credential; the fetched dashboard (revenue, expenses, profit, the expense breakdown, up to five open invoices' recipient names and amounts) stays in memory, not persisted. import.meta.env.VITE_API_URL is the only baked value. Origin and CSRF: the api singleton sends the Bearer header and, unlike billing-credits/ui/subscription-manager's user-only routes, an X-Workspace-ID header the backend re-validates against the requesting user's access (_get_workspace_id, api/dependencies.py line 680 to 705). Surface gating: all nine accounting routers are mounted under LABS_SURFACES (app_fastapi.py line 915 to 923), the same gate B16's admin routes and billing-credits/ui/subscription-manager's three routes sit behind.
Tenancy here is per-workspace, WorkspaceID on every one of this page's three routes (unlike billing-credits/ui/subscription-manager's per-user routes), so switching workspaces in the shell does change this page, correctly. What is hidden by CSS versus enforced: the "Money" domain is adminOnly in the navigation model (nav.tsx line 214), which hides the rail entry from a non-admin workspace member, but neither PrivateRoute (auth and surface only, PrivateRoute.tsx line 34 to 60) nor the backend's WorkspaceID/ActiveUser dependencies check a role; a non-admin member who already knows the URL, or who is a member of the workspace through a share rather than an owner invite, loads this dashboard exactly as an admin would. Not itself a defect specific to this page (every route behind these two dependencies works the same way), but worth a line here because the navigation model's own adminOnly flag reads, at a glance, like the actual boundary. Attacker-controlled input the page sends: none, this page issues no writes at all, only three reads. What the page renders from the server unescaped: the AI insight text, via a plain whitespace-pre-line div, no dangerouslySetInnerHTML; React escapes it like every other string, the markdown asterisks the prompt asks for simply show up literally rather than becoming HTML (Application logic, Gotchas).
Tests
- Colocated with the demo:
bookkeeping-financial-dashboard/demo.test.tsx(vitest, jsdom,@testing-library/react),afterEach(cleanup)per_shared/test-layout:loadingrenders a spinner and nothing else (no header text "Voila" in the tree);defaultandinsight-generatingrender the same KPI values but only one of them renders the five shimmer bars;pending-review-alertrenders the alert row with "3 Belege" (plural);empty-workspacerenders no fiscal-year line, "Noch keine Buchungsdaten vorhanden", "Noch keine Aufwanddaten" and "Keine Daten" all at once; the open-invoices list never renders more than 5 rows even whendefault's 6 open invoices are all passed; every invoice'sStatusBadgeindefaultrenders the literalentries.status.<value>string, not a German word (the reproduced bug, Gotchas), proving the demo did not quietly fix it. - The kit's render smoke:
node tooling/verify/render-smoke.mjs bookkeeping/ui/bookkeeping-financial-dashboardrenders all five states withrenderToStringandfetchstubbed; run and passing at 62ba4fc, 2026-09-12 (Acceptance checks). - Labs at 62ba4fc has no unit test of
AccountingDashboard.tsx(grep -rl AccountingDashboard frontend/src --include='*.test.*'is empty) and no Playwright spec for/accounting. Backend coverage ofdashboard-data,invoicesandai-insightbelongs to thebackend_modulesrecipes' own suites, not to this page. - What a new app adds: a jsdom test that
StatusBadge's label lookup is fixed to actually fall through toinvoices.status.*(a real bug this recipe's Gotchas names but does not silently correct in the demo); a Playwright spec that seeds a workspace with no fiscal year and asserts every "no data yet" sentence appears together, the gap this recipe's own error-handling Invariant documents.
Deploy & ops
Build target: the Labs Vite bundle (S2), a lazy chunk (App.tsx line 83, lazy(() => import('./pages/accounting/AccountingDashboard'))). Env baked at build: VITE_API_URL, S2's own. CSP and CORS: nothing of its own; all nine accounting routers answer on the API origin under the SPA's existing allowance, gated by LABS_SURFACES as above. Deploy path: S13's frontend rsync with the bundle; rollback is the bundle's. Nothing to migrate here directly; the schema dashboard-data reads (journal_entries, journal_entry_lines, accounting_accounts, accounting_invoices, accounting_expenses, accounting_fiscal_years) belongs to the six backend_modules recipes. Argus: a synthetic workspace session calling GET /api/accounting/dashboard-data returning 200 with a kpi object is this page's liveness; the page has no health signal of its own beyond the "no data yet" placeholders every chart and list already carries.
Gotchas
- 2026-09-12 (measured at 62ba4fc):
StatusBadge(components/accounting/shared.tsxline 27 to 34) resolves its label witht(entries.status.X) || t(invoices.status.X) || t(expenses.status.X) || status, butt()never returns an empty string, a missing key falls back to the key itself (i18n/accounting.tsline 508 to 510), which is truthy. So the first call in the chain always wins, and sinceentries.statusonly definesdraft/posted/void, every invoice status this dashboard actually shows (sent,overdue) renders as the literal untranslated key ("entries.status.sent") instead of the German word. Reproduced in the demo, not fixed. Fix: check for a genuinely missing translation (compare against the key) before falling through, or reorder the chain to try the caller's own namespace first. - 2026-09-12 (measured at 62ba4fc): the AI insight prompt (
accounting_router.pyline 215 to 227) asks Prima for**Lage**-style markdown headings, but the page rendersinsightinto a plainwhitespace-pre-linediv with no markdown parser (line 146 to 148); the asterisks reach the screen literally. Reproduced in the demo's sample text, not corrected. - 2026-09-12 (measured at 62ba4fc):
Send(a lucide icon, imported line 13) andPageHeader(imported line 17 fromcomponents/accounting/shared) are both imported and never used anywhere in the file, two dead imports, the same class of harmless leftover billing-credits/ui/subscription-manager's Gotchas names for its ownCardContentanduseNavigatereference (that page's ownuseNavigate, line 49, is also declared and genuinely called here, unlike there). - 2026-09-12 (measured at 62ba4fc):
kpi.pending_reviewis sourced fromAccountingExpense.needs_review(accounting_router.pyline 302 to 313), the receipts/Belege domain; none of this wave's sixbackend_modulescover expense capture or review, so this one KPI, and the alert row it drives, render real data this recipe's own backend list does not account for. Named in Purpose and Application logic rather than left implicit. - 2026-09-12 (measured at 62ba4fc): the
dashboard-dataroute this page's charts and KPIs actually call lives inaccounting_router.py(the core journal-entries/chart-of-accounts router) alongside_get_current_fiscal_year, not insideaccounting_reports_router.py;financial-reports-and-ledgeris the closest of this wave's six recipes in subject matter and is named inbackend_moduleson that basis, but a reader expecting the dashboard's aggregation query to live inside that recipe's own file will not find it there. - 2026-09-12 (measured at 62ba4fc): the Finanzanalyse card's gradient border (
border-[#6844C4]/20, line 122) and the loading spinner's border (border-[#6844C4], line 100) both use the raw hex value rather than thepurpletoken already defined in the same Tailwind config ('purple': '#6844C4'), a small instance of the same non-token-color habit billing-credits/ui/subscription-manager's Gotchas flags for its own amber cards. - 2026-09-12 (measured at 62ba4fc): the bar chart's
CartesianGrid(#1E293B) and axis ticks (#94A3B8), and both charts'Tooltipbackground (#0F172A), are dark-theme slate values with no dark mode toggle anywhere in Labs to justify them, sitting inside otherwise-light white cards; the same "stray dark palette" pattern as the arbitrary purple hex above, here specifically in the recharts config.
Reference implementation
Branch: alchemy_labs main at 62ba4fc41009b454e1c033e5036c7e8577db2061 (2026-09-12). Paths relative to the repo root:
frontend/src/pages/accounting/AccountingDashboard.tsx(306 lines): imports andDashboardData(1 to 35),formatMonth(37 to 46), the component and its state (48 to 54),fetchDashboard(56 to 70),fetchInsight(72 to 81), the mount effect (83 to 86), derived values (88 to 94), the loading return (96 to 104), the header (106 to 119), the Finanzanalyse card (121 to 150), the quick actions grid (152 to 170), the KPI cards (172 to 197), the pending-review alert (199 to 211), the charts row (213 to 272), the open-invoices list (274 to 302).frontend/src/components/accounting/shared.tsx:StatusBadge(27 to 34),MoneyDisplay(40 to 51),KpiCard(145 to 172),PageHeader(178 to 185, imported by the dashboard and never called).frontend/src/i18n/accounting.ts: thededictionary'sdashboard.*/invoices.*/common.*keys,t()(508 to 510),formatMoney(514 to 520).frontend/src/services/accountingApi.ts:dashboardData,aiInsight,listInvoicesand theInvoice/DashboardData-shaped types this page consumes.frontend/src/components/Navigation.tsx: the delegator toAppShell.frontend/src/components/PrivateRoute.tsx: the auth and surface gate (34 to 60), no role check.frontend/src/App.tsx: the lazy import (83), the route (529).backend/api/routers/accounting_router.py:_get_current_fiscal_year(44 to 61),dashboard_data(67 to 206),ai_insightand its prompt (208 to 227 onward).backend/api/dependencies.py:_get_workspace_id/WorkspaceID(680 to 779).backend/app_fastapi.pyline 915 to 923: the nine accounting routers'LABS_SURFACESmounts.- The demo:
modules/bookkeeping/ui/bookkeeping-financial-dashboard/demo.tsx; the shell it renders in:modules/ui-design-system/ui/appshell-navigation/demo.tsxandnav.tsx.
Deviations of the reference from this recipe, all dated 2026-09-12: the StatusBadge label bug; the unrendered markdown asterisks in the insight text; the two dead imports; pending_review's dependency on a domain outside this wave's six recipes; the dashboard-data route sitting in the core router rather than the reports router; the untokenized purple hex; the dark-theme chart palette on a light page; the missing role check behind an adminOnly nav entry.
Acceptance checks
From alchemy_labs/frontend at 62ba4fc with the harness up (tooling/verify/labs-local.sh up, a workspace-owner session), or from a new app built to this recipe:
grep -n "\.catch(() =>" src/pages/accounting/AccountingDashboard.tsxprints 2 lines (the dashboard and the invoices read, line 60 to 61);grep -c "err.response" src/pages/accounting/AccountingDashboard.tsxprints0, unlike billing-credits/ui/subscription-manager's 3, confirming this page never inspects the envelope at all (API surface).grep -n "t(\entries.status" src/components/accounting/shared.tsxprints theStatusBadgeline; a browser walk-through against a seeded workspace with asent` invoice shows the badge reading the literal key, not "Versendet" (Gotchas).- Browser walk-through, signed in as a workspace member with a fiscal year and at least one
sent/overdueinvoice: (a) open/accounting, observe the shell paint first and a bare spinner where the whole content area will be, then the full page at once (no partial paint); (b) observe the Finanzanalyse card shimmer independently, sometimes finishing before the rest of the page has fully painted and sometimes after; (c) with more than 5 open invoices seeded, count the rows in "Offene Rechnungen": exactly 5; (d) as a non-admin member of the same workspace who cannot see "Money" in the rail, paste the/accountingURL directly, observe the dashboard loads anyway (Security and tenancy). - Demo gate:
node tooling/verify/render-smoke.mjs bookkeeping/ui/bookkeeping-financial-dashboardrenders all five states, 0 failures, 2026-09-12.npm run checkpasses on this recipe's frontmatter shape, heading order, the internal-term scan and the states list matching the demo's exported keys. - Verifier:
node tooling/verify/compare.mjs bookkeeping/ui/bookkeeping-financial-dashboard --state default --docs http://127.0.0.1:4321 --app http://127.0.0.1:3020 --session-file tooling/verify/.labs-local/session.json(a workspace-owner session with a fiscal year and open invoices seeded) writes a report withpage_errors: [];mismatch_ratiois read for the header, the KPI row and the Finanzanalyse card only, since the app's monthly totals, expense breakdown and invoice rows are the harness's seeded account and the demo's are sample data.
Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.