Skip to content
UI Kit
Accent color

Report builder

Build reports in five screens. Columns with groups and totals, charts from a measure, AND and OR filters, schedule and share, and templates to start from.

5 variantsOne of 2 components in Analytics and reports

Reports people build themselves

A report builder lets people answer their own questions without asking someone to write a query. These five screens go from picking fields to sending the finished report every Monday, and every change shows its result live.

Table report ticks fields, orders the columns by drag or keyboard, groups, sorts and filters, and the table with subtotals and total updates live and exports as CSV. Chart report picks bars, line, area or ring, a dimension, a measure and sum, average or count, splits by a second field and redraws as you go. Filter builder joins conditions in groups with AND or OR, offers operators that fit the field, counts matching rows live and reads the filter back in words. Schedule and share sends daily, weekly or monthly with the next five sends listed, to recipients as checked chips, as PDF, CSV or spreadsheet, with a share link and access. Report templates start from a drawn preview by category.

Offer templates first, most people change a good report rather than build one from nothing. Reading the filter back in words catches mistakes before anyone sends the wrong numbers. Show the next send dates, then everyone knows when the report arrives.

Variants

Table reportNew in 1.2

Tick fields, order the columns by drag or keyboard, group, sort and filter, the table with subtotals and total updates live and exports as CSV.

Chart reportNew in 1.2

Pick bars, line, area or ring, a dimension, a measure and sum, average or count, split by a second field and see the chart redraw as you go.

Filter builderNew in 1.2

Conditions in groups joined by AND or OR, operators that fit the field, the number of matching rows live, the filter in words and the first matches.

Schedule and shareNew in 1.2

Daily, weekly or monthly with the next five sends, recipients as checked chips, PDF, CSV or spreadsheet, a message and a share link with access.

Report templatesNew in 1.2

Templates by category with a drawn preview, pick one with mouse or arrow keys, rename it, choose range and measures and create the report.

Details

What Report builder brings along, read from the registry and the source.

Area
App
Variants
5
Version
1.0.0 · since 1.2.0
File
components/ui/report-builder.tsx
npm packages
None
Languages
English and German, 128 texts of its own
Studio
Adjust in code
In collections
SaaS app screens

Props

The ReportBuilderProps interface from components/ui/report-builder.tsx, with the comments from the code.

  • 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.

Built-in texts

