Zum Inhalt springen
UI Kit
Accent

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.

5 VariantsEine von 2 Components in Analysen und Berichte

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

TabellenberichtNeu in 1.2

Felder anhaken, Spalten per Ziehen oder Tastatur ordnen, gruppieren, sortieren und filtern, die Tabelle mit Zwischensummen läuft live mit und geht als CSV raus.

DiagrammberichtNeu in 1.2

Balken, Linie, Fläche oder Ring wählen, Dimension, Kennzahl und Summe, Durchschnitt oder Anzahl, nach einem zweiten Feld aufteilen und live sehen.

FilterbaukastenNeu in 1.2

Bedingungen in Gruppen mit UND oder ODER, Operatoren passend zum Feld, die Zahl der Treffer live, der Filter in Worten und die ersten Treffer.

Planen und teilenNeu in 1.2

Täglich, wöchentlich oder monatlich mit den nächsten fünf Versänden, Empfänger als geprüfte Chips, PDF, CSV oder Tabelle, Nachricht und Link mit Zugriff.

BerichtsvorlagenNeu in 1.2

Vorlagen nach Kategorie mit gezeichneter Vorschau, per Maus oder Pfeiltasten wählen, umbenennen, Zeitraum und Kennzahlen setzen und den Bericht erstellen.

Details

Was Berichtsbaukasten mitbringt, aus Registry und Quellcode gelesen.

Bereich
App
Variants
5
Version
1.0.0 · seit 1.2.0
Datei
components/ui/report-builder.tsx
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 128 eigene Texte
Studio
Im Code anpassen
In Sammlungen
SaaS-App-Screens

Props

Die Schnittstelle ReportBuilderProps aus components/ui/report-builder.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    ReportBuilderVariant

    table-reportchart-reportfiltersschedule-sharetemplates

  • sidebaroptional
    Omit<SidebarProps, 'children'> | null

    The app frame. Leave it out (or null) for a standalone screen.

  • titleoptional
    string
  • subtitleoptional
    string
  • nowoptional
    string

    ISO date and time that counts as now

  • currencyoptional
    string

    ISO currency code for currency fields

  • nameoptional
    string

    Name of the report

  • fieldsoptional
    ReportField[]
  • rowsoptional
    ReportRow[]
  • columnsoptional
    string[]

    table-report: field ids shown as columns, in order

  • groupsoptional
    ReportGroup[]

    filters: the groups to start with

  • recipientsoptional
    string[]

    schedule-share: people who get the report

  • shareUrloptional
    string

    schedule-share: the link to the report

  • templatesoptional
    ReportTemplate[]

    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
