Report Builder für Apps
Berichte bauen in fünf Screens. Spalten mit Gruppen und Summen, Diagramme aus einer Kennzahl, UND- und ODER-Filter, planen und teilen und Vorlagen zum Start.
Berichte, die Menschen selbst bauen
Ein Report Builder lässt Menschen ihre eigenen Fragen beantworten, ohne dass jemand eine Abfrage schreiben muss. Diese fünf Screens führen vom Wählen der Felder bis zum fertigen Bericht, der jeden Montag verschickt wird, und jede Änderung zeigt ihr Ergebnis sofort.
Der „Tabellenbericht“ hakt Felder an, ordnet Spalten per Ziehen oder Tastatur, gruppiert, sortiert und filtert, die Tabelle mit Zwischensummen läuft live mit und geht als CSV raus. Der „Diagrammbericht“ wählt Balken, Linie, Fläche oder Ring, Dimension, Kennzahl und Summe, Durchschnitt oder Anzahl, teilt nach einem zweiten Feld auf und zeichnet live neu. Der „Filterbaukasten“ verbindet Bedingungen in Gruppen mit UND oder ODER, bietet Operatoren passend zum Feld, zählt die Treffer live und liest den Filter in Worten vor. „Planen und teilen“ verschickt täglich, wöchentlich oder monatlich mit den nächsten fünf Terminen, an Empfänger als geprüfte Chips, als PDF, CSV oder Tabelle, mit Link und Zugriff. Die „Berichtsvorlagen“ starten aus einer gezeichneten Vorschau nach Kategorie.
Biete zuerst Vorlagen an, die meisten Menschen passen lieber einen guten Bericht an, als bei null zu beginnen. Der Filter in Worten fängt Fehler, bevor jemand falsche Zahlen verschickt. Zeig die nächsten Versandtermine, dann weiß jeder, wann der Bericht kommt.
Variants
Details
Was Berichtsbaukasten mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Analysen und Berichte
- Bereich
- App
- Variants
- 5
- Version
- 1.0.0 · seit 1.2.0
- Datei
- components/ui/report-builder.tsx
- Bringt mit
- utilskitarea-chartbar-chartbuttondonut-charticonline-chartmotionswitchsidebar
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 128 eigene Texte
- Studio
- Im Code anpassen
- In Sammlungen
- SaaS-App-Screens
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle ReportBuilderProps aus components/ui/report-builder.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
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.
Eingebaute Texte
Diese Texte schreibt Berichtsbaukasten selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 128 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| fields | Englisch Fields | Deutsch Felder |
| fieldsHint | Englisch Tick a field to add it as a column. | Deutsch Hake ein Feld an, um es als Spalte aufzunehmen. |
| columns | Englisch Columns | Deutsch Spalten |
| columnsHint | Englisch Drag a column or use Alt and the arrow keys to move it. | Deutsch Ziehe eine Spalte oder verschiebe sie mit Alt und den Pfeiltasten. |
| moveUp | Englisch Move {name} up | Deutsch {name} nach oben |
| moveDown | Englisch Move {name} down | Deutsch {name} nach unten |
| remove | Englisch Remove {name} | Deutsch {name} entfernen |
| moved | Englisch {name} is now column {n}. | Deutsch {name} ist jetzt Spalte {n}. |
| groupBy | Englisch Group by | Deutsch Gruppieren nach |
| none | Englisch None | Deutsch Keine |
| sortBy | Englisch Sort by | Deutsch Sortieren nach |
| ascending | Englisch Ascending | Deutsch Aufsteigend |
| descending | Englisch Descending | Deutsch Absteigend |
| direction | Englisch Sort direction | Deutsch Sortierrichtung |
| filter | Englisch Filter | Deutsch Filter |
| allValues | Englisch All | Deutsch Alle |
| preview | Englisch Preview | Deutsch Vorschau |
| setup | Englisch Set up | Deutsch Aufbau |
| mode | Englisch Set up or preview | Deutsch Aufbau oder Vorschau |
| rowsN | Englisch {n} rows | Deutsch {n} Zeilen |
| total | Englisch Total | Deutsch Gesamt |
| subtotal | Englisch Subtotal | Deutsch Zwischensumme |
| export | Englisch Export CSV | Deutsch CSV exportieren |
| exported | Englisch {name}.csv is in your downloads. | Deutsch {name}.csv liegt in deinen Downloads. |
| save | Englisch Save report | Deutsch Bericht speichern |
| saving | Englisch Saving… | Deutsch Wird gespeichert… |
| saved | Englisch Report saved. | Deutsch Bericht gespeichert. |
| empty | Englisch Pick at least one field to see a preview. | Deutsch Wähle mindestens ein Feld für die Vorschau. |
| chartType | Englisch Chart type | Deutsch Diagrammtyp |
| bar | Englisch Bars | Deutsch Balken |
| line | Englisch Line | Deutsch Linie |
| area | Englisch Area | Deutsch Fläche |
| donut | Englisch Ring | Deutsch Ring |
| dimension | Englisch Dimension | Deutsch Dimension |
| measure | Englisch Measure | Deutsch Kennzahl |
| aggregation | Englisch Aggregation | Deutsch Berechnung |
| sum | Englisch Sum | Deutsch Summe |
| avg | Englisch Average | Deutsch Durchschnitt |
| count | Englisch Count | Deutsch Anzahl |
| split | Englisch Split by | Deutsch Aufteilen nach |
| top | Englisch Show | Deutsch Zeigen |
| topN | Englisch Top {n} | Deutsch Top {n} |
| all | Englisch All | Deutsch Alle |
| chartTitle | Englisch {measure} by {dimension} | Deutsch {measure} nach {dimension} |
| chartTitleSplit | Englisch {measure} by {dimension} and {split} | Deutsch {measure} nach {dimension} und {split} |
| countOf | Englisch Number of rows | Deutsch Anzahl Zeilen |
| donutHint | Englisch A ring reads best with up to four parts. The rest is shown as Other. | Deutsch Ein Ring liest sich mit bis zu vier Teilen am besten. Der Rest steht unter Sonstige. |
| other | Englisch Other | Deutsch Sonstige |
| conditions | Englisch Conditions | Deutsch Bedingungen |
| where | Englisch Where | Deutsch Wo |
| and | Englisch and | Deutsch und |
| or | Englisch or | Deutsch oder |
| matchAll | Englisch All | Deutsch Alle |
| matchAny | Englisch Any | Deutsch Eine |
| groupJoin | Englisch Groups join with | Deutsch Gruppen verbinden mit |
| conditionJoin | Englisch Conditions in group {n} join with | Deutsch Bedingungen in Gruppe {n} verbinden mit |
| group | Englisch Group {n} | Deutsch Gruppe {n} |
| field | Englisch Field | Deutsch Feld |
| op | Englisch Condition | Deutsch Bedingung |
| value | Englisch Value | Deutsch Wert |
| is | Englisch is | Deutsch ist |
| is-not | Englisch is not | Deutsch ist nicht |
| contains | Englisch contains | Deutsch enthält |
| gt | Englisch is more than | Deutsch ist größer als |
| lt | Englisch is less than | Deutsch ist kleiner als |
| addCondition | Englisch Add condition | Deutsch Bedingung hinzufügen |
| addGroup | Englisch Add group | Deutsch Gruppe hinzufügen |
| removeGroup | Englisch Remove group {n} | Deutsch Gruppe {n} entfernen |
| removeCondition | Englisch Remove condition | Deutsch Bedingung entfernen |
| matches | Englisch {n} of {m} rows match | Deutsch {n} von {m} Zeilen passen |
| hits | Englisch Matching rows | Deutsch Passende Zeilen |
| ofRows | Englisch of {m} rows | Deutsch von {m} Zeilen |
| noMatch | Englisch No row matches. Loosen a condition. | Deutsch Keine Zeile passt. Lockere eine Bedingung. |
| inWords | Englisch In words | Deutsch In Worten |
| showing | Englisch First {n} matches | Deutsch Erste {n} Treffer |
| reset | Englisch Reset filters | Deutsch Filter zurücksetzen |
| schedule | Englisch Schedule | Deutsch Zeitplan |
| scheduleText | Englisch Sends the report on its own at the times you pick. | Deutsch Verschickt den Bericht von selbst zu den gewählten Zeiten. |
| daily | Englisch Daily | Deutsch Täglich |
| weekly | Englisch Weekly | Deutsch Wöchentlich |
| monthly | Englisch Monthly | Deutsch Monatlich |
| repeat | Englisch Repeat | Deutsch Wiederholen |
| on | Englisch On | Deutsch Am |
| at | Englisch At | Deutsch Um |
| dayOfMonth | Englisch Day of the month | Deutsch Tag im Monat |
| pickDay | Englisch Pick at least one day. | Deutsch Wähle mindestens einen Tag. |
| next | Englisch Next sends | Deutsch Nächste Versände |
| recipients | Englisch Recipients | Deutsch Empfänger |
| addRecipient | Englisch Add an email and press Enter | Deutsch E-Mail eingeben und Enter drücken |
| badEmail | Englisch {v} is not an email address. | Deutsch {v} ist keine E-Mail-Adresse. |
| removeRecipient | Englisch Remove {v} | Deutsch {v} entfernen |
| added | Englisch {v} added. | Deutsch {v} hinzugefügt. |
| removed | Englisch {v} removed. | Deutsch {v} entfernt. |
| format | Englisch Format | Deutsch Format |
| Englisch PDF | Deutsch PDF | |
| csv | Englisch CSV | Deutsch CSV |
| xlsx | Englisch Spreadsheet | Deutsch Tabelle |
| pdfText | Englisch Charts and tables, ready to read | Deutsch Diagramme und Tabellen, fertig zum Lesen |
| csvText | Englisch Raw rows for any tool | Deutsch Rohdaten für jedes Werkzeug |
| xlsxText | Englisch One sheet per table | Deutsch Ein Blatt je Tabelle |
| message | Englisch Message | Deutsch Nachricht |
| messagePh | Englisch A short note above the report | Deutsch Ein kurzer Satz über dem Bericht |
| share | Englisch Share link | Deutsch Link teilen |
| linkOn | Englisch Anyone with access can open the link | Deutsch Wer Zugang hat, kann den Link öffnen |
| access | Englisch Who can open it | Deutsch Wer ihn öffnen kann |
| team | Englisch Your team | Deutsch Dein Team |
| anyone | Englisch Anyone with the link | Deutsch Alle mit dem Link |
| copy | Englisch Copy link | Deutsch Link kopieren |
| copied | Englisch Link copied. | Deutsch Link kopiert. |
| saveSchedule | Englisch Save schedule | Deutsch Zeitplan speichern |
| scheduled | Englisch Saved. The next report goes out {when}. | Deutsch Gespeichert. Der nächste Bericht geht {when} raus. |
| needRecipient | Englisch Add at least one recipient. | Deutsch Füge mindestens einen Empfänger hinzu. |
| templates | Englisch Templates | Deutsch Vorlagen |
| categories | Englisch Categories | Deutsch Kategorien |
| use | Englisch Use this template | Deutsch Vorlage nutzen |
| selected | Englisch Selected | Deutsch Gewählt |
| customize | Englisch Customize | Deutsch Anpassen |
| reportName | Englisch Report name | Deutsch Name des Berichts |
| range | Englisch Time range | Deutsch Zeitraum |
| r7 | Englisch 7 days | Deutsch 7 Tage |
| r30 | Englisch 30 days | Deutsch 30 Tage |
| r90 | Englisch 90 days | Deutsch 90 Tage |
| measures | Englisch Measures | Deutsch Kennzahlen |
| create | Englisch Create report | Deutsch Bericht erstellen |
| creating | Englisch Creating… | Deutsch Wird erstellt… |
| created | Englisch {name} is ready and saved in your reports. | Deutsch {name} ist fertig und liegt bei deinen Berichten. |
| pickTemplate | Englisch Pick a template to customize it. | Deutsch Wähle eine Vorlage, um sie anzupassen. |
| oneMeasure | Englisch Pick at least one measure. | Deutsch Wähle mindestens eine Kennzahl. |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Berichtsbaukasten reagiert selbst auf die Tasten Enter, Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts, Rücktaste, Entf und Leertaste.
- Setzt aria-checked, aria-describedby, aria-invalid, aria-label, aria-labelledby, aria-live, aria-pressed und aria-sort, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen group, radio, radiogroup und status.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Jedes Feld hat ein Label, sichtbar oder für Screenreader.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 1.766 Zeilen
'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>
);
}Häufige Fragen
Wie installiere ich Berichtsbaukasten?
Mit aiw add @ai-website/report-builder. Der Befehl legt die Datei components/ui/report-builder.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind area-chart, bar-chart, button, donut-chart, icon, line-chart, motion, switch und sidebar. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Berichtsbaukasten?
5 Varianten, Tabellenbericht, Diagrammbericht, Filterbaukasten, Planen und teilen und Berichtsvorlagen. Du wählst sie mit der Prop variant, zum Beispiel variant="chart-report".
Kann ich die Texte von Berichtsbaukasten übersetzen?
Ja. Berichtsbaukasten bringt 128 eigene Texte auf Deutsch und Englisch mit, etwa „Felder“, „Hake ein Feld an, um es als Spalte aufzunehmen.“ und „Spalten“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Berichtsbaukasten einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Berichtsbaukasten mit aiw_components und installieren sie mit aiw_add @ai-website/report-builder. Nenn im Prompt die Variante, etwa variant="table-report", dann schreibt der Assistent deine Inhalte direkt in die Props.