Report builder writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 128 texts
fieldsEnglish FieldsGerman Felder
fieldsHintEnglish Tick a field to add it as a column.German Hake ein Feld an, um es als Spalte aufzunehmen.
columnsEnglish ColumnsGerman Spalten
columnsHintEnglish Drag a column or use Alt and the arrow keys to move it.German Ziehe eine Spalte oder verschiebe sie mit Alt und den Pfeiltasten.
moveUpEnglish Move {name} upGerman {name} nach oben
moveDownEnglish Move {name} downGerman {name} nach unten
removeEnglish Remove {name}German {name} entfernen
movedEnglish {name} is now column {n}.German {name} ist jetzt Spalte {n}.
groupByEnglish Group byGerman Gruppieren nach
noneEnglish NoneGerman Keine
sortByEnglish Sort byGerman Sortieren nach
ascendingEnglish AscendingGerman Aufsteigend
descendingEnglish DescendingGerman Absteigend
directionEnglish Sort directionGerman Sortierrichtung
filterEnglish FilterGerman Filter
allValuesEnglish AllGerman Alle
previewEnglish PreviewGerman Vorschau
setupEnglish Set upGerman Aufbau
modeEnglish Set up or previewGerman Aufbau oder Vorschau
rowsNEnglish {n} rowsGerman {n} Zeilen
totalEnglish TotalGerman Gesamt
subtotalEnglish SubtotalGerman Zwischensumme
exportEnglish Export CSVGerman CSV exportieren
exportedEnglish {name}.csv is in your downloads.German {name}.csv liegt in deinen Downloads.
saveEnglish Save reportGerman Bericht speichern
savingEnglish Saving…German Wird gespeichert…
savedEnglish Report saved.German Bericht gespeichert.
emptyEnglish Pick at least one field to see a preview.German Wähle mindestens ein Feld für die Vorschau.
chartTypeEnglish Chart typeGerman Diagrammtyp
barEnglish BarsGerman Balken
lineEnglish LineGerman Linie
areaEnglish AreaGerman Fläche
donutEnglish RingGerman Ring
dimensionEnglish DimensionGerman Dimension
measureEnglish MeasureGerman Kennzahl
aggregationEnglish AggregationGerman Berechnung
sumEnglish SumGerman Summe
avgEnglish AverageGerman Durchschnitt
countEnglish CountGerman Anzahl
splitEnglish Split byGerman Aufteilen nach
topEnglish ShowGerman Zeigen
topNEnglish Top {n}German Top {n}
allEnglish AllGerman Alle
chartTitleEnglish {measure} by {dimension}German {measure} nach {dimension}
chartTitleSplitEnglish {measure} by {dimension} and {split}German {measure} nach {dimension} und {split}
countOfEnglish Number of rowsGerman Anzahl Zeilen
donutHintEnglish A ring reads best with up to four parts. The rest is shown as Other.German Ein Ring liest sich mit bis zu vier Teilen am besten. Der Rest steht unter Sonstige.
otherEnglish OtherGerman Sonstige
conditionsEnglish ConditionsGerman Bedingungen
whereEnglish WhereGerman Wo
andEnglish andGerman und
orEnglish orGerman oder
matchAllEnglish AllGerman Alle
matchAnyEnglish AnyGerman Eine
groupJoinEnglish Groups join withGerman Gruppen verbinden mit
conditionJoinEnglish Conditions in group {n} join withGerman Bedingungen in Gruppe {n} verbinden mit
groupEnglish Group {n}German Gruppe {n}
fieldEnglish FieldGerman Feld
opEnglish ConditionGerman Bedingung
valueEnglish ValueGerman Wert
isEnglish isGerman ist
is-notEnglish is notGerman ist nicht
containsEnglish containsGerman enthält
gtEnglish is more thanGerman ist größer als
ltEnglish is less thanGerman ist kleiner als
addConditionEnglish Add conditionGerman Bedingung hinzufügen
addGroupEnglish Add groupGerman Gruppe hinzufügen
removeGroupEnglish Remove group {n}German Gruppe {n} entfernen
removeConditionEnglish Remove conditionGerman Bedingung entfernen
matchesEnglish {n} of {m} rows matchGerman {n} von {m} Zeilen passen
hitsEnglish Matching rowsGerman Passende Zeilen
ofRowsEnglish of {m} rowsGerman von {m} Zeilen
noMatchEnglish No row matches. Loosen a condition.German Keine Zeile passt. Lockere eine Bedingung.
inWordsEnglish In wordsGerman In Worten
showingEnglish First {n} matchesGerman Erste {n} Treffer
resetEnglish Reset filtersGerman Filter zurücksetzen
scheduleEnglish ScheduleGerman Zeitplan
scheduleTextEnglish Sends the report on its own at the times you pick.German Verschickt den Bericht von selbst zu den gewählten Zeiten.
dailyEnglish DailyGerman Täglich
weeklyEnglish WeeklyGerman Wöchentlich
monthlyEnglish MonthlyGerman Monatlich
repeatEnglish RepeatGerman Wiederholen
onEnglish OnGerman Am
atEnglish AtGerman Um
dayOfMonthEnglish Day of the monthGerman Tag im Monat
pickDayEnglish Pick at least one day.German Wähle mindestens einen Tag.
nextEnglish Next sendsGerman Nächste Versände
recipientsEnglish RecipientsGerman Empfänger
addRecipientEnglish Add an email and press EnterGerman E-Mail eingeben und Enter drücken
badEmailEnglish {v} is not an email address.German {v} ist keine E-Mail-Adresse.
removeRecipientEnglish Remove {v}German {v} entfernen
addedEnglish {v} added.German {v} hinzugefügt.
removedEnglish {v} removed.German {v} entfernt.
formatEnglish FormatGerman Format
pdfEnglish PDFGerman PDF
csvEnglish CSVGerman CSV
xlsxEnglish SpreadsheetGerman Tabelle
pdfTextEnglish Charts and tables, ready to readGerman Diagramme und Tabellen, fertig zum Lesen
csvTextEnglish Raw rows for any toolGerman Rohdaten für jedes Werkzeug
xlsxTextEnglish One sheet per tableGerman Ein Blatt je Tabelle
messageEnglish MessageGerman Nachricht
messagePhEnglish A short note above the reportGerman Ein kurzer Satz über dem Bericht
shareEnglish Share linkGerman Link teilen
linkOnEnglish Anyone with access can open the linkGerman Wer Zugang hat, kann den Link öffnen
accessEnglish Who can open itGerman Wer ihn öffnen kann
teamEnglish Your teamGerman Dein Team
anyoneEnglish Anyone with the linkGerman Alle mit dem Link
copyEnglish Copy linkGerman Link kopieren
copiedEnglish Link copied.German Link kopiert.
saveScheduleEnglish Save scheduleGerman Zeitplan speichern
scheduledEnglish Saved. The next report goes out {when}.German Gespeichert. Der nächste Bericht geht {when} raus.
needRecipientEnglish Add at least one recipient.German Füge mindestens einen Empfänger hinzu.
templatesEnglish TemplatesGerman Vorlagen
categoriesEnglish CategoriesGerman Kategorien
useEnglish Use this templateGerman Vorlage nutzen
selectedEnglish SelectedGerman Gewählt
customizeEnglish CustomizeGerman Anpassen
reportNameEnglish Report nameGerman Name des Berichts
rangeEnglish Time rangeGerman Zeitraum
r7English 7 daysGerman 7 Tage
r30English 30 daysGerman 30 Tage
r90English 90 daysGerman 90 Tage
measuresEnglish MeasuresGerman Kennzahlen
createEnglish Create reportGerman Bericht erstellen
creatingEnglish Creating…German Wird erstellt…
createdEnglish {name} is ready and saved in your reports.German {name} ist fertig und liegt bei deinen Berichten.
pickTemplateEnglish Pick a template to customize it.German Wähle eine Vorlage, um sie anzupassen.
oneMeasureEnglish Pick at least one measure.German Wähle mindestens eine Kennzahl.