fieldsEnglisch FieldsDeutsch Felder
fieldsHintEnglisch Tick a field to add it as a column.Deutsch Hake ein Feld an, um es als Spalte aufzunehmen.
columnsEnglisch ColumnsDeutsch Spalten
columnsHintEnglisch 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.
moveUpEnglisch Move {name} upDeutsch {name} nach oben
moveDownEnglisch Move {name} downDeutsch {name} nach unten
removeEnglisch Remove {name}Deutsch {name} entfernen
movedEnglisch {name} is now column {n}.Deutsch {name} ist jetzt Spalte {n}.
groupByEnglisch Group byDeutsch Gruppieren nach
noneEnglisch NoneDeutsch Keine
sortByEnglisch Sort byDeutsch Sortieren nach
ascendingEnglisch AscendingDeutsch Aufsteigend
descendingEnglisch DescendingDeutsch Absteigend
directionEnglisch Sort directionDeutsch Sortierrichtung
filterEnglisch FilterDeutsch Filter
allValuesEnglisch AllDeutsch Alle
previewEnglisch PreviewDeutsch Vorschau
setupEnglisch Set upDeutsch Aufbau
modeEnglisch Set up or previewDeutsch Aufbau oder Vorschau
rowsNEnglisch {n} rowsDeutsch {n} Zeilen
totalEnglisch TotalDeutsch Gesamt
subtotalEnglisch SubtotalDeutsch Zwischensumme
exportEnglisch Export CSVDeutsch CSV exportieren
exportedEnglisch {name}.csv is in your downloads.Deutsch {name}.csv liegt in deinen Downloads.
saveEnglisch Save reportDeutsch Bericht speichern
savingEnglisch Saving…Deutsch Wird gespeichert…
savedEnglisch Report saved.Deutsch Bericht gespeichert.
emptyEnglisch Pick at least one field to see a preview.Deutsch Wähle mindestens ein Feld für die Vorschau.
chartTypeEnglisch Chart typeDeutsch Diagrammtyp
barEnglisch BarsDeutsch Balken
lineEnglisch LineDeutsch Linie
areaEnglisch AreaDeutsch Fläche
donutEnglisch RingDeutsch Ring
dimensionEnglisch DimensionDeutsch Dimension
measureEnglisch MeasureDeutsch Kennzahl
aggregationEnglisch AggregationDeutsch Berechnung
sumEnglisch SumDeutsch Summe
avgEnglisch AverageDeutsch Durchschnitt
countEnglisch CountDeutsch Anzahl
splitEnglisch Split byDeutsch Aufteilen nach
topEnglisch ShowDeutsch Zeigen
topNEnglisch Top {n}Deutsch Top {n}
allEnglisch AllDeutsch Alle
chartTitleEnglisch {measure} by {dimension}Deutsch {measure} nach {dimension}
chartTitleSplitEnglisch {measure} by {dimension} and {split}Deutsch {measure} nach {dimension} und {split}
countOfEnglisch Number of rowsDeutsch Anzahl Zeilen
donutHintEnglisch 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.
otherEnglisch OtherDeutsch Sonstige
conditionsEnglisch ConditionsDeutsch Bedingungen
whereEnglisch WhereDeutsch Wo
andEnglisch andDeutsch und
orEnglisch orDeutsch oder
matchAllEnglisch AllDeutsch Alle
matchAnyEnglisch AnyDeutsch Eine
groupJoinEnglisch Groups join withDeutsch Gruppen verbinden mit
conditionJoinEnglisch Conditions in group {n} join withDeutsch Bedingungen in Gruppe {n} verbinden mit
groupEnglisch Group {n}Deutsch Gruppe {n}
fieldEnglisch FieldDeutsch Feld
opEnglisch ConditionDeutsch Bedingung
valueEnglisch ValueDeutsch Wert
isEnglisch isDeutsch ist
is-notEnglisch is notDeutsch ist nicht
containsEnglisch containsDeutsch enthält
gtEnglisch is more thanDeutsch ist größer als
ltEnglisch is less thanDeutsch ist kleiner als
addConditionEnglisch Add conditionDeutsch Bedingung hinzufügen
addGroupEnglisch Add groupDeutsch Gruppe hinzufügen
removeGroupEnglisch Remove group {n}Deutsch Gruppe {n} entfernen
removeConditionEnglisch Remove conditionDeutsch Bedingung entfernen
matchesEnglisch {n} of {m} rows matchDeutsch {n} von {m} Zeilen passen
hitsEnglisch Matching rowsDeutsch Passende Zeilen
ofRowsEnglisch of {m} rowsDeutsch von {m} Zeilen
noMatchEnglisch No row matches. Loosen a condition.Deutsch Keine Zeile passt. Lockere eine Bedingung.
inWordsEnglisch In wordsDeutsch In Worten
showingEnglisch First {n} matchesDeutsch Erste {n} Treffer
resetEnglisch Reset filtersDeutsch Filter zurücksetzen
scheduleEnglisch ScheduleDeutsch Zeitplan
scheduleTextEnglisch Sends the report on its own at the times you pick.Deutsch Verschickt den Bericht von selbst zu den gewählten Zeiten.
dailyEnglisch DailyDeutsch Täglich
weeklyEnglisch WeeklyDeutsch Wöchentlich
monthlyEnglisch MonthlyDeutsch Monatlich
repeatEnglisch RepeatDeutsch Wiederholen
onEnglisch OnDeutsch Am
atEnglisch AtDeutsch Um
dayOfMonthEnglisch Day of the monthDeutsch Tag im Monat
pickDayEnglisch Pick at least one day.Deutsch Wähle mindestens einen Tag.
nextEnglisch Next sendsDeutsch Nächste Versände
recipientsEnglisch RecipientsDeutsch Empfänger
addRecipientEnglisch Add an email and press EnterDeutsch E-Mail eingeben und Enter drücken
badEmailEnglisch {v} is not an email address.Deutsch {v} ist keine E-Mail-Adresse.
removeRecipientEnglisch Remove {v}Deutsch {v} entfernen
addedEnglisch {v} added.Deutsch {v} hinzugefügt.
removedEnglisch {v} removed.Deutsch {v} entfernt.
formatEnglisch FormatDeutsch Format
pdfEnglisch PDFDeutsch PDF
csvEnglisch CSVDeutsch CSV
xlsxEnglisch SpreadsheetDeutsch Tabelle
pdfTextEnglisch Charts and tables, ready to readDeutsch Diagramme und Tabellen, fertig zum Lesen
csvTextEnglisch Raw rows for any toolDeutsch Rohdaten für jedes Werkzeug
xlsxTextEnglisch One sheet per tableDeutsch Ein Blatt je Tabelle
messageEnglisch MessageDeutsch Nachricht
messagePhEnglisch A short note above the reportDeutsch Ein kurzer Satz über dem Bericht
shareEnglisch Share linkDeutsch Link teilen
linkOnEnglisch Anyone with access can open the linkDeutsch Wer Zugang hat, kann den Link öffnen
accessEnglisch Who can open itDeutsch Wer ihn öffnen kann
teamEnglisch Your teamDeutsch Dein Team
anyoneEnglisch Anyone with the linkDeutsch Alle mit dem Link
copyEnglisch Copy linkDeutsch Link kopieren
copiedEnglisch Link copied.Deutsch Link kopiert.
saveScheduleEnglisch Save scheduleDeutsch Zeitplan speichern
scheduledEnglisch Saved. The next report goes out {when}.Deutsch Gespeichert. Der nächste Bericht geht {when} raus.
needRecipientEnglisch Add at least one recipient.Deutsch Füge mindestens einen Empfänger hinzu.
templatesEnglisch TemplatesDeutsch Vorlagen
categoriesEnglisch CategoriesDeutsch Kategorien
useEnglisch Use this templateDeutsch Vorlage nutzen
selectedEnglisch SelectedDeutsch Gewählt
customizeEnglisch CustomizeDeutsch Anpassen
reportNameEnglisch Report nameDeutsch Name des Berichts
rangeEnglisch Time rangeDeutsch Zeitraum
r7Englisch 7 daysDeutsch 7 Tage
r30Englisch 30 daysDeutsch 30 Tage
r90Englisch 90 daysDeutsch 90 Tage
measuresEnglisch MeasuresDeutsch Kennzahlen
createEnglisch Create reportDeutsch Bericht erstellen
creatingEnglisch Creating…Deutsch Wird erstellt…
createdEnglisch {name} is ready and saved in your reports.Deutsch {name} ist fertig und liegt bei deinen Berichten.
pickTemplateEnglisch Pick a template to customize it.Deutsch Wähle eine Vorlage, um sie anzupassen.
oneMeasureEnglisch 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
components/ui/report-builder.tsx
'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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert