Report builder
Build reports in five screens. Columns with groups and totals, charts from a measure, AND and OR filters, schedule and share, and templates to start from.
Reports people build themselves
A report builder lets people answer their own questions without asking someone to write a query. These five screens go from picking fields to sending the finished report every Monday, and every change shows its result live.
Table report ticks fields, orders the columns by drag or keyboard, groups, sorts and filters, and the table with subtotals and total updates live and exports as CSV. Chart report picks bars, line, area or ring, a dimension, a measure and sum, average or count, splits by a second field and redraws as you go. Filter builder joins conditions in groups with AND or OR, offers operators that fit the field, counts matching rows live and reads the filter back in words. Schedule and share sends daily, weekly or monthly with the next five sends listed, to recipients as checked chips, as PDF, CSV or spreadsheet, with a share link and access. Report templates start from a drawn preview by category.
Offer templates first, most people change a good report rather than build one from nothing. Reading the filter back in words catches mistakes before anyone sends the wrong numbers. Show the next send dates, then everyone knows when the report arrives.
Variants
Details
What Report builder brings along, read from the registry and the source.
- Category
- Analytics and reports
- Area
- App
- Variants
- 5
- Version
- 1.0.0 · since 1.2.0
- File
- components/ui/report-builder.tsx
- Brings along
- utilskitarea-chartbar-chartbuttondonut-charticonline-chartmotionswitchsidebar
- npm packages
- None
- Languages
- English and German, 128 texts of its own
- Studio
- Adjust in code
- In collections
- SaaS app screens
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The ReportBuilderProps interface from components/ui/report-builder.tsx, with the comments from the code.
variantoptionalReportBuilderVarianttable-reportchart-reportfiltersschedule-sharetemplates
sidebaroptionalOmit<SidebarProps, 'children'> | nullThe app frame. Leave it out (or null) for a standalone screen.
titleoptionalstringsubtitleoptionalstringnowoptionalstringISO date and time that counts as now
currencyoptionalstringISO currency code for currency fields
nameoptionalstringName of the report
fieldsoptionalReportField[]rowsoptionalReportRow[]columnsoptionalstring[]table-report: field ids shown as columns, in order
groupsoptionalReportGroup[]filters: the groups to start with
recipientsoptionalstring[]schedule-share: people who get the report
shareUrloptionalstringschedule-share: the link to the report
templatesoptionalReportTemplate[]templates
onActionoptional(action: string, data: Record<string, unknown>) => void | Promise<void>Called on save, export, schedule and create. Without it the block simulates the request.
Built-in texts
Report builder writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 128 texts
| Key | English | German |
|---|---|---|
| fields | English Fields | German Felder |
| fieldsHint | English Tick a field to add it as a column. | German Hake ein Feld an, um es als Spalte aufzunehmen. |
| columns | English Columns | German Spalten |
| columnsHint | English Drag a column or use Alt and the arrow keys to move it. | German Ziehe eine Spalte oder verschiebe sie mit Alt und den Pfeiltasten. |
| moveUp | English Move {name} up | German {name} nach oben |
| moveDown | English Move {name} down | German {name} nach unten |
| remove | English Remove {name} | German {name} entfernen |
| moved | English {name} is now column {n}. | German {name} ist jetzt Spalte {n}. |
| groupBy | English Group by | German Gruppieren nach |
| none | English None | German Keine |
| sortBy | English Sort by | German Sortieren nach |
| ascending | English Ascending | German Aufsteigend |
| descending | English Descending | German Absteigend |
| direction | English Sort direction | German Sortierrichtung |
| filter | English Filter | German Filter |
| allValues | English All | German Alle |
| preview | English Preview | German Vorschau |
| setup | English Set up | German Aufbau |
| mode | English Set up or preview | German Aufbau oder Vorschau |
| rowsN | English {n} rows | German {n} Zeilen |
| total | English Total | German Gesamt |
| subtotal | English Subtotal | German Zwischensumme |
| export | English Export CSV | German CSV exportieren |
| exported | English {name}.csv is in your downloads. | German {name}.csv liegt in deinen Downloads. |
| save | English Save report | German Bericht speichern |
| saving | English Saving… | German Wird gespeichert… |
| saved | English Report saved. | German Bericht gespeichert. |
| empty | English Pick at least one field to see a preview. | German Wähle mindestens ein Feld für die Vorschau. |
| chartType | English Chart type | German Diagrammtyp |
| bar | English Bars | German Balken |
| line | English Line | German Linie |
| area | English Area | German Fläche |
| donut | English Ring | German Ring |
| dimension | English Dimension | German Dimension |
| measure | English Measure | German Kennzahl |
| aggregation | English Aggregation | German Berechnung |
| sum | English Sum | German Summe |
| avg | English Average | German Durchschnitt |
| count | English Count | German Anzahl |
| split | English Split by | German Aufteilen nach |
| top | English Show | German Zeigen |
| topN | English Top {n} | German Top {n} |
| all | English All | German Alle |
| chartTitle | English {measure} by {dimension} | German {measure} nach {dimension} |
| chartTitleSplit | English {measure} by {dimension} and {split} | German {measure} nach {dimension} und {split} |
| countOf | English Number of rows | German Anzahl Zeilen |
| donutHint | English A ring reads best with up to four parts. The rest is shown as Other. | German Ein Ring liest sich mit bis zu vier Teilen am besten. Der Rest steht unter Sonstige. |
| other | English Other | German Sonstige |
| conditions | English Conditions | German Bedingungen |
| where | English Where | German Wo |
| and | English and | German und |
| or | English or | German oder |
| matchAll | English All | German Alle |
| matchAny | English Any | German Eine |
| groupJoin | English Groups join with | German Gruppen verbinden mit |
| conditionJoin | English Conditions in group {n} join with | German Bedingungen in Gruppe {n} verbinden mit |
| group | English Group {n} | German Gruppe {n} |
| field | English Field | German Feld |
| op | English Condition | German Bedingung |
| value | English Value | German Wert |
| is | English is | German ist |
| is-not | English is not | German ist nicht |
| contains | English contains | German enthält |
| gt | English is more than | German ist größer als |
| lt | English is less than | German ist kleiner als |
| addCondition | English Add condition | German Bedingung hinzufügen |
| addGroup | English Add group | German Gruppe hinzufügen |
| removeGroup | English Remove group {n} | German Gruppe {n} entfernen |
| removeCondition | English Remove condition | German Bedingung entfernen |
| matches | English {n} of {m} rows match | German {n} von {m} Zeilen passen |
| hits | English Matching rows | German Passende Zeilen |
| ofRows | English of {m} rows | German von {m} Zeilen |
| noMatch | English No row matches. Loosen a condition. | German Keine Zeile passt. Lockere eine Bedingung. |
| inWords | English In words | German In Worten |
| showing | English First {n} matches | German Erste {n} Treffer |
| reset | English Reset filters | German Filter zurücksetzen |
| schedule | English Schedule | German Zeitplan |
| scheduleText | English Sends the report on its own at the times you pick. | German Verschickt den Bericht von selbst zu den gewählten Zeiten. |
| daily | English Daily | German Täglich |
| weekly | English Weekly | German Wöchentlich |
| monthly | English Monthly | German Monatlich |
| repeat | English Repeat | German Wiederholen |
| on | English On | German Am |
| at | English At | German Um |
| dayOfMonth | English Day of the month | German Tag im Monat |
| pickDay | English Pick at least one day. | German Wähle mindestens einen Tag. |
| next | English Next sends | German Nächste Versände |
| recipients | English Recipients | German Empfänger |
| addRecipient | English Add an email and press Enter | German E-Mail eingeben und Enter drücken |
| badEmail | English {v} is not an email address. | German {v} ist keine E-Mail-Adresse. |
| removeRecipient | English Remove {v} | German {v} entfernen |
| added | English {v} added. | German {v} hinzugefügt. |
| removed | English {v} removed. | German {v} entfernt. |
| format | English Format | German Format |
| English PDF | German PDF | |
| csv | English CSV | German CSV |
| xlsx | English Spreadsheet | German Tabelle |
| pdfText | English Charts and tables, ready to read | German Diagramme und Tabellen, fertig zum Lesen |
| csvText | English Raw rows for any tool | German Rohdaten für jedes Werkzeug |
| xlsxText | English One sheet per table | German Ein Blatt je Tabelle |
| message | English Message | German Nachricht |
| messagePh | English A short note above the report | German Ein kurzer Satz über dem Bericht |
| share | English Share link | German Link teilen |
| linkOn | English Anyone with access can open the link | German Wer Zugang hat, kann den Link öffnen |
| access | English Who can open it | German Wer ihn öffnen kann |
| team | English Your team | German Dein Team |
| anyone | English Anyone with the link | German Alle mit dem Link |
| copy | English Copy link | German Link kopieren |
| copied | English Link copied. | German Link kopiert. |
| saveSchedule | English Save schedule | German Zeitplan speichern |
| scheduled | English Saved. The next report goes out {when}. | German Gespeichert. Der nächste Bericht geht {when} raus. |
| needRecipient | English Add at least one recipient. | German Füge mindestens einen Empfänger hinzu. |
| templates | English Templates | German Vorlagen |
| categories | English Categories | German Kategorien |
| use | English Use this template | German Vorlage nutzen |
| selected | English Selected | German Gewählt |
| customize | English Customize | German Anpassen |
| reportName | English Report name | German Name des Berichts |
| range | English Time range | German Zeitraum |
| r7 | English 7 days | German 7 Tage |
| r30 | English 30 days | German 30 Tage |
| r90 | English 90 days | German 90 Tage |
| measures | English Measures | German Kennzahlen |
| create | English Create report | German Bericht erstellen |
| creating | English Creating… | German Wird erstellt… |
| created | English {name} is ready and saved in your reports. | German {name} ist fertig und liegt bei deinen Berichten. |
| pickTemplate | English Pick a template to customize it. | German Wähle eine Vorlage, um sie anzupassen. |
| oneMeasure | English Pick at least one measure. | German Wähle mindestens eine Kennzahl. |
Accessibility
Read from the source of the component.
- Report builder handles the keys Enter, Arrow up, Arrow down, Arrow left, Arrow right, Backspace, Delete and Space itself.
- Sets aria-checked, aria-describedby, aria-invalid, aria-label, aria-labelledby, aria-live, aria-pressed and aria-sort, so screen readers announce purpose and state.
- Uses the roles group, radio, radiogroup and status.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 1,766 lines
'use client';
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { cn, type Locale, prefersReducedMotion, useLocale, useTexts } from '@/lib/kit';
import { AreaChart } from '@/components/ui/area-chart';
import { BarChart } from '@/components/ui/bar-chart';
import { Button } from '@/components/ui/button';
import { DonutChart } from '@/components/ui/donut-chart';
import { Icon, type IconName } from '@/components/ui/icon';
import { LineChart } from '@/components/ui/line-chart';
import { useScrollFade } from '@/components/ui/motion';
import { Switch } from '@/components/ui/switch';
import { Sidebar, type SidebarProps } from '@/components/ui/sidebar';
export type ReportBuilderVariant = 'table-report' | 'chart-report' | 'filters' | 'schedule-share' | 'templates';
export interface ReportField {
id: string;
label: string;
/** text and date fields group, number and currency fields add up */
type: 'text' | 'date' | 'number' | 'currency';
}
export type ReportRow = Record<string, string | number>;
export interface ReportCondition {
id: string;
field: string;
op: 'is' | 'is-not' | 'contains' | 'gt' | 'lt';
value: string;
}
export interface ReportGroup {
id: string;
/** How the conditions inside the group join */
join: 'and' | 'or';
conditions: ReportCondition[];
}
export interface ReportTemplate {
id: string;
name: string;
description: string;
category: string;
/** Which small picture the card shows */
kind: 'table' | 'bars' | 'line' | 'donut' | 'kpi';
/** Field ids of the measures it starts with */
measures: string[];
}
export interface ReportBuilderProps {
variant?: ReportBuilderVariant;
/** The app frame. Leave it out (or null) for a standalone screen. */
sidebar?: Omit<SidebarProps, 'children'> | null;
title?: string;
subtitle?: string;
/** ISO date and time that counts as now */
now?: string;
/** ISO currency code for currency fields */
currency?: string;
/** Name of the report */
name?: string;
fields?: ReportField[];
rows?: ReportRow[];
/** table-report: field ids shown as columns, in order */
columns?: string[];
/** filters: the groups to start with */
groups?: ReportGroup[];
/** schedule-share: people who get the report */
recipients?: string[];
/** schedule-share: the link to the report */
shareUrl?: string;
/** templates */
templates?: ReportTemplate[];
/** Called on save, export, schedule and create. Without it the block simulates the request. */
onAction?: (action: string, data: Record<string, unknown>) => void | Promise<void>;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
const TEXTS = {
en: {
fields: 'Fields',
fieldsHint: 'Tick a field to add it as a column.',
columns: 'Columns',
columnsHint: 'Drag a column or use Alt and the arrow keys to move it.',
moveUp: 'Move {name} up',
moveDown: 'Move {name} down',
remove: 'Remove {name}',
moved: '{name} is now column {n}.',
groupBy: 'Group by',
none: 'None',
sortBy: 'Sort by',
ascending: 'Ascending',
descending: 'Descending',
direction: 'Sort direction',
filter: 'Filter',
allValues: 'All',
preview: 'Preview',
setup: 'Set up',
mode: 'Set up or preview',
rowsN: '{n} rows',
total: 'Total',
subtotal: 'Subtotal',
export: 'Export CSV',
exported: '{name}.csv is in your downloads.',
save: 'Save report',
saving: 'Saving…',
saved: 'Report saved.',
empty: 'Pick at least one field to see a preview.',
chartType: 'Chart type',
bar: 'Bars',
line: 'Line',
area: 'Area',
donut: 'Ring',
dimension: 'Dimension',
measure: 'Measure',
aggregation: 'Aggregation',
sum: 'Sum',
avg: 'Average',
count: 'Count',
split: 'Split by',
top: 'Show',
topN: 'Top {n}',
all: 'All',
chartTitle: '{measure} by {dimension}',
chartTitleSplit: '{measure} by {dimension} and {split}',
countOf: 'Number of rows',
donutHint: 'A ring reads best with up to four parts. The rest is shown as Other.',
other: 'Other',
conditions: 'Conditions',
where: 'Where',
and: 'and',
or: 'or',
matchAll: 'All',
matchAny: 'Any',
groupJoin: 'Groups join with',
conditionJoin: 'Conditions in group {n} join with',
group: 'Group {n}',
field: 'Field',
op: 'Condition',
value: 'Value',
is: 'is',
'is-not': 'is not',
contains: 'contains',
gt: 'is more than',
lt: 'is less than',
addCondition: 'Add condition',
addGroup: 'Add group',
removeGroup: 'Remove group {n}',
removeCondition: 'Remove condition',
matches: '{n} of {m} rows match',
hits: 'Matching rows',
ofRows: 'of {m} rows',
noMatch: 'No row matches. Loosen a condition.',
inWords: 'In words',
showing: 'First {n} matches',
reset: 'Reset filters',
schedule: 'Schedule',
scheduleText: 'Sends the report on its own at the times you pick.',
daily: 'Daily',
weekly: 'Weekly',
monthly: 'Monthly',
repeat: 'Repeat',
on: 'On',
at: 'At',
dayOfMonth: 'Day of the month',
pickDay: 'Pick at least one day.',
next: 'Next sends',
recipients: 'Recipients',
addRecipient: 'Add an email and press Enter',
badEmail: '{v} is not an email address.',
removeRecipient: 'Remove {v}',
added: '{v} added.',
removed: '{v} removed.',
format: 'Format',
pdf: 'PDF',
csv: 'CSV',
xlsx: 'Spreadsheet',
pdfText: 'Charts and tables, ready to read',
csvText: 'Raw rows for any tool',
xlsxText: 'One sheet per table',
message: 'Message',
messagePh: 'A short note above the report',
share: 'Share link',
linkOn: 'Anyone with access can open the link',
access: 'Who can open it',
team: 'Your team',
anyone: 'Anyone with the link',
copy: 'Copy link',
copied: 'Link copied.',
saveSchedule: 'Save schedule',
scheduled: 'Saved. The next report goes out {when}.',
needRecipient: 'Add at least one recipient.',
templates: 'Templates',
categories: 'Categories',
use: 'Use this template',
selected: 'Selected',
customize: 'Customize',
reportName: 'Report name',
range: 'Time range',
r7: '7 days',
r30: '30 days',
r90: '90 days',
measures: 'Measures',
create: 'Create report',
creating: 'Creating…',
created: '{name} is ready and saved in your reports.',
pickTemplate: 'Pick a template to customize it.',
oneMeasure: 'Pick at least one measure.',
},
de: {
fields: 'Felder',
fieldsHint: 'Hake ein Feld an, um es als Spalte aufzunehmen.',
columns: 'Spalten',
columnsHint: 'Ziehe eine Spalte oder verschiebe sie mit Alt und den Pfeiltasten.',
moveUp: '{name} nach oben',
moveDown: '{name} nach unten',
remove: '{name} entfernen',
moved: '{name} ist jetzt Spalte {n}.',
groupBy: 'Gruppieren nach',
none: 'Keine',
sortBy: 'Sortieren nach',
ascending: 'Aufsteigend',
descending: 'Absteigend',
direction: 'Sortierrichtung',
filter: 'Filter',
allValues: 'Alle',
preview: 'Vorschau',
setup: 'Aufbau',
mode: 'Aufbau oder Vorschau',
rowsN: '{n} Zeilen',
total: 'Gesamt',
subtotal: 'Zwischensumme',
export: 'CSV exportieren',
exported: '{name}.csv liegt in deinen Downloads.',
save: 'Bericht speichern',
saving: 'Wird gespeichert…',
saved: 'Bericht gespeichert.',
empty: 'Wähle mindestens ein Feld für die Vorschau.',
chartType: 'Diagrammtyp',
bar: 'Balken',
line: 'Linie',
area: 'Fläche',
donut: 'Ring',
dimension: 'Dimension',
measure: 'Kennzahl',
aggregation: 'Berechnung',
sum: 'Summe',
avg: 'Durchschnitt',
count: 'Anzahl',
split: 'Aufteilen nach',
top: 'Zeigen',
topN: 'Top {n}',
all: 'Alle',
chartTitle: '{measure} nach {dimension}',
chartTitleSplit: '{measure} nach {dimension} und {split}',
countOf: 'Anzahl Zeilen',
donutHint: 'Ein Ring liest sich mit bis zu vier Teilen am besten. Der Rest steht unter Sonstige.',
other: 'Sonstige',
conditions: 'Bedingungen',
where: 'Wo',
and: 'und',
or: 'oder',
matchAll: 'Alle',
matchAny: 'Eine',
groupJoin: 'Gruppen verbinden mit',
conditionJoin: 'Bedingungen in Gruppe {n} verbinden mit',
group: 'Gruppe {n}',
field: 'Feld',
op: 'Bedingung',
value: 'Wert',
is: 'ist',
'is-not': 'ist nicht',
contains: 'enthält',
gt: 'ist größer als',
lt: 'ist kleiner als',
addCondition: 'Bedingung hinzufügen',
addGroup: 'Gruppe hinzufügen',
removeGroup: 'Gruppe {n} entfernen',
removeCondition: 'Bedingung entfernen',
matches: '{n} von {m} Zeilen passen',
hits: 'Passende Zeilen',
ofRows: 'von {m} Zeilen',
noMatch: 'Keine Zeile passt. Lockere eine Bedingung.',
inWords: 'In Worten',
showing: 'Erste {n} Treffer',
reset: 'Filter zurücksetzen',
schedule: 'Zeitplan',
scheduleText: 'Verschickt den Bericht von selbst zu den gewählten Zeiten.',
daily: 'Täglich',
weekly: 'Wöchentlich',
monthly: 'Monatlich',
repeat: 'Wiederholen',
on: 'Am',
at: 'Um',
dayOfMonth: 'Tag im Monat',
pickDay: 'Wähle mindestens einen Tag.',
next: 'Nächste Versände',
recipients: 'Empfänger',
addRecipient: 'E-Mail eingeben und Enter drücken',
badEmail: '{v} ist keine E-Mail-Adresse.',
removeRecipient: '{v} entfernen',
added: '{v} hinzugefügt.',
removed: '{v} entfernt.',
format: 'Format',
pdf: 'PDF',
csv: 'CSV',
xlsx: 'Tabelle',
pdfText: 'Diagramme und Tabellen, fertig zum Lesen',
csvText: 'Rohdaten für jedes Werkzeug',
xlsxText: 'Ein Blatt je Tabelle',
message: 'Nachricht',
messagePh: 'Ein kurzer Satz über dem Bericht',
share: 'Link teilen',
linkOn: 'Wer Zugang hat, kann den Link öffnen',
access: 'Wer ihn öffnen kann',
team: 'Dein Team',
anyone: 'Alle mit dem Link',
copy: 'Link kopieren',
copied: 'Link kopiert.',
saveSchedule: 'Zeitplan speichern',
scheduled: 'Gespeichert. Der nächste Bericht geht {when} raus.',
needRecipient: 'Füge mindestens einen Empfänger hinzu.',
templates: 'Vorlagen',
categories: 'Kategorien',
use: 'Vorlage nutzen',
selected: 'Gewählt',
customize: 'Anpassen',
reportName: 'Name des Berichts',
range: 'Zeitraum',
r7: '7 Tage',
r30: '30 Tage',
r90: '90 Tage',
measures: 'Kennzahlen',
create: 'Bericht erstellen',
creating: 'Wird erstellt…',
created: '{name} ist fertig und liegt bei deinen Berichten.',
pickTemplate: 'Wähle eine Vorlage, um sie anzupassen.',
oneMeasure: 'Wähle mindestens eine Kennzahl.',
},
};
type Texts = (typeof TEXTS)['en'];
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
/* ------------------------------------------------------------------ */
/* Motion */
/* ------------------------------------------------------------------ */
/*
* An area waits until it scrolls in, then its children rise (rpb-up) or pop
* (rpb-pop) with --rpb-d as delay. rpb-in runs when something new appears.
* The server renders the end state, without JavaScript nothing waits longer
* than 2.5 s, with reduced motion nothing moves.
*/
const useIso = typeof window === 'undefined' ? useEffect : useLayoutEffect;
const EASE = 'cubic-bezier(0.2, 0.7, 0.2, 1)';
const FX: [string, string, number][] = [
['up', 'opacity:0;transform:translateY(10px)', 600],
['pop', 'opacity:0;transform:scale(.94)', 450],
];
const CSS =
FX.map(([k, v]) => `@keyframes rpb-${k}{from{${v}}}@keyframes rpb-${k}-h{from{${v}}}`).join('') +
'@keyframes rpb-flash{from{background-color:color-mix(in oklab,var(--primary) 14%,transparent)}}' +
'@media (prefers-reduced-motion:no-preference){' +
FX.map(([k, , d]) => `[data-rpb=play] .rpb-${k}{animation:rpb-${k} ${d}ms var(--ease-kit) var(--rpb-d,0ms) both}[data-rpb=wait] .rpb-${k}{animation:rpb-${k}-h 0s 1s both paused}`).join('') +
'.rpb-in{animation:rpb-up .45s var(--ease-kit) var(--rpb-d,0ms) both}.rpb-flash{animation:rpb-flash 1.6s ease-out both}' +
'}@media (scripting:enabled) and (prefers-reduced-motion:no-preference){' +
FX.map(([k]) => `[data-rpb-scope]:not([data-rpb]) .rpb-${k}{animation:rpb-${k}-h 0s 2.5s both}`).join('') +
'}';
function Style() {
return (
<style href="kitb5-report-builder" precedence="default">
{CSS}
</style>
);
}
function useEnter<T extends HTMLElement>() {
const ref = useRef<T>(null);
const [state, setState] = useState<'wait' | 'play' | undefined>(undefined);
useEffect(() => {
const el = ref.current;
if (!el || prefersReducedMotion() || el.getBoundingClientRect().top < window.innerHeight * 0.92) return setState('play');
setState('wait');
const io = new IntersectionObserver(
([e]) => {
if (!e?.isIntersecting) return;
setState('play');
io.disconnect();
},
{ rootMargin: '0px 0px -8% 0px' },
);
io.observe(el);
return () => io.disconnect();
}, []);
return [ref, { 'data-rpb-scope': '', 'data-rpb': state }] as const;
}
type Box = { x: number; y: number; w: number; h: number };
const boxOf = (el: Element, root: HTMLElement): Box => {
const r = el.getBoundingClientRect();
const b = root.getBoundingClientRect();
return { x: r.left - b.left + root.scrollLeft, y: r.top - b.top + root.scrollTop, w: r.width, h: r.height };
};
function useGlide<T extends HTMLElement>(key: unknown) {
const ref = useRef<T>(null);
const before = useRef<Box | null>(null);
if (typeof window !== 'undefined' && ref.current) {
const m = ref.current.querySelector('[data-glide]');
before.current = m ? boxOf(m, ref.current) : null;
}
useIso(() => {
const root = ref.current;
const m = root?.querySelector<HTMLElement>('[data-glide]');
const a = before.current;
if (!root || !m || !a || prefersReducedMotion()) return;
const b = boxOf(m, root);
if (!b.w || !b.h || (Math.abs(a.x - b.x) < 1 && Math.abs(a.y - b.y) < 1 && Math.abs(a.w - b.w) < 1)) return;
m.animate([{ transformOrigin: '0 0', transform: `translate(${a.x - b.x}px, ${a.y - b.y}px) scale(${a.w / b.w}, ${a.h / b.h})` }, { transformOrigin: '0 0', transform: 'none' }], { duration: 420, easing: EASE });
}, [key]);
return ref;
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
const MIN = 60000;
const toMin = (iso: string) => Math.round(Date.parse(iso.length <= 10 ? iso + 'T00:00:00Z' : iso + (iso.endsWith('Z') ? '' : 'Z')) / MIN);
const tagOf = (l: Locale) => (l === 'de' ? 'de-DE' : 'en-US');
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function run(onAction: ReportBuilderProps['onAction'], action: string, data: Record<string, unknown>, ms = 700) {
if (onAction) await onAction(action, data);
else await wait(prefersReducedMotion() ? 0 : ms);
}
const isNum = (f?: ReportField) => f?.type === 'number' || f?.type === 'currency';
type Fmt = ReturnType<typeof makeFmt>;
function makeFmt(locale: Locale, currency: string) {
const tag = tagOf(locale);
const n0 = new Intl.NumberFormat(tag, { maximumFractionDigits: 0 });
const m0 = new Intl.NumberFormat(tag, { style: 'currency', currency, maximumFractionDigits: 0 });
const cell = (f: ReportField | undefined, v: string | number | undefined) => {
if (v === undefined || v === '') return '';
if (f?.type === 'currency') return m0.format(Number(v));
if (f?.type === 'number') return n0.format(Number(v));
if (f?.type === 'date' && typeof v === 'string') {
const d = Date.parse(v.length === 7 ? `${v}-01T00:00:00Z` : `${v.slice(0, 10)}T00:00:00Z`);
if (!Number.isNaN(d)) return new Intl.DateTimeFormat(tag, v.length === 7 ? { month: 'short', year: 'numeric', timeZone: 'UTC' } : { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' }).format(new Date(d));
}
return String(v);
};
return { cell, n: (v: number) => n0.format(v), tag };
}
/** Download a text file in the browser */
function download(name: string, text: string, type = 'text/csv') {
const url = URL.createObjectURL(new Blob([text], { type }));
const a = document.createElement('a');
a.href = url;
a.download = name;
a.click();
URL.revokeObjectURL(url);
}
type P = Omit<ReportBuilderProps, 'variant' | 'sidebar' | 'labels' | 'className'> & { t: Texts; now: string; locale: Locale; f: Fmt; fields: ReportField[]; rows: ReportRow[] };
const focusRing = 'outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[color-mix(in_oklab,var(--primary)_70%,transparent)]';
const field =
'h-11 w-full min-w-0 rounded-lg border border-input bg-background px-3 text-base text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] duration-300 placeholder:text-subtle-foreground hover:border-border-strong focus:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] aria-[invalid=true]:border-destructive sm:h-9 sm:text-[13px]';
const iconBtn = cn('grid size-11 shrink-0 cursor-pointer place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-not-allowed disabled:opacity-30 sm:size-8', focusRing);
const cardCls = 'rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]';
const labelCls = 'grid gap-1.5 text-[12.5px] font-medium text-muted-foreground';
function Header({ title, subtitle, children }: { title?: string; subtitle?: React.ReactNode; children?: React.ReactNode }) {
if (!title && !subtitle && !children) return null;
return (
<div className="mb-6 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div className="min-w-0">
{subtitle && <p className="text-[13px] text-muted-foreground">{subtitle}</p>}
{title && <h1 className="mt-0.5 text-balance text-[clamp(1.5rem,1.2rem+1vw,1.875rem)] font-semibold tracking-[-0.035em] text-foreground">{title}</h1>}
</div>
{children && <div className="grid grid-cols-2 gap-2 max-md:w-full md:flex md:flex-wrap md:items-center">{children}</div>}
</div>
);
}
function Main({ children, className }: { children: React.ReactNode; className?: string }) {
return <main className={cn('mx-auto w-full max-w-[80rem] flex-1 p-4 sm:p-6 lg:p-8', className)}>{children}</main>;
}
function Card({ title, description, action, children, className, bodyClassName }: { title?: React.ReactNode; description?: React.ReactNode; action?: React.ReactNode; children: React.ReactNode; className?: string; bodyClassName?: string }) {
return (
<section className={cn(cardCls, 'flex min-w-0 flex-col', className)}>
{(title || action) && (
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-border px-4 py-3.5 sm:px-5">
<div className="min-w-0">
{title && <h2 className="text-[14px] font-semibold tracking-[-0.01em] text-foreground">{title}</h2>}
{description && <p className="mt-0.5 text-pretty text-[12.5px] text-muted-foreground">{description}</p>}
</div>
{action}
</div>
)}
<div className={cn('flex-1 p-4 sm:p-5', bodyClassName)}>{children}</div>
</section>
);
}
function Live({ text }: { text: string }) {
return (
<p role="status" aria-live="polite" className="sr-only">
{text}
</p>
);
}
function Segmented<T extends string>({ options, value, onChange, label, className }: { options: { value: T; label: React.ReactNode; title?: string }[]; value: T; onChange: (v: T) => void; label: string; className?: string }) {
const ref = useGlide<HTMLDivElement>(value);
const onKey = (e: React.KeyboardEvent, i: number) => {
const dir = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0;
if (!dir) return;
e.preventDefault();
const next = (i + dir + options.length) % options.length;
onChange(options[next].value);
ref.current?.querySelectorAll<HTMLButtonElement>('[role=radio]')[next]?.focus();
};
return (
<div ref={ref} role="radiogroup" aria-label={label} className={cn('relative inline-flex shrink-0 rounded-lg bg-muted p-1', className)}>
{options.map((o, i) => (
<button
key={o.value}
type="button"
role="radio"
aria-checked={value === o.value}
aria-label={o.title}
tabIndex={value === o.value ? 0 : -1}
onClick={() => onChange(o.value)}
onKeyDown={(e) => onKey(e, i)}
className={cn('relative inline-flex h-11 flex-1 max-sm:min-w-11 shrink-0 cursor-pointer items-center justify-center gap-1.5 whitespace-nowrap rounded-md px-3 text-[13px] font-medium transition-colors duration-200 sm:h-8 sm:min-w-fit', focusRing, value === o.value ? 'text-foreground' : 'text-muted-foreground hover:text-foreground')}
>
{value === o.value && <span data-glide aria-hidden className="absolute inset-0 rounded-md bg-background shadow-[var(--shadow-sm)] ring-1 ring-border" />}
<span className="relative z-[1] inline-flex shrink-0 items-center gap-1.5">{o.label}</span>
</button>
))}
</div>
);
}
/** The browser select in the look of the kit fields */
function Pick({ label, value, onChange, children, className, hideLabel, id }: { label: string; value: string; onChange: (v: string) => void; children: React.ReactNode; className?: string; hideLabel?: boolean; id?: string }) {
return (
<label className={cn(labelCls, className)}>
<span className={hideLabel ? 'sr-only' : undefined}>{label}</span>
<span className="relative block min-w-0">
<select id={id} value={value} onChange={(e) => onChange(e.target.value)} className={cn(field, 'cursor-pointer appearance-none truncate pe-8')}>
{children}
</select>
<Icon name="chevrons-up-down" size={14} className="pointer-events-none absolute end-2.5 top-1/2 -translate-y-1/2 text-subtle-foreground" />
</span>
</label>
);
}
const FIELD_ICON: Record<ReportField['type'], IconName> = { text: 'type', date: 'calendar', number: 'hash', currency: 'coins' };
/* ------------------------------------------------------------------ */
/* table-report */
/* ------------------------------------------------------------------ */
function TableReport({ t, f, title, subtitle, name = 'Report', fields, rows, columns, onAction }: P) {
const id = useId();
const [cols, setCols] = useState<string[]>(columns ?? fields.slice(0, 4).map((x) => x.id));
const [group, setGroup] = useState('');
const [sortKey, setSortKey] = useState(cols[0] ?? '');
const [dir, setDir] = useState<'asc' | 'desc'>('desc');
const textFields = fields.filter((x) => !isNum(x));
const [filterField, setFilterField] = useState(textFields[0]?.id ?? '');
const [filterValue, setFilterValue] = useState('');
const [mode, setMode] = useState<'setup' | 'preview'>('setup');
const [drag, setDrag] = useState<string | null>(null);
const [fresh, setFresh] = useState('');
const [state, setState] = useState<'idle' | 'saving' | 'saved'>('idle');
const [msg, setMsg] = useState('');
const [scope, enter] = useEnter<HTMLDivElement>();
const listRef = useRef<HTMLOListElement>(null);
const fieldOf = (fid: string) => fields.find((x) => x.id === fid);
const values = useMemo(() => [...new Set(rows.map((r) => String(r[filterField] ?? '')))].sort(), [rows, filterField]);
const data = useMemo(() => {
const l = rows.filter((r) => !filterValue || String(r[filterField]) === filterValue);
const k = sortKey;
const num = isNum(fieldOf(k));
return [...l].sort((a, b) => {
const x = a[k];
const y = b[k];
const c = num ? Number(x) - Number(y) : String(x).localeCompare(String(y), f.tag);
return dir === 'asc' ? c : -c;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rows, filterField, filterValue, sortKey, dir]);
const groups = useMemo(() => {
if (!group) return [{ key: '', rows: data }];
const out: { key: string; rows: ReportRow[] }[] = [];
data.forEach((r) => {
const k = String(r[group]);
const g = out.find((x) => x.key === k);
if (g) g.rows.push(r);
else out.push({ key: k, rows: [r] });
});
return out;
}, [data, group]);
const sum = (rs: ReportRow[], c: string) => rs.reduce((s, r) => s + Number(r[c] ?? 0), 0);
const move = (i: number, d: -1 | 1) => {
const j = i + d;
if (j < 0 || j >= cols.length) return;
const c = [...cols];
[c[i], c[j]] = [c[j], c[i]];
setCols(c);
setFresh(cols[i]);
setMsg(fill(t.moved, { name: fieldOf(cols[i])?.label ?? '', n: j + 1 }));
requestAnimationFrame(() => listRef.current?.querySelector<HTMLElement>(`[data-col="${cols[i]}"]`)?.focus());
};
const toggle = (fid: string) => {
setCols((c) => (c.includes(fid) ? c.filter((x) => x !== fid) : [...c, fid]));
setFresh(fid);
};
const csv = () => {
const esc = (v: string) => (/[",;\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v);
return [cols.map((c) => esc(fieldOf(c)?.label ?? c)).join(','), ...data.map((r) => cols.map((c) => esc(String(r[c] ?? ''))).join(','))].join('\n');
};
const shown = cols.map(fieldOf).filter((x): x is ReportField => !!x);
const setup = (
<div className="grid content-start gap-5">
<Card title={t.fields} description={t.fieldsHint} className="rpb-up">
<ul className="grid gap-0.5">
{fields.map((x) => {
const on = cols.includes(x.id);
return (
<li key={x.id}>
<label className={cn('flex min-h-11 cursor-pointer items-center gap-3 rounded-md px-2 text-[13.5px] transition-colors hover:bg-accent sm:min-h-9', on ? 'text-foreground' : 'text-muted-foreground')}>
<input type="checkbox" checked={on} onChange={() => toggle(x.id)} className="size-[18px] shrink-0 cursor-pointer accent-[var(--primary)]" />
<Icon name={FIELD_ICON[x.type]} size={14} className="text-subtle-foreground" />
<span className="min-w-0 flex-1 truncate">{x.label}</span>
</label>
</li>
);
})}
</ul>
</Card>
<Card title={t.columns} description={t.columnsHint} className="rpb-up [--rpb-d:80ms]">
<ol ref={listRef} className="grid gap-1.5" aria-describedby={`${id}-ch`}>
{cols.map((c, i) => {
const x = fieldOf(c);
if (!x) return null;
return (
<li
key={c}
data-col={c}
tabIndex={0}
draggable
aria-label={`${x.label}, ${i + 1} / ${cols.length}`}
onDragStart={(e) => {
setDrag(c);
e.dataTransfer.effectAllowed = 'move';
}}
onDragOver={(e) => {
e.preventDefault();
if (!drag || drag === c) return;
const from = cols.indexOf(drag);
const next = cols.filter((y) => y !== drag);
next.splice(i, 0, drag);
if (from !== i) setCols(next);
}}
onDragEnd={() => {
if (drag) setMsg(fill(t.moved, { name: fieldOf(drag)?.label ?? '', n: cols.indexOf(drag) + 1 }));
setDrag(null);
}}
onKeyDown={(e) => {
if (e.altKey && e.key === 'ArrowUp') (e.preventDefault(), move(i, -1));
if (e.altKey && e.key === 'ArrowDown') (e.preventDefault(), move(i, 1));
if (e.key === 'Delete') (e.preventDefault(), toggle(c));
}}
className={cn('group flex min-h-11 cursor-grab items-center gap-2 rounded-lg border bg-surface ps-2 text-[13.5px] text-foreground transition-[border-color,opacity,box-shadow] duration-200 active:cursor-grabbing sm:min-h-10', focusRing, drag === c ? 'border-primary opacity-60 shadow-[var(--shadow-md)]' : 'border-border hover:border-border-strong', c === fresh && 'rpb-in rpb-flash')}
>
<Icon name="grip" size={15} className="shrink-0 text-subtle-foreground" />
<span className="grid size-5 shrink-0 place-items-center rounded bg-card text-[11px] font-semibold tabular-nums text-muted-foreground ring-1 ring-border">{i + 1}</span>
<span className="min-w-0 flex-1 truncate">{x.label}</span>
<span className="flex">
<button type="button" aria-label={fill(t.moveUp, { name: x.label })} disabled={i === 0} onClick={() => move(i, -1)} className={iconBtn}>
<Icon name="arrow-up" size={14} />
</button>
<button type="button" aria-label={fill(t.moveDown, { name: x.label })} disabled={i === cols.length - 1} onClick={() => move(i, 1)} className={iconBtn}>
<Icon name="arrow-down" size={14} />
</button>
<button type="button" aria-label={fill(t.remove, { name: x.label })} onClick={() => toggle(c)} className={cn(iconBtn, 'hover:text-destructive')}>
<Icon name="x" size={14} />
</button>
</span>
</li>
);
})}
</ol>
<p id={`${id}-ch`} className="sr-only">
{t.columnsHint}
</p>
</Card>
<Card className="rpb-up [--rpb-d:140ms]" bodyClassName="grid gap-4">
<Pick label={t.groupBy} value={group} onChange={setGroup}>
<option value="">{t.none}</option>
{textFields.map((x) => (
<option key={x.id} value={x.id}>
{x.label}
</option>
))}
</Pick>
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-end gap-2">
<Pick label={t.sortBy} value={sortKey} onChange={setSortKey}>
{fields.map((x) => (
<option key={x.id} value={x.id}>
{x.label}
</option>
))}
</Pick>
<Segmented
label={t.direction}
value={dir}
onChange={setDir}
options={[
{ value: 'asc', label: <Icon name="arrow-up" size={15} />, title: t.ascending },
{ value: 'desc', label: <Icon name="arrow-down" size={15} />, title: t.descending },
]}
/>
</div>
<div className="grid grid-cols-2 gap-2">
<Pick label={t.filter} value={filterField} onChange={(v) => (setFilterField(v), setFilterValue(''))}>
{textFields.map((x) => (
<option key={x.id} value={x.id}>
{x.label}
</option>
))}
</Pick>
<Pick label={t.value} value={filterValue} onChange={setFilterValue}>
<option value="">{t.allValues}</option>
{values.map((v) => (
<option key={v} value={v}>
{f.cell(fieldOf(filterField), v)}
</option>
))}
</Pick>
</div>
</Card>
</div>
);
const preview = (
<section aria-labelledby={`${id}-pv`} className={cn(cardCls, 'rpb-up min-w-0 overflow-hidden [--rpb-d:100ms]')}>
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border px-4 py-3 sm:px-5">
<h2 id={`${id}-pv`} className="text-[14px] font-semibold text-foreground">
{t.preview}
</h2>
<span key={data.length} className="rpb-in text-[12.5px] tabular-nums text-muted-foreground">
{fill(t.rowsN, { n: f.n(data.length) })}
</span>
</div>
{shown.length ? (
<div className="kit-scrollbar relative max-h-[36rem] overflow-auto">
<table className="w-full border-collapse text-[13px]">
<thead className="sticky top-0 z-[1] bg-surface">
<tr className="border-b border-border">
{shown.map((c) => (
<th key={c.id} scope="col" aria-sort={sortKey === c.id ? (dir === 'asc' ? 'ascending' : 'descending') : undefined} className={cn('whitespace-nowrap px-4 py-2.5 text-[12px] font-medium text-muted-foreground', isNum(c) ? 'text-right' : 'text-left')}>
<button type="button" onClick={() => (sortKey === c.id ? setDir(dir === 'asc' ? 'desc' : 'asc') : (setSortKey(c.id), setDir(isNum(c) ? 'desc' : 'asc')))} className={cn('inline-flex cursor-pointer items-center gap-1 rounded-sm hover:text-foreground', focusRing, sortKey === c.id && 'text-foreground', isNum(c) && 'flex-row-reverse')}>
{c.label}
{sortKey === c.id && <Icon name={dir === 'asc' ? 'arrow-up' : 'arrow-down'} size={12} />}
</button>
</th>
))}
</tr>
</thead>
{groups.map((g) => (
<tbody key={g.key + cols.join() + sortKey + dir} className="rpb-in">
{group && (
<tr className="border-b border-border bg-[color-mix(in_oklab,var(--primary)_5%,var(--card))]">
<th scope="colgroup" colSpan={shown.length} className="px-4 py-2 text-left text-[12.5px] font-semibold text-foreground">
{f.cell(fieldOf(group), g.key)}
<span className="ms-2 font-normal tabular-nums text-muted-foreground">{fill(t.rowsN, { n: g.rows.length })}</span>
</th>
</tr>
)}
{g.rows.map((r, i) => (
<tr key={i} className="border-b border-border transition-colors hover:bg-[color-mix(in_oklab,var(--foreground)_2.5%,transparent)]">
{shown.map((c) => (
<td key={c.id} className={cn('whitespace-nowrap px-4 py-2', isNum(c) ? 'text-right tabular-nums text-foreground' : 'text-muted-foreground')}>
{f.cell(c, r[c.id])}
</td>
))}
</tr>
))}
{group && (
<tr className="border-b border-border-strong">
{shown.map((c, k) => (
<td key={c.id} className={cn('whitespace-nowrap px-4 py-2 text-[12.5px] font-medium', isNum(c) ? 'text-right tabular-nums text-foreground' : 'text-muted-foreground')}>
{isNum(c) ? f.cell(c, sum(g.rows, c.id)) : k === 0 ? t.subtotal : ''}
</td>
))}
</tr>
)}
</tbody>
))}
<tfoot className="sticky bottom-0 bg-surface">
<tr className="border-t border-border-strong">
{shown.map((c, k) => (
<td key={c.id} className={cn('whitespace-nowrap px-4 py-2.5 font-semibold', isNum(c) ? 'text-right tabular-nums text-foreground' : 'text-foreground')}>
{isNum(c) ? f.cell(c, sum(data, c.id)) : k === 0 ? t.total : ''}
</td>
))}
</tr>
</tfoot>
</table>
</div>
) : (
<p className="px-6 py-16 text-center text-[13.5px] text-muted-foreground">{t.empty}</p>
)}
</section>
);
return (
<Main>
<Header title={title} subtitle={subtitle}>
<Button
size="sm"
variant="outline"
iconLeft="download"
disabled={!shown.length}
onClick={() => {
if (onAction) void onAction('export', { csv: csv() });
else download(`${name}.csv`, csv());
setMsg(fill(t.exported, { name }));
}}
>
{t.export}
</Button>
<Button
size="sm"
iconLeft={state === 'saving' ? 'loader' : state === 'saved' ? 'check' : 'file-check'}
disabled={state === 'saving'}
onClick={async () => {
setState('saving');
await run(onAction, 'save', { columns: cols, group, sort: { key: sortKey, dir }, filter: { field: filterField, value: filterValue } });
setState('saved');
setMsg(t.saved);
}}
>
{state === 'saving' ? t.saving : state === 'saved' ? t.saved : t.save}
</Button>
<Segmented label={t.mode} value={mode} onChange={setMode} className="col-span-2 lg:hidden" options={[{ value: 'setup', label: <><Icon name="sliders" size={14} />{t.setup}</> }, { value: 'preview', label: <><Icon name="table" size={14} />{t.preview}</> }]} />
</Header>
<div ref={scope} {...enter} className="grid grid-cols-[minmax(0,1fr)] gap-5 lg:grid-cols-[19rem_minmax(0,1fr)] lg:items-start">
<div className={cn('lg:block', mode === 'setup' ? 'block' : 'hidden')}>{setup}</div>
<div className={cn('min-w-0 lg:block', mode === 'preview' ? 'block rpb-in' : 'hidden')}>{preview}</div>
</div>
<Live text={msg} />
</Main>
);
}
/* ------------------------------------------------------------------ */
/* chart-report */
/* ------------------------------------------------------------------ */
type ChartKind = 'bar' | 'line' | 'area' | 'donut';
type Agg = 'sum' | 'avg' | 'count';
const KIND_ICON: Record<ChartKind, IconName> = { bar: 'bar-chart', line: 'line-chart', area: 'activity', donut: 'pie-chart' };
function ChartReport({ t, f, title, subtitle, fields, rows, currency = 'EUR', onAction }: P) {
const cid = useId();
const dims = fields.filter((x) => !isNum(x));
const nums = fields.filter(isNum);
const [kind, setKind] = useState<ChartKind>('bar');
const [dim, setDim] = useState(dims.find((x) => x.type === 'date')?.id ?? dims[0]?.id ?? '');
const [measure, setMeasure] = useState(nums.find((x) => x.type === 'currency')?.id ?? nums[0]?.id ?? '');
const [agg, setAgg] = useState<Agg>('sum');
const [split, setSplit] = useState('');
const [top, setTop] = useState('5');
const [state, setState] = useState<'idle' | 'saving' | 'saved'>('idle');
const [msg, setMsg] = useState('');
const [scope, enter] = useEnter<HTMLDivElement>();
const typeRef = useGlide<HTMLDivElement>(kind);
const dField = fields.find((x) => x.id === dim);
const mField = fields.find((x) => x.id === measure);
const sField = fields.find((x) => x.id === split);
const isDate = dField?.type === 'date';
const useSplit = !!split && split !== dim && kind !== 'donut';
const calc = (rs: ReportRow[]) => (agg === 'count' ? rs.length : agg === 'avg' ? (rs.length ? rs.reduce((s, r) => s + Number(r[measure] ?? 0), 0) / rs.length : 0) : rs.reduce((s, r) => s + Number(r[measure] ?? 0), 0));
const { data, series } = useMemo(() => {
const keys = [...new Set(rows.map((r) => String(r[dim])))];
let ordered = keys.map((k) => ({ k, v: calc(rows.filter((r) => String(r[dim]) === k)) }));
ordered = isDate ? ordered.sort((a, b) => a.k.localeCompare(b.k)) : ordered.sort((a, b) => b.v - a.v);
if (!isDate && top !== 'all') ordered = ordered.slice(0, Number(top));
const splits = useSplit ? [...new Set(rows.map((r) => String(r[split])))].map((k) => ({ k, v: calc(rows.filter((r) => String(r[split]) === k)) })).sort((a, b) => b.v - a.v).slice(0, 4).map((x) => x.k) : [];
const d = ordered.map(({ k, v }) => {
const row: Record<string, string | number> = { x: k };
if (useSplit) splits.forEach((s, i) => (row[`s${i}`] = Math.round(calc(rows.filter((r) => String(r[dim]) === k && String(r[split]) === s)) * 100) / 100));
else row.v = Math.round(v * 100) / 100;
return row;
});
const se = useSplit ? splits.map((s, i) => ({ key: `s${i}`, label: s })) : [{ key: 'v', label: agg === 'count' ? t.countOf : (mField?.label ?? '') }];
return { data: d, series: se };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rows, dim, measure, agg, split, top, kind, isDate]);
const format: Intl.NumberFormatOptions = agg !== 'count' && mField?.type === 'currency' ? { style: 'currency', currency, maximumFractionDigits: 0 } : { maximumFractionDigits: agg === 'avg' ? 1 : 0 };
const name = useSplit ? fill(t.chartTitleSplit, { measure: agg === 'count' ? t.countOf : (mField?.label ?? ''), dimension: dField?.label ?? '', split: sField?.label ?? '' }) : fill(t.chartTitle, { measure: agg === 'count' ? t.countOf : (mField?.label ?? ''), dimension: dField?.label ?? '' });
const kinds: ChartKind[] = ['bar', 'line', 'area', 'donut'];
const donut = (() => {
const s = [...data].sort((a, b) => Number(b.v) - Number(a.v));
const head = s.slice(0, 4).map((x) => ({ label: f.cell(dField, String(x.x)), value: Number(x.v) }));
const rest = s.slice(4).reduce((a, x) => a + Number(x.v), 0);
return rest ? [...head.slice(0, 3), { label: t.other, value: head[3].value + rest }] : head;
})();
const chartKey = [kind, dim, measure, agg, split, top].join('-');
return (
<Main>
<Header title={title} subtitle={subtitle}>
<Button
size="sm"
className="col-span-2"
iconLeft={state === 'saving' ? 'loader' : state === 'saved' ? 'check' : 'file-check'}
disabled={state === 'saving'}
onClick={async () => {
setState('saving');
await run(onAction, 'save', { kind, dimension: dim, measure, aggregation: agg, split, top });
setState('saved');
setMsg(t.saved);
}}
>
{state === 'saving' ? t.saving : state === 'saved' ? t.saved : t.save}
</Button>
</Header>
<div ref={scope} {...enter} className="grid grid-cols-[minmax(0,1fr)] gap-5 lg:grid-cols-[19rem_minmax(0,1fr)] lg:items-start">
<section aria-label={t.setup} className={cn(cardCls, 'rpb-up grid gap-5 p-4 sm:p-5 lg:sticky lg:top-6')}>
<div>
<p className="mb-2 text-[12.5px] font-medium text-muted-foreground">{t.chartType}</p>
<div
ref={typeRef}
role="radiogroup"
aria-label={t.chartType}
className="relative grid grid-cols-4 gap-2"
onKeyDown={(e) => {
const d = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0;
if (!d) return;
e.preventDefault();
const n = kinds[(kinds.indexOf(kind) + d + kinds.length) % kinds.length];
setKind(n);
typeRef.current?.querySelector<HTMLButtonElement>(`[data-k=${n}]`)?.focus();
}}
>
{kinds.map((k) => {
const on = kind === k;
return (
<button key={k} data-k={k} type="button" role="radio" aria-checked={on} tabIndex={on ? 0 : -1} onClick={() => setKind(k)} className={cn('group relative grid h-16 cursor-pointer content-center justify-items-center gap-1 rounded-lg border text-[12px] font-medium transition-[border-color,color,translate] duration-300 hover:-translate-y-0.5', focusRing, on ? 'border-transparent text-foreground' : 'border-border text-muted-foreground hover:border-border-strong hover:text-foreground')}>
{on && <span data-glide aria-hidden className="absolute inset-0 rounded-lg bg-[color-mix(in_oklab,var(--primary)_8%,var(--card))] ring-2 ring-primary" />}
<Icon name={KIND_ICON[k]} size={18} className={cn('relative', on && 'text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]')} />
<span className="relative">{t[k]}</span>
</button>
);
})}
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-1">
<Pick label={t.dimension} value={dim} onChange={setDim}>
{dims.map((x) => (
<option key={x.id} value={x.id}>
{x.label}
</option>
))}
</Pick>
<Pick label={t.measure} value={measure} onChange={setMeasure}>
{nums.map((x) => (
<option key={x.id} value={x.id}>
{x.label}
</option>
))}
</Pick>
</div>
<div>
<p className="mb-2 text-[12.5px] font-medium text-muted-foreground">{t.aggregation}</p>
<Segmented label={t.aggregation} value={agg} onChange={setAgg} className="flex w-full" options={(['sum', 'avg', 'count'] as Agg[]).map((a) => ({ value: a, label: t[a] }))} />
</div>
<div className="grid grid-cols-2 gap-3">
<Pick label={t.split} value={useSplit ? split : ''} onChange={setSplit}>
<option value="">{t.none}</option>
{dims
.filter((x) => x.id !== dim && x.type !== 'date')
.map((x) => (
<option key={x.id} value={x.id}>
{x.label}
</option>
))}
</Pick>
<Pick label={t.top} value={isDate ? 'all' : top} onChange={setTop}>
{['5', '10', 'all'].map((n) => (
<option key={n} value={n}>
{n === 'all' ? t.all : fill(t.topN, { n })}
</option>
))}
</Pick>
</div>
</section>
<section aria-labelledby={`${cid}-title`} className={cn(cardCls, 'rpb-up min-w-0 p-4 [--rpb-d:100ms] sm:p-6')}>
<h2 id={`${cid}-title`} key={name} className="rpb-in text-balance text-[18px] font-semibold tracking-[-0.02em] text-foreground">
{name}
</h2>
<p className="mt-1 text-[12.5px] tabular-nums text-muted-foreground">{fill(t.rowsN, { n: f.n(rows.length) })}</p>
<div key={chartKey} className="rpb-in mt-5">
{kind === 'bar' && <BarChart data={data} xKey="x" series={series} stacked={useSplit} format={format} xFormat={isDate ? { month: 'short' } : undefined} height={320} label={name} highlight={!useSplit && !isDate ? String(data[0]?.x ?? '') : undefined} />}
{kind === 'line' && <LineChart data={data} xKey="x" series={series} format={format} xFormat={isDate ? { month: 'short' } : undefined} height={320} label={name} endLabels={!useSplit} />}
{kind === 'area' && <AreaChart data={data} xKey="x" series={series} stacked={useSplit} fill="gradient" format={format} xFormat={isDate ? { month: 'short' } : undefined} height={320} label={name} />}
{kind === 'donut' && (
<>
<DonutChart data={donut} format={format} label={name} size={240} legend="side" />
<p className="mt-4 text-[12.5px] text-subtle-foreground">{t.donutHint}</p>
</>
)}
</div>
</section>
</div>
<Live text={msg} />
</Main>
);
}
/* ------------------------------------------------------------------ */
/* filters */
/* ------------------------------------------------------------------ */
const TEXT_OPS: ReportCondition['op'][] = ['is', 'is-not', 'contains'];
const NUM_OPS: ReportCondition['op'][] = ['gt', 'lt', 'is', 'is-not'];
function test(r: ReportRow, c: ReportCondition, fd?: ReportField) {
const v = r[c.field];
if (c.value === '') return true;
if (isNum(fd)) {
const n = Number(String(c.value).replace(/\./g, '').replace(',', '.'));
if (!Number.isFinite(n)) return true;
const x = Number(v);
return c.op === 'gt' ? x > n : c.op === 'lt' ? x < n : c.op === 'is' ? x === n : c.op === 'is-not' ? x !== n : true;
}
const s = String(v).toLowerCase();
const q = c.value.toLowerCase();
return c.op === 'is' ? s === q : c.op === 'is-not' ? s !== q : c.op === 'contains' ? s.includes(q) : true;
}
function Filters({ t, f, title, subtitle, fields, rows, groups: start, locale, onAction }: P) {
const id = useId();
const blank = (gi = 0): ReportGroup => ({ id: `g${Date.now()}${gi}`, join: 'and', conditions: [{ id: `c${Date.now()}`, field: fields[0]?.id ?? '', op: isNum(fields[0]) ? 'gt' : 'is', value: '' }] });
const [groups, setGroups] = useState<ReportGroup[]>(start?.length ? start : [blank()]);
const [top, setTop] = useState<'and' | 'or'>('and');
const [fresh, setFresh] = useState('');
const [state, setState] = useState<'idle' | 'saving' | 'saved'>('idle');
const [scope, enter] = useEnter<HTMLDivElement>();
const fieldOf = (fid: string) => fields.find((x) => x.id === fid);
const quote = (v: string) => (locale === 'de' ? `„${v}“` : `“${v}”`);
const match = (r: ReportRow) => {
const res = groups.filter((g) => g.conditions.length).map((g) => (g.join === 'and' ? g.conditions.every((c) => test(r, c, fieldOf(c.field))) : g.conditions.some((c) => test(r, c, fieldOf(c.field)))));
if (!res.length) return true;
return top === 'and' ? res.every(Boolean) : res.some(Boolean);
};
const hits = rows.filter(match);
const pct = rows.length ? Math.round((hits.length / rows.length) * 100) : 0;
const setG = (gid: string, p: Partial<ReportGroup>) => (setGroups((l) => l.map((g) => (g.id === gid ? { ...g, ...p } : g))), setState('idle'));
const setC = (g: ReportGroup, cid: string, p: Partial<ReportCondition>) => setG(g.id, { conditions: g.conditions.map((c) => (c.id === cid ? { ...c, ...p } : c)) });
const valuesOf = (fid: string) => [...new Set(rows.map((r) => String(r[fid])))].sort();
const words = groups
.filter((g) => g.conditions.length)
.map((g) => g.conditions.map((c) => `${fieldOf(c.field)?.label ?? c.field} ${t[c.op]} ${c.value ? (isNum(fieldOf(c.field)) ? c.value : quote(f.cell(fieldOf(c.field), c.value))) : '…'}`).join(` ${t[g.join]} `))
.map((s, _, a) => (a.length > 1 ? `(${s})` : s))
.join(` ${t[top]} `);
const cols = fields.slice(0, 5);
return (
<Main>
<Header title={title} subtitle={subtitle}>
<Button size="sm" variant="ghost" iconLeft="rotate-ccw" onClick={() => (setGroups([blank()]), setTop('and'))}>
{t.reset}
</Button>
<Button
size="sm"
iconLeft={state === 'saving' ? 'loader' : state === 'saved' ? 'check' : 'file-check'}
disabled={state === 'saving'}
onClick={async () => {
setState('saving');
await run(onAction, 'save', { groups, join: top });
setState('saved');
}}
>
{state === 'saving' ? t.saving : state === 'saved' ? t.saved : t.save}
</Button>
</Header>
<div ref={scope} {...enter} className="grid grid-cols-[minmax(0,1fr)] gap-5 xl:grid-cols-[minmax(0,1.15fr)_minmax(0,1fr)] xl:items-start">
<section aria-labelledby={`${id}-c`} className="grid min-w-0 gap-3">
<div className="rpb-up flex flex-wrap items-center justify-between gap-3">
<h2 id={`${id}-c`} className="text-[14px] font-semibold text-foreground">
{t.conditions}
</h2>
{groups.length > 1 && <Segmented label={t.groupJoin} value={top} onChange={setTop} options={[{ value: 'and', label: t.matchAll }, { value: 'or', label: t.matchAny }]} />}
</div>
{groups.map((g, gi) => (
<div key={g.id} className="grid gap-3">
{gi > 0 && (
<div className="flex items-center gap-3" aria-hidden>
<span className="h-px flex-1 bg-border" />
<span className="rounded-md bg-muted px-2 py-0.5 text-[11.5px] font-semibold uppercase tracking-[0.06em] text-muted-foreground">{t[top]}</span>
<span className="h-px flex-1 bg-border" />
</div>
)}
<fieldset className={cn(cardCls, 'rpb-up min-w-0 p-3 sm:p-4', g.id === fresh && 'rpb-in')} style={{ '--rpb-d': `${gi * 80}ms` } as React.CSSProperties}>
<legend className="sr-only">{fill(t.group, { n: gi + 1 })}</legend>
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<span className="text-[12.5px] font-semibold text-muted-foreground">{fill(t.group, { n: gi + 1 })}</span>
<span className="flex items-center gap-1">
{g.conditions.length > 1 && <Segmented label={fill(t.conditionJoin, { n: gi + 1 })} value={g.join} onChange={(v) => setG(g.id, { join: v })} options={[{ value: 'and', label: t.and }, { value: 'or', label: t.or }]} />}
{groups.length > 1 && (
<button type="button" aria-label={fill(t.removeGroup, { n: gi + 1 })} onClick={() => setGroups((l) => l.filter((x) => x.id !== g.id))} className={cn(iconBtn, 'hover:text-destructive')}>
<Icon name="trash" size={15} />
</button>
)}
</span>
</div>
<ul className="grid gap-2">
{g.conditions.map((c, ci) => {
const fd = fieldOf(c.field);
const ops = isNum(fd) ? NUM_OPS : TEXT_OPS;
return (
<li key={c.id} className={cn('grid gap-2', c.id === fresh && 'rpb-in')}>
{ci > 0 && <span className="ps-1 text-[11.5px] font-semibold uppercase tracking-[0.06em] text-subtle-foreground">{t[g.join]}</span>}
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,0.9fr)_minmax(0,1fr)_auto]">
<Pick hideLabel label={t.field} value={c.field} onChange={(v) => setC(g, c.id, { field: v, op: isNum(fieldOf(v)) ? 'gt' : 'is', value: '' })}>
{fields.map((x) => (
<option key={x.id} value={x.id}>
{x.label}
</option>
))}
</Pick>
<button type="button" aria-label={t.removeCondition} disabled={g.conditions.length === 1 && groups.length === 1} onClick={() => (g.conditions.length === 1 ? setGroups((l) => l.filter((x) => x.id !== g.id)) : setG(g.id, { conditions: g.conditions.filter((x) => x.id !== c.id) }))} className={cn(iconBtn, 'hover:text-destructive sm:order-last sm:size-9')}>
<Icon name="x" size={15} />
</button>
<Pick hideLabel label={t.op} value={c.op} onChange={(v) => setC(g, c.id, { op: v as ReportCondition['op'] })} className="max-sm:col-span-2">
{ops.map((o) => (
<option key={o} value={o}>
{t[o]}
</option>
))}
</Pick>
{isNum(fd) || c.op === 'contains' ? (
<label className="max-sm:col-span-2">
<span className="sr-only">{t.value}</span>
<input inputMode={isNum(fd) ? 'decimal' : 'text'} value={c.value} placeholder={t.value} onChange={(e) => setC(g, c.id, { value: e.target.value })} className={cn(field, isNum(fd) && 'tabular-nums')} />
</label>
) : (
<Pick hideLabel label={t.value} value={c.value} onChange={(v) => setC(g, c.id, { value: v })} className="max-sm:col-span-2">
<option value="">{t.allValues}</option>
{valuesOf(c.field).map((v) => (
<option key={v} value={v}>
{f.cell(fd, v)}
</option>
))}
</Pick>
)}
</div>
</li>
);
})}
</ul>
<Button
variant="ghost"
size="sm"
iconLeft="plus"
className="mt-2"
onClick={() => {
const c: ReportCondition = { id: `c${Date.now()}`, field: fields[0]?.id ?? '', op: isNum(fields[0]) ? 'gt' : 'is', value: '' };
setG(g.id, { conditions: [...g.conditions, c] });
setFresh(c.id);
}}
>
{t.addCondition}
</Button>
</fieldset>
</div>
))}
<Button
variant="outline"
size="sm"
iconLeft="plus-circle"
className="rpb-up justify-self-start border-dashed"
onClick={() => {
const g = blank(groups.length);
setGroups((l) => [...l, g]);
setFresh(g.id);
}}
>
{t.addGroup}
</Button>
</section>
<div className="grid min-w-0 gap-5 xl:sticky xl:top-6">
<section className={cn(cardCls, 'rpb-up p-4 [--rpb-d:80ms] sm:p-5')}>
<h2 className="text-[13px] font-medium text-muted-foreground">{t.hits}</h2>
<p role="status" aria-live="polite" className="mt-1 flex items-baseline gap-2">
<span aria-hidden key={hits.length} className="rpb-in text-[32px] font-semibold leading-none tracking-[-0.04em] tabular-nums text-foreground">
{f.n(hits.length)}
</span>
<span aria-hidden className="text-[13.5px] text-muted-foreground">{fill(t.ofRows, { m: f.n(rows.length) })}</span>
<span className="sr-only">{fill(t.matches, { n: f.n(hits.length), m: f.n(rows.length) })}</span>
</p>
<span className="mt-3 block h-2 overflow-hidden rounded-full bg-track" aria-hidden>
<span className="block h-full rounded-full bg-primary transition-[width] duration-500 ease-[var(--ease-kit)]" style={{ width: `${pct}%` }} />
</span>
<h3 className="mt-5 text-[12.5px] font-medium text-muted-foreground">{t.inWords}</h3>
<p key={words} className="rpb-in mt-1 text-pretty text-[13.5px] leading-relaxed text-foreground">
<span className="font-medium">{t.where}</span> {words || '…'}
</p>
</section>
<section aria-labelledby={`${id}-r`} className={cn(cardCls, 'rpb-up min-w-0 overflow-hidden [--rpb-d:140ms]')}>
<h2 id={`${id}-r`} className="border-b border-border px-4 py-3 text-[13px] font-semibold text-foreground sm:px-5">
{fill(t.showing, { n: Math.min(6, hits.length) })}
</h2>
{hits.length ? (
<div className="kit-scrollbar relative overflow-x-auto">
<table className="w-full border-collapse text-[12.5px]">
<thead>
<tr className="border-b border-border bg-surface">
{cols.map((c) => (
<th key={c.id} scope="col" className={cn('whitespace-nowrap px-3 py-2 font-medium text-muted-foreground first:ps-4 last:pe-4', isNum(c) ? 'text-right' : 'text-left')}>
{c.label}
</th>
))}
</tr>
</thead>
<tbody>
{hits.slice(0, 6).map((r, i) => (
<tr key={i} className="rpb-in border-b border-border last:border-b-0" style={{ '--rpb-d': `${i * 30}ms` } as React.CSSProperties}>
{cols.map((c) => (
<td key={c.id} className={cn('whitespace-nowrap px-3 py-2 first:ps-4 last:pe-4', isNum(c) ? 'text-right tabular-nums text-foreground' : 'text-muted-foreground')}>
{f.cell(c, r[c.id])}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="px-6 py-10 text-center text-[13.5px] text-muted-foreground">{t.noMatch}</p>
)}
</section>
</div>
</div>
</Main>
);
}
/* ------------------------------------------------------------------ */
/* schedule-share */
/* ------------------------------------------------------------------ */
type Freq = 'daily' | 'weekly' | 'monthly';
type Format = 'pdf' | 'csv' | 'xlsx';
const FORMAT_ICON: Record<Format, IconName> = { pdf: 'file', csv: 'file-code', xlsx: 'table' };
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
function ScheduleShare({ t, f, title, subtitle, name = 'Report', recipients = [], shareUrl = 'https://app.example.com/r/q3-revenue', now, locale, onAction }: P) {
const id = useId();
const [freq, setFreq] = useState<Freq>('weekly');
const [days, setDays] = useState<number[]>([0]);
const [time, setTime] = useState('08:00');
const [dom, setDom] = useState(1);
const [people, setPeople] = useState(recipients);
const [draft, setDraft] = useState('');
const [bad, setBad] = useState('');
const [format, setFormat] = useState<Format>('pdf');
const [message, setMessage] = useState('');
const [link, setLink] = useState(true);
const [access, setAccess] = useState<'team' | 'anyone'>('team');
const [copied, setCopied] = useState(false);
const [state, setState] = useState<'idle' | 'busy' | 'saved'>('idle');
const [tried, setTried] = useState(false);
const [msg, setMsg] = useState('');
const [scope, enter] = useEnter<HTMLDivElement>();
const fmtRef = useGlide<HTMLDivElement>(format);
const tag = f.tag;
const dayNames = useMemo(() => Array.from({ length: 7 }, (_, i) => new Intl.DateTimeFormat(tag, { weekday: 'short', timeZone: 'UTC' }).format(new Date(Date.UTC(2026, 9, 19 + i)))), [tag]);
const longDays = useMemo(() => Array.from({ length: 7 }, (_, i) => new Intl.DateTimeFormat(tag, { weekday: 'long', timeZone: 'UTC' }).format(new Date(Date.UTC(2026, 9, 19 + i)))), [tag]);
const next = useMemo(() => {
const [h, m] = time.split(':').map(Number);
const start = toMin(now);
const out: number[] = [];
let day = Math.floor(start / 1440);
for (let k = 0; k < 400 && out.length < 5; k++, day++) {
const at = day * 1440 + (h || 0) * 60 + (m || 0);
if (at <= start) continue;
const d = new Date(day * 1440 * MIN);
const wd = (d.getUTCDay() + 6) % 7;
if (freq === 'daily' || (freq === 'weekly' && days.includes(wd)) || (freq === 'monthly' && d.getUTCDate() === dom)) out.push(at);
}
return out;
}, [freq, days, time, dom, now]);
const fmtWhen = (min: number) => new Intl.DateTimeFormat(tag, { weekday: 'short', day: 'numeric', month: 'short', hour: locale === 'de' ? '2-digit' : 'numeric', minute: '2-digit', timeZone: 'UTC' }).format(new Date(min * MIN));
const add = (raw: string) => {
const parts = raw.split(/[,;\s]+/).map((x) => x.trim()).filter(Boolean);
const okList = parts.filter((p) => EMAIL.test(p) && !people.includes(p));
const wrong = parts.find((p) => !EMAIL.test(p));
if (okList.length) {
setPeople((l) => [...l, ...okList]);
setMsg(fill(t.added, { v: okList.join(', ') }));
}
setBad(wrong ? fill(t.badEmail, { v: wrong }) : '');
setDraft(wrong ?? '');
};
const noDay = freq === 'weekly' && !days.length;
const noPeople = tried && !people.length;
const formats: Format[] = ['pdf', 'csv', 'xlsx'];
return (
<Main>
<Header title={title} subtitle={subtitle} />
<div ref={scope} {...enter} className="grid grid-cols-[minmax(0,1fr)] gap-5 xl:grid-cols-[minmax(0,1fr)_21rem] xl:items-start">
<div className="grid min-w-0 gap-5">
<Card title={t.schedule} description={t.scheduleText} className="rpb-up" bodyClassName="grid gap-5">
<div>
<p className="mb-2 text-[12.5px] font-medium text-muted-foreground">{t.repeat}</p>
<Segmented label={t.repeat} value={freq} onChange={(v) => (setFreq(v), setState('idle'))} className="flex w-full sm:inline-flex sm:w-auto" options={(['daily', 'weekly', 'monthly'] as Freq[]).map((x) => ({ value: x, label: t[x] }))} />
</div>
<div key={freq} className="rpb-in grid gap-4 sm:grid-cols-[minmax(0,1fr)_10rem]">
{freq === 'weekly' && (
<fieldset className="min-w-0">
<legend className="mb-2 text-[12.5px] font-medium text-muted-foreground">{t.on}</legend>
<div className="grid grid-cols-7 gap-1">
{dayNames.map((d, i) => {
const on = days.includes(i);
return (
<button key={i} type="button" aria-pressed={on} aria-label={longDays[i]} onClick={() => setDays((xs) => (on ? xs.filter((x) => x !== i) : [...xs, i].sort()))} className={cn('h-11 cursor-pointer rounded-lg border text-[12.5px] font-medium transition-[background-color,border-color,color,transform] duration-200 active:scale-95 sm:h-9', focusRing, on ? 'border-transparent bg-primary text-primary-foreground' : 'border-border text-muted-foreground hover:border-border-strong hover:text-foreground')}>
{d.replace('.', '')}
</button>
);
})}
</div>
{noDay && <p className="rpb-in mt-2 text-[12.5px] font-medium text-destructive">{t.pickDay}</p>}
</fieldset>
)}
{freq === 'monthly' && (
<label className={labelCls}>
{t.dayOfMonth}
<input type="number" min={1} max={28} value={dom} onChange={(e) => setDom(Math.max(1, Math.min(28, Number(e.target.value) || 1)))} className={cn(field, 'max-w-[8rem] tabular-nums')} />
</label>
)}
{freq === 'daily' && <span className="hidden sm:block" />}
<label className={labelCls}>
{t.at}
<input type="time" value={time} onChange={(e) => setTime(e.target.value || '08:00')} className={cn(field, 'tabular-nums')} />
</label>
</div>
</Card>
<Card title={t.recipients} className="rpb-up [--rpb-d:80ms]" bodyClassName="grid gap-5">
<div>
{/* Recipients as chips in front of the field, Backspace removes the last one */}
<div className={cn('flex min-h-11 flex-wrap items-center gap-1.5 rounded-lg border bg-background p-1.5 shadow-[var(--shadow-sm)] transition-[border-color,box-shadow] duration-300 focus-within:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus-within:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)]', bad || noPeople ? 'border-destructive' : 'border-input')}>
<ul className="contents" aria-label={t.recipients}>
{people.map((p) => (
<li key={p} className="rpb-in inline-flex h-8 max-w-full items-center gap-1 rounded-md bg-muted ps-2.5 text-[13px] text-foreground">
<span className="truncate">{p}</span>
<button type="button" aria-label={fill(t.removeRecipient, { v: p })} onClick={() => (setPeople((l) => l.filter((x) => x !== p)), setMsg(fill(t.removed, { v: p })))} className={cn('relative grid size-7 shrink-0 cursor-pointer place-items-center rounded text-muted-foreground hover:text-destructive', 'after:absolute after:-inset-1.5 max-sm:after:-inset-2', focusRing)}>
<Icon name="x" size={13} />
</button>
</li>
))}
</ul>
<label className="min-w-[12rem] flex-1">
<span className="sr-only">{t.addRecipient}</span>
<input
type="email"
value={draft}
placeholder={t.addRecipient}
aria-invalid={!!bad}
aria-describedby={bad ? `${id}-bad` : undefined}
onChange={(e) => (setDraft(e.target.value), setBad(''))}
onBlur={() => draft.trim() && add(draft)}
onPaste={(e) => {
const text = e.clipboardData.getData('text');
if (/[,;\s]/.test(text.trim())) (e.preventDefault(), add(text));
}}
onKeyDown={(e) => {
if ((e.key === 'Enter' || e.key === ',' || e.key === ';') && draft.trim()) (e.preventDefault(), add(draft));
if (e.key === 'Backspace' && !draft && people.length) {
const last = people[people.length - 1];
setPeople((l) => l.slice(0, -1));
setMsg(fill(t.removed, { v: last }));
}
}}
className="h-8 w-full min-w-0 bg-transparent px-1.5 text-base text-foreground outline-none placeholder:text-subtle-foreground sm:text-[13px]"
/>
</label>
</div>
{bad && (
<p id={`${id}-bad`} className="rpb-in mt-1.5 text-[12.5px] text-destructive">
{bad}
</p>
)}
{noPeople && !bad && <p className="rpb-in mt-1.5 text-[12.5px] text-destructive">{t.needRecipient}</p>}
</div>
<div>
<p id={`${id}-fmt`} className="mb-2 text-[12.5px] font-medium text-muted-foreground">
{t.format}
</p>
<div
ref={fmtRef}
role="radiogroup"
aria-labelledby={`${id}-fmt`}
className="relative grid grid-cols-[minmax(0,1fr)] gap-2 sm:grid-cols-3"
onKeyDown={(e) => {
const d = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0;
if (!d) return;
e.preventDefault();
const n = formats[(formats.indexOf(format) + d + formats.length) % formats.length];
setFormat(n);
fmtRef.current?.querySelector<HTMLButtonElement>(`[data-f=${n}]`)?.focus();
}}
>
{formats.map((x) => {
const on = format === x;
return (
<button key={x} data-f={x} type="button" role="radio" aria-checked={on} tabIndex={on ? 0 : -1} onClick={() => setFormat(x)} className={cn('group relative flex min-h-14 cursor-pointer items-center gap-3 rounded-lg border p-3 text-left transition-[border-color,translate] duration-300 hover:-translate-y-0.5 sm:grid sm:content-start sm:gap-1.5', focusRing, on ? 'border-transparent' : 'border-border hover:border-border-strong')}>
{on && <span data-glide aria-hidden className="absolute inset-0 rounded-lg bg-[color-mix(in_oklab,var(--primary)_7%,var(--card))] ring-2 ring-primary" />}
<Icon name={FORMAT_ICON[x]} size={18} className={cn('relative', on ? 'text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]' : 'text-muted-foreground')} />
<span className="relative min-w-0">
<span className="block text-[13.5px] font-medium text-foreground">{t[x]}</span>
<span className="block text-[12px] leading-snug text-muted-foreground">{t[`${x}Text` as 'pdfText']}</span>
</span>
</button>
);
})}
</div>
</div>
<label className={labelCls}>
{t.message}
<textarea rows={2} value={message} placeholder={t.messagePh} onChange={(e) => setMessage(e.target.value)} className={cn(field, 'h-auto resize-none py-2 leading-relaxed sm:h-auto')} />
</label>
</Card>
<Card title={t.share} className="rpb-up [--rpb-d:140ms]" bodyClassName="grid gap-4" action={<Switch checked={link} onCheckedChange={setLink} aria-label={t.share} />}>
<div className={cn('grid gap-4 transition-opacity duration-300', !link && 'pointer-events-none opacity-50')} inert={!link}>
<p className="text-[13px] text-muted-foreground">{t.linkOn}</p>
<div className="flex flex-col gap-2 sm:flex-row">
<label className="min-w-0 flex-1">
<span className="sr-only">{t.share}</span>
<input readOnly value={shareUrl} onFocus={(e) => e.currentTarget.select()} className={cn(field, 'bg-surface text-muted-foreground')} />
</label>
<Button
size="sm"
variant="outline"
iconLeft={copied ? 'check' : 'copy'}
className="max-sm:w-full"
onClick={async () => {
try {
await navigator.clipboard.writeText(shareUrl);
} catch {
/* the field stays selectable */
}
setCopied(true);
setMsg(t.copied);
setTimeout(() => setCopied(false), 1800);
}}
>
{copied ? t.copied : t.copy}
</Button>
</div>
<Pick label={t.access} value={access} onChange={(v) => setAccess(v as 'team' | 'anyone')} className="sm:max-w-xs">
<option value="team">{t.team}</option>
<option value="anyone">{t.anyone}</option>
</Pick>
</div>
</Card>
</div>
<aside className="grid gap-5 xl:sticky xl:top-6">
<Card title={t.next} className="rpb-up [--rpb-d:100ms]" bodyClassName="p-0 sm:p-0">
<ol>
{next.map((n, i) => (
<li key={`${freq}-${n}`} className="rpb-in flex items-center gap-3 border-b border-border px-4 py-3 last:border-b-0 sm:px-5" style={{ '--rpb-d': `${i * 50}ms` } as React.CSSProperties}>
<span className={cn('size-2 shrink-0 rounded-full', i === 0 ? 'bg-primary' : 'bg-border-strong')} />
<span className={cn('flex-1 text-[13.5px] tabular-nums', i === 0 ? 'font-medium text-foreground' : 'text-muted-foreground')}>{fmtWhen(n)}</span>
{i === 0 && <Icon name={FORMAT_ICON[format]} size={14} className="text-subtle-foreground" />}
</li>
))}
{!next.length && <li className="px-5 py-4 text-[13px] text-muted-foreground">{t.pickDay}</li>}
</ol>
<div className="border-t border-border p-4 sm:p-5">
<p className="mb-3 flex items-center gap-2 text-[12.5px] text-muted-foreground">
<Icon name="users" size={14} />
{people.length} · {t[format]} · {name}
</p>
{state === 'saved' && (
<p className="rpb-in mb-3 flex items-start gap-2 text-[13px] text-success-foreground">
<Icon name="check-circle" size={15} className="mt-px shrink-0" />
{fill(t.scheduled, { when: next[0] ? fmtWhen(next[0]) : '' })}
</p>
)}
<Button
className="w-full"
magnetic
iconLeft={state === 'busy' ? 'loader' : 'calendar-check'}
disabled={state === 'busy' || noDay}
onClick={async () => {
setTried(true);
if (!people.length) {
setMsg(t.needRecipient);
return;
}
setState('busy');
await run(onAction, 'schedule', { freq, days, time, dom, recipients: people, format, message, link, access });
setState('saved');
setMsg(fill(t.scheduled, { when: next[0] ? fmtWhen(next[0]) : '' }));
}}
>
{state === 'busy' ? t.saving : t.saveSchedule}
</Button>
</div>
</Card>
</aside>
</div>
<Live text={msg} />
</Main>
);
}
/* ------------------------------------------------------------------ */
/* templates */
/* ------------------------------------------------------------------ */
/** A small drawn picture of what the report looks like */
function Thumb({ kind, className }: { kind: ReportTemplate['kind']; className?: string }) {
const bars = [38, 56, 44, 70, 62, 84];
return (
<svg viewBox="0 0 160 90" aria-hidden className={cn('block h-auto w-full', className)}>
<rect x="0" y="0" width="160" height="90" rx="8" fill="var(--surface)" />
<rect x="12" y="11" width="46" height="5" rx="2.5" fill="var(--border-strong)" />
{kind === 'bars' &&
bars.map((h, i) => <rect key={i} x={16 + i * 22} y={80 - h * 0.7} width="13" height={h * 0.7} rx="2" fill={i === 5 ? 'var(--primary)' : 'color-mix(in oklab, var(--primary) 30%, var(--surface))'} />)}
{kind === 'line' && (
<>
<path d="M14 70 L40 58 L66 62 L92 44 L118 48 L146 28" fill="none" stroke="var(--primary)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M14 74 L40 68 L66 70 L92 60 L118 63 L146 52" fill="none" stroke="var(--border-strong)" strokeWidth="2" strokeDasharray="4 4" strokeLinecap="round" />
</>
)}
{kind === 'donut' && (
<>
<circle cx="56" cy="52" r="22" fill="none" stroke="color-mix(in oklab, var(--primary) 25%, var(--surface))" strokeWidth="10" />
<circle cx="56" cy="52" r="22" fill="none" stroke="var(--primary)" strokeWidth="10" strokeDasharray="86 140" transform="rotate(-90 56 52)" />
{[0, 1, 2].map((i) => (
<rect key={i} x="96" y={38 + i * 11} width={44 - i * 10} height="5" rx="2.5" fill={i ? 'var(--border-strong)' : 'var(--primary)'} />
))}
</>
)}
{kind === 'table' &&
[0, 1, 2, 3, 4].map((i) => (
<g key={i}>
<rect x="12" y={26 + i * 12} width="136" height="1" fill="var(--border)" />
<rect x="14" y={30 + i * 12} width={40 + ((i * 13) % 20)} height="4" rx="2" fill={i ? 'var(--border-strong)' : 'var(--muted-foreground)'} />
<rect x={120 - ((i * 7) % 12)} y={30 + i * 12} width={26 + ((i * 7) % 12)} height="4" rx="2" fill={i ? 'color-mix(in oklab, var(--primary) 40%, var(--surface))' : 'var(--muted-foreground)'} />
</g>
))}
{kind === 'kpi' &&
[0, 1, 2].map((i) => (
<g key={i}>
<rect x={12 + i * 47} y="26" width="42" height="52" rx="5" fill="var(--card)" stroke="var(--border)" />
<rect x={18 + i * 47} y="34" width="20" height="4" rx="2" fill="var(--border-strong)" />
<rect x={18 + i * 47} y="44" width="28" height="8" rx="2" fill={i === 0 ? 'var(--primary)' : 'var(--muted-foreground)'} />
<path d={`M${18 + i * 47} 70 l7 -4 l7 2 l7 -6 l7 1`} fill="none" stroke="color-mix(in oklab, var(--primary) 60%, var(--surface))" strokeWidth="1.6" strokeLinecap="round" />
</g>
))}
</svg>
);
}
function Templates({ t, f, title, subtitle, templates = [], fields, onAction }: P) {
const id = useId();
const cats = useMemo(() => [...new Set(templates.map((x) => x.category))], [templates]);
const [cat, setCat] = useState('all');
const [sel, setSel] = useState<string>(templates[0]?.id ?? '');
const tpl = templates.find((x) => x.id === sel);
const [form, setForm] = useState(() => ({ name: tpl?.name ?? '', range: '30', measures: tpl?.measures ?? [] }));
const [state, setState] = useState<'idle' | 'busy' | 'done'>('idle');
const [msg, setMsg] = useState('');
const [scope, enter] = useEnter<HTMLDivElement>();
const [row, fade] = useScrollFade<HTMLDivElement>();
const listRef = useRef<HTMLDivElement>(null);
const shown = templates.filter((x) => cat === 'all' || x.category === cat);
const nums = fields.filter(isNum);
const choose = (x: ReportTemplate, scroll = false) => {
setSel(x.id);
setForm({ name: x.name, range: form.range, measures: x.measures });
setState('idle');
if (scroll && window.matchMedia('(max-width: 1023px)').matches) requestAnimationFrame(() => document.getElementById(`${id}-cz`)?.scrollIntoView({ behavior: prefersReducedMotion() ? 'auto' : 'smooth', block: 'start' }));
};
const go = (d: number) => {
const i = shown.findIndex((x) => x.id === sel);
const n = shown[(Math.max(0, i) + d + shown.length) % shown.length];
if (!n) return;
choose(n);
listRef.current?.querySelector<HTMLElement>(`[data-t="${n.id}"]`)?.focus();
};
return (
<Main>
<Header title={title} subtitle={subtitle} />
<div ref={scope} {...enter} className="grid grid-cols-[minmax(0,1fr)] gap-5 xl:grid-cols-[minmax(0,1fr)_21rem] xl:items-start">
<div className="grid min-w-0 gap-4">
<div ref={row} role="group" aria-label={t.categories} className={cn('rpb-up kit-no-scrollbar -mx-4 -my-1 flex gap-2 overflow-x-auto px-4 py-1 sm:mx-0 sm:flex-wrap sm:px-0', fade)}>
{['all', ...cats].map((c) => (
<button key={c} type="button" aria-pressed={cat === c} onClick={() => setCat(c)} className={cn('h-11 shrink-0 cursor-pointer rounded-lg border px-3.5 text-[13px] font-medium transition-colors sm:h-9', focusRing, cat === c ? 'border-transparent bg-foreground text-background' : 'border-border text-muted-foreground hover:border-border-strong hover:text-foreground')}>
{c === 'all' ? t.all : c}
</button>
))}
</div>
<div
ref={listRef}
role="radiogroup"
aria-label={t.templates}
key={cat}
className="grid grid-cols-[minmax(0,1fr)] gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-2 2xl:grid-cols-3"
onKeyDown={(e) => {
const d = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0;
if (d) (e.preventDefault(), go(d));
}}
>
{shown.map((x, i) => {
const on = x.id === sel;
const focusable = on || (!shown.some((y) => y.id === sel) && i === 0);
return (
<div
key={x.id}
data-t={x.id}
role="radio"
aria-checked={on}
tabIndex={focusable ? 0 : -1}
onClick={() => choose(x, true)}
onKeyDown={(e) => {
if (e.key === ' ' || e.key === 'Enter') (e.preventDefault(), choose(x, true));
}}
className={cn('rpb-up group relative flex cursor-pointer flex-col rounded-xl border bg-card p-3 shadow-[var(--highlight),var(--shadow-sm)] transition-[border-color,translate,box-shadow] duration-300 hover:-translate-y-0.5 hover:shadow-[var(--highlight),var(--shadow-md)]', focusRing, on ? 'border-primary ring-1 ring-primary' : 'border-border hover:border-border-strong')}
style={{ '--rpb-d': `${i * 50}ms` } as React.CSSProperties}
>
<Thumb kind={x.kind} className="transition-transform duration-500 ease-[var(--ease-kit)] group-hover:scale-[1.02]" />
<div className="flex items-start justify-between gap-2 px-1 pb-1 pt-3">
<div className="min-w-0">
<h3 className="text-[14.5px] font-semibold text-foreground">{x.name}</h3>
<p className="mt-0.5 text-pretty text-[12.5px] leading-snug text-muted-foreground">{x.description}</p>
</div>
<span className={cn('grid size-5 shrink-0 place-items-center rounded-full border transition-colors', on ? 'border-primary bg-primary text-primary-foreground' : 'border-border-strong')}>{on && <Icon name="check" size={12} strokeWidth={2.6} />}</span>
</div>
<p className="mt-auto px-1 pt-2 text-[12px] text-subtle-foreground">{x.category}</p>
</div>
);
})}
</div>
</div>
<section id={`${id}-cz`} aria-labelledby={`${id}-czh`} className={cn(cardCls, 'rpb-up scroll-mt-4 p-4 [--rpb-d:120ms] sm:p-5 xl:sticky xl:top-6')}>
<h2 id={`${id}-czh`} className="text-[14px] font-semibold text-foreground">
{t.customize}
</h2>
{tpl ? (
<div key={tpl.id} className="rpb-in mt-4 grid gap-4">
<Thumb kind={tpl.kind} />
<label className={labelCls}>
{t.reportName}
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className={field} />
</label>
<div>
<p className="mb-2 text-[12.5px] font-medium text-muted-foreground">{t.range}</p>
<Segmented label={t.range} value={form.range} onChange={(v) => setForm({ ...form, range: v })} className="flex w-full" options={['7', '30', '90'].map((r) => ({ value: r, label: t[`r${r}` as 'r7'] }))} />
</div>
<fieldset>
<legend className="mb-1.5 text-[12.5px] font-medium text-muted-foreground">{t.measures}</legend>
<ul className="grid gap-0.5">
{nums.map((x) => {
const on = form.measures.includes(x.id);
return (
<li key={x.id}>
<label className="flex min-h-11 cursor-pointer items-center gap-3 rounded-md px-2 text-[13.5px] text-foreground transition-colors hover:bg-accent sm:min-h-9">
<input type="checkbox" checked={on} onChange={() => setForm({ ...form, measures: on ? form.measures.filter((m) => m !== x.id) : [...form.measures, x.id] })} className="size-[18px] shrink-0 cursor-pointer accent-[var(--primary)]" />
{x.label}
</label>
</li>
);
})}
</ul>
{!form.measures.length && <p className="rpb-in mt-1 text-[12.5px] text-destructive">{t.oneMeasure}</p>}
</fieldset>
{state === 'done' && (
<p className="rpb-in flex items-start gap-2 rounded-lg bg-[color-mix(in_oklab,var(--success)_10%,var(--card))] p-3 text-[13px] text-foreground">
<Icon name="check-circle" size={15} className="mt-px shrink-0 text-success-foreground" />
{fill(t.created, { name: form.name })}
</p>
)}
<Button
magnetic
className="w-full"
iconLeft={state === 'busy' ? 'loader' : 'sparkle'}
disabled={state === 'busy' || !form.measures.length || !form.name.trim()}
onClick={async () => {
setState('busy');
await run(onAction, 'create', { template: tpl.id, ...form });
setState('done');
setMsg(fill(t.created, { name: form.name }));
}}
>
{state === 'busy' ? t.creating : t.create}
</Button>
</div>
) : (
<p className="mt-3 text-[13px] text-muted-foreground">{t.pickTemplate}</p>
)}
</section>
</div>
<Live text={msg} />
</Main>
);
}
/* ------------------------------------------------------------------ */
/* ReportBuilder */
/* ------------------------------------------------------------------ */
/**
* Build reports in an app: pick and order columns with groups and totals,
* turn a measure into a chart, filter with AND and OR groups, schedule and
* share the report, or start from a template.
* <ReportBuilder variant="table-report" fields={[…]} rows={[…]} columns={['month', 'revenue']} />
*/
export function ReportBuilder({ variant = 'table-report', sidebar, labels, className, now = '2026-10-23T10:30', currency = 'EUR', fields = [], rows = [], ...props }: ReportBuilderProps) {
const t = useTexts(TEXTS, labels);
const locale = useLocale();
const f = useMemo(() => makeFmt(locale, currency), [locale, currency]);
const p: P = { ...props, t, now, locale, f, currency, fields, rows };
const body = (() => {
switch (variant) {
case 'chart-report':
return <ChartReport {...p} />;
case 'filters':
return <Filters {...p} />;
case 'schedule-share':
return <ScheduleShare {...p} />;
case 'templates':
return <Templates {...p} />;
default:
return <TableReport {...p} />;
}
})();
if (!sidebar)
return (
<div className={cn('min-h-dvh bg-background', className)}>
<Style />
{body}
</div>
);
return (
<Sidebar mobile="drawer" {...sidebar} className={className}>
<Style />
{body}
</Sidebar>
);
}Frequent questions
How do I install Report builder?
With aiw add @ai-website/report-builder. The command puts components/ui/report-builder.tsx into your project, the code is yours from then on. It brings along area-chart, bar-chart, button, donut-chart, icon, line-chart, motion, switch and sidebar. It needs no further npm packages.
Which variants does Report builder have?
5 variants, Table report, Chart report, Filter builder, Schedule and share and Report templates. You pick one with the variant prop, for example variant="chart-report".
Can I translate the texts of Report builder?
Yes. Report builder carries 128 texts of its own in English and German, such as “Fields”, “Tick a field to add it as a column.” and “Columns”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Report builder?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Report builder with aiw_components and install it with aiw_add @ai-website/report-builder. Name the variant in your prompt, for example variant="table-report", and the assistant writes your content straight into the props.