Accessibility

Read from the source of the component.

  • Report builder handles the keys Enter, Arrow up, Arrow down, Arrow left, Arrow right, Backspace, Delete and Space itself.
  • Sets aria-checked, aria-describedby, aria-invalid, aria-label, aria-labelledby, aria-live, aria-pressed and aria-sort, so screen readers announce purpose and state.
  • Uses the roles group, radio, radiogroup and status.
  • Announces changes through a live region without moving focus.
  • Every field has a label, visible or for screen readers.
  • Adds text for screen readers where icons and graphics stand alone.
  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 1,766 lines
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>
  );
}

Frequent questions

How do I install Report builder?

With aiw add @ai-website/report-builder. The command puts components/ui/report-builder.tsx into your project, the code is yours from then on. It brings along area-chart, bar-chart, button, donut-chart, icon, line-chart, motion, switch and sidebar. It needs no further npm packages.

Which variants does Report builder have?

5 variants, Table report, Chart report, Filter builder, Schedule and share and Report templates. You pick one with the variant prop, for example variant="chart-report".

Can I translate the texts of Report builder?

Yes. Report builder carries 128 texts of its own in English and German, such as “Fields”, “Tick a field to add it as a column.” and “Columns”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Report builder?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Report builder with aiw_components and install it with aiw_add @ai-website/report-builder. Name the variant in your prompt, for example variant="table-report", and the assistant writes your content straight into the props.

Often searched

Jump to

Copied to the clipboard