Prima Get an API key

Module kit / Bookkeeping

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
  • alchemy_labs/frontend/src/pages/accounting/AccountingDashboard.tsx
  • alchemy_labs/frontend/src/components/accounting/shared.tsx
  • alchemy_labs/frontend/src/i18n/accounting.ts
  • alchemy_labs/frontend/src/services/accountingApi.ts
  • alchemy_labs/frontend/src/components/Navigation.tsx
  • alchemy_labs/frontend/src/components/PrivateRoute.tsx
  • alchemy_labs/frontend/src/App.tsx
  • alchemy_labs/backend/api/routers/accounting_router.py
  • alchemy_labs/backend/api/dependencies.py
  • alchemy_labs/backend/app_fastapi.py
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)RouteAuth / tenancyParsedRendered as
accountingApi.dashboardData() (60)GET /api/accounting/dashboard-dataActiveUser + 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/invoicesActiveUser + WorkspaceIDthe 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-insightActiveUser + WorkspaceIDresponse.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

  1. AccountingDashboard mounts with dataLoading: true, insightLoading: true, dashData: null, insight: ''; one useEffect fires both fetchDashboard() and fetchInsight() at once, independently (line 83 to 86). While dataLoading && !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).
  2. fetchDashboard runs dashboardData() and listInvoices() in parallel (Promise.all, line 59 to 62), each individually caught; it stores the dashboard body as dashData and the invoices filtered to sent/overdue as openInvoices; dataLoading goes false either way (line 56 to 70).
  3. fetchInsight runs 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-generating state); insightLoading goes 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).
  4. monthly and period are derived, not stored twice: monthly maps dashData.monthly through formatMonth at render time (line 89 to 92); a null period (no fiscal year yet) suppresses the header's second line and, because the same backend early return also yields empty monthly/expense_by_account and a zeroed kpi, drives every "no data yet" branch on the page at once (empty-workspace state, Data model, accounting_router.py line 71 to 81).
  5. The "Neue Analyse" button re-runs fetchInsight() only; it does not touch dashData or openInvoices (line 128 to 135).
  6. 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.
  7. The pending-review alert renders only when kpi.pending_review > 0, with singular/plural copy ("Beleg" vs "Belege", line 206 to 208); pending_review is a count of AccountingExpense rows with needs_review = true and status = pending, a model this page's own six backend_modules do 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-workspace covers both).
  • The balance shown is the balance summed server-side: revenue, expenses and profit are never computed in this component; they arrive pre-summed in kpi from the same aggregation query that produces monthly (accounting_router.py line 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 sets insight to 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_review and 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.

First mount: the single spinner before any balance is known (line 96 to 104), the shell already on screen loading server rendered, hydrated in view
A fiscal year with six months of activity: KPIs, both charts, six open invoices (one past the 5-row cap), Finanzanalyse populated default server rendered, hydrated in view

Voila

Geschäftsjahr 2026

Finanzanalyse

**Lage** Die 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. **Handlungsbedarf** 6 offene Rechnungen im Wert von CHF 14'840.00 warten auf Zahlung, davon 2 bereits überfällig (Café Lindenhof, Nordwind Logistik). Dort zuerst nachfassen. **Grösste Kostentreiber** Lö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. **Trend** Umsatz und Aufwand steigen beide leicht über die letzten sechs Monate, der Abstand zwischen beiden bleibt stabil. **Nächster Schritt** Sende noch heute eine Zahlungserinnerung an die zwei überfälligen Rechnungen.
Umsatz
CHF 128'450.00
Diese Periode
Aufwand
CHF 76'320.00
Diese Periode
Gewinn
CHF 52'130.00
Offene Rechnungen
CHF 14'840.00
6 Rechnungen

Umsatz vs. Aufwand

AprMaiJunJulAugSep

Aufwand nach Kategorie

  • Löhne45%
  • Miete19%
  • Material12%
  • Versicherungen8%
  • Marketing6%
  • Übriges9%

Offene Rechnungen

RE-2026-042Studio Nordlicht AG
entries.status.sentCHF 3'200.00
RE-2026-043Café Lindenhof
entries.status.overdueCHF 890.00
RE-2026-044Bergbau Consulting GmbH
entries.status.sentCHF 5'400.00
RE-2026-046Praxis am See
entries.status.sentCHF 2'750.00
RE-2026-047Nordwind Logistik
entries.status.overdueCHF 1'620.00
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 insight-generating server rendered, hydrated in view

Voila

Geschäftsjahr 2026

Finanzanalyse

Umsatz
CHF 128'450.00
Diese Periode
Aufwand
CHF 76'320.00
Diese Periode
Gewinn
CHF 52'130.00
Offene Rechnungen
CHF 14'840.00
6 Rechnungen

Umsatz vs. Aufwand

AprMaiJunJulAugSep

Aufwand nach Kategorie

  • Löhne45%
  • Miete19%
  • Material12%
  • Versicherungen8%
  • Marketing6%
  • Übriges9%

Offene Rechnungen

RE-2026-042Studio Nordlicht AG
entries.status.sentCHF 3'200.00
RE-2026-043Café Lindenhof
entries.status.overdueCHF 890.00
RE-2026-044Bergbau Consulting GmbH
entries.status.sentCHF 5'400.00
RE-2026-046Praxis am See
entries.status.sentCHF 2'750.00
RE-2026-047Nordwind Logistik
entries.status.overdueCHF 1'620.00
The same period, three receipts flagged needs_review: the amber alert row between the KPIs and the charts, plural copy ("Belege") pending-review-alert server rendered, hydrated in view

Voila

Geschäftsjahr 2026

Finanzanalyse

**Lage** Die 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. **Handlungsbedarf** 6 offene Rechnungen im Wert von CHF 14'840.00 warten auf Zahlung, davon 2 bereits überfällig (Café Lindenhof, Nordwind Logistik). Dort zuerst nachfassen. **Grösste Kostentreiber** Lö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. **Trend** Umsatz und Aufwand steigen beide leicht über die letzten sechs Monate, der Abstand zwischen beiden bleibt stabil. **Nächster Schritt** Sende noch heute eine Zahlungserinnerung an die zwei überfälligen Rechnungen.
Umsatz
CHF 128'450.00
Diese Periode
Aufwand
CHF 76'320.00
Diese Periode
Gewinn
CHF 52'130.00
Offene Rechnungen
CHF 14'840.00
6 Rechnungen

Umsatz vs. Aufwand

AprMaiJunJulAugSep

Aufwand nach Kategorie

  • Löhne45%
  • Miete19%
  • Material12%
  • Versicherungen8%
  • Marketing6%
  • Übriges9%

Offene Rechnungen

RE-2026-042Studio Nordlicht AG
entries.status.sentCHF 3'200.00
RE-2026-043Café Lindenhof
entries.status.overdueCHF 890.00
RE-2026-044Bergbau Consulting GmbH
entries.status.sentCHF 5'400.00
RE-2026-046Praxis am See
entries.status.sentCHF 2'750.00
RE-2026-047Nordwind Logistik
entries.status.overdueCHF 1'620.00
A fresh workspace with no fiscal year yet: no subtitle, both chart placeholders, "Keine Daten", the insight route's own onboarding sentence empty-workspace server rendered, hydrated in view

Voila

Finanzanalyse

Noch keine Buchhaltungsdaten vorhanden. Erstelle zuerst ein Geschäftsjahr und erfasse Buchungen.
Umsatz
CHF 0.00
Diese Periode
Aufwand
CHF 0.00
Diese Periode
Gewinn
CHF 0.00
Offene Rechnungen
CHF 0.00
0 Rechnungen

Umsatz vs. Aufwand

Noch keine Buchungsdaten vorhanden

Aufwand nach Kategorie

Noch keine Aufwanddaten

Offene Rechnungen

Keine Daten

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: 'fabienne@example.com', 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
Design system
alchemy-ui
Packages
@prima-modules/tokens 0.1.0, from alchemy_labs fd8558f, @prima-modules/alchemy-ui 0.1.0, from alchemy_labs fd8558f, @prima-modules/demo 0.1.0, from prima-modules 5d496a7
Content
Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14.

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: loading renders a spinner and nothing else (no header text "Voila" in the tree); default and insight-generating render the same KPI values but only one of them renders the five shimmer bars; pending-review-alert renders the alert row with "3 Belege" (plural); empty-workspace renders 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 when default's 6 open invoices are all passed; every invoice's StatusBadge in default renders the literal entries.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-dashboard renders all five states with renderToString and fetch stubbed; 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 of dashboard-data, invoices and ai-insight belongs to the backend_modules recipes' 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 to invoices.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.tsx line 27 to 34) resolves its label with t(entries.status.X) || t(invoices.status.X) || t(expenses.status.X) || status, but t() never returns an empty string, a missing key falls back to the key itself (i18n/accounting.ts line 508 to 510), which is truthy. So the first call in the chain always wins, and since entries.status only defines draft/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.py line 215 to 227) asks Prima for **Lage**-style markdown headings, but the page renders insight into a plain whitespace-pre-line div 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) and PageHeader (imported line 17 from components/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 own CardContent and useNavigate reference (that page's own useNavigate, line 49, is also declared and genuinely called here, unlike there).
  • 2026-09-12 (measured at 62ba4fc): kpi.pending_review is sourced from AccountingExpense.needs_review (accounting_router.py line 302 to 313), the receipts/Belege domain; none of this wave's six backend_modules cover 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-data route this page's charts and KPIs actually call lives in accounting_router.py (the core journal-entries/chart-of-accounts router) alongside _get_current_fiscal_year, not inside accounting_reports_router.py; financial-reports-and-ledger is the closest of this wave's six recipes in subject matter and is named in backend_modules on 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 the purple token 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' Tooltip background (#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 and DashboardData (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: the de dictionary's dashboard.*/invoices.*/common.* keys, t() (508 to 510), formatMoney (514 to 520).
  • frontend/src/services/accountingApi.ts: dashboardData, aiInsight, listInvoices and the Invoice/DashboardData-shaped types this page consumes.
  • frontend/src/components/Navigation.tsx: the delegator to AppShell.
  • 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_insight and its prompt (208 to 227 onward).
  • backend/api/dependencies.py: _get_workspace_id / WorkspaceID (680 to 779).
  • backend/app_fastapi.py line 915 to 923: the nine accounting routers' LABS_SURFACES mounts.
  • The demo: modules/bookkeeping/ui/bookkeeping-financial-dashboard/demo.tsx; the shell it renders in: modules/ui-design-system/ui/appshell-navigation/demo.tsx and nav.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:

  1. grep -n "\.catch(() =>" src/pages/accounting/AccountingDashboard.tsx prints 2 lines (the dashboard and the invoices read, line 60 to 61); grep -c "err.response" src/pages/accounting/AccountingDashboard.tsx prints 0, unlike billing-credits/ui/subscription-manager's 3, confirming this page never inspects the envelope at all (API surface).
  2. 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).
  3. Browser walk-through, signed in as a workspace member with a fiscal year and at least one sent/overdue invoice: (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 /accounting URL directly, observe the dashboard loads anyway (Security and tenancy).
  4. Demo gate: node tooling/verify/render-smoke.mjs bookkeeping/ui/bookkeeping-financial-dashboard renders all five states, 0 failures, 2026-09-12. npm run check passes on this recipe's frontmatter shape, heading order, the internal-term scan and the states list matching the demo's exported keys.
  5. 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 with page_errors: []; mismatch_ratio is 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.