Zum Inhalt springen
UI Kit
Accent

Datenexport

Export-Screens. Schneller Export mit Format und Zeilen, Spalten wählen und ordnen mit Vorschau, geplante Exporte, Exportverlauf und ein vollständiges Kontoarchiv.

Eine von 3 Components in Import, Export und Verlauf

5 Variants

Variants

Schneller Export

Format als Karten mit gezeichneter Datei, alle, gefilterte oder ausgewählte Zeilen, zwei Optionen und Dateiname und Größe, bevor der Export läuft.

Im Studio öffnen

Spalten wählen

Spalten zum An- und Abschalten und Ordnen, alle oder keine auf einmal, eine Vorschau der ersten Zeilen, die jeder Änderung folgt, und eine Vorlage zum Speichern.

Im Studio öffnen

Geplante Exporte

Exporte, die von selbst per E-Mail oder in einen Ordner kommen, mit dem nächsten Lauf, einem Schalter zum Pausieren und einem Formular zum Hinzufügen.

Im Studio öffnen

Exportverlauf

Jede Datei mit Zeilen, Größe, Autor und Status, Herunterladen, solange der Link gilt, erneut ausführen bei Ablauf oder Fehler, und ein laufender Export, der fertig wird.

Im Studio öffnen

Kontoarchiv

Wählen, was in ein ZIP aller Daten kommt, mit Größe, mit Passwort bestätigen und sehen, wie es weitergeht.

Im Studio öffnen

Details

Was Datenexport mitbringt, aus Registry und Quellcode gelesen.

Bereich
App
Variants
5
Version
1.0.0 · seit 1.1.0
Datei
components/ui/data-export.tsx
Bringt mit
utilskitbuttoniconswitchsidebar
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 83 eigene Texte

Props

Die Schnittstelle DataExportProps aus components/ui/data-export.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    DataExportVariant

    quick-exportcolumnsscheduledhistoryaccount-archive

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

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

  • titlePflicht
    string
  • subtitleoptional
    string
  • entityoptional
    string

    What gets exported, e.g. contacts

  • countsoptional
    { all: number; filtered?: number; selected?: number }

    quick-export: rows in all data, in the current filter and selected

  • columnsoptional
    ExportColumn[]

    columns

  • schedulesoptional
    ScheduledExport[]

    scheduled

  • filesoptional
    ExportFile[]

    history

  • partsoptional
    ArchivePart[]

    account-archive

  • emailoptional
    string

    account-archive: where the link goes

  • nowoptional
    string

    ISO date and time that counts as now

  • onActionoptional
    (action: string, data: Record<string, unknown>) => void | Promise<void>

    Called on export, save and download. Without it the block simulates the work.

Eingebaute Texte

Diese Texte schreibt Datenexport selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 83 Texte anzeigen
formatEnglisch FormatDeutsch Format
csvEnglisch CSVDeutsch CSV
xlsxEnglisch ExcelDeutsch Excel
jsonEnglisch JSONDeutsch JSON
pdfEnglisch PDFDeutsch PDF
csvTextEnglisch For any spreadsheetDeutsch Für jede Tabelle
xlsxTextEnglisch With column widthsDeutsch Mit Spaltenbreiten
jsonTextEnglisch For developersDeutsch Für Entwickler
pdfTextEnglisch To print or sendDeutsch Zum Drucken und Senden
whichEnglisch Which rowsDeutsch Welche Zeilen
allEnglisch All {entity}Deutsch Alle {entity}
filteredEnglisch Current filterDeutsch Aktueller Filter
selectedEnglisch Selected onlyDeutsch Nur ausgewählte
rowsNEnglisch {n} rowsDeutsch {n} Zeilen
optionsEnglisch OptionsDeutsch Optionen
headersEnglisch Include column namesDeutsch Spaltennamen mitnehmen
datesEnglisch Dates as YYYY-MM-DDDeutsch Datum als JJJJ-MM-TT
exportEnglisch Export {n} rowsDeutsch {n} Zeilen exportieren
exportingEnglisch Preparing your file…Deutsch Deine Datei wird vorbereitet…
readyEnglisch Your file is readyDeutsch Deine Datei ist fertig
downloadEnglisch DownloadDeutsch Herunterladen
anotherEnglisch Export againDeutsch Erneut exportieren
emailTooEnglisch We also sent the link to your email.Deutsch Den Link haben wir dir auch per E-Mail geschickt.
columnsEnglisch ColumnsDeutsch Spalten
columnsTextEnglisch Pick the columns and their order. The preview shows the first rows.Deutsch Wähle Spalten und Reihenfolge. Die Vorschau zeigt die ersten Zeilen.
pickedEnglisch {n} of {total} columnsDeutsch {n} von {total} Spalten
pickAllEnglisch AllDeutsch Alle
pickNoneEnglisch NoneDeutsch Keine
moveUpEnglisch Move {name} upDeutsch {name} nach oben
moveDownEnglisch Move {name} downDeutsch {name} nach unten
previewEnglisch PreviewDeutsch Vorschau
noColumnsEnglisch Pick at least one column.Deutsch Wähle mindestens eine Spalte.
saveSetEnglisch Save as presetDeutsch Als Vorlage speichern
savedEnglisch Preset savedDeutsch Vorlage gespeichert
scheduledEnglisch Scheduled exportsDeutsch Geplante Exporte
scheduledTextEnglisch Fresh files arrive on their own, no one has to remember.Deutsch Frische Dateien kommen von selbst, niemand muss daran denken.
nextEnglisch Next {when}Deutsch Nächster {when}
pausedEnglisch PausedDeutsch Pausiert
toggleEnglisch Run {name}Deutsch {name} ausführen
newScheduleEnglisch New scheduled exportDeutsch Neuer geplanter Export
nameEnglisch NameDeutsch Name
everyEnglisch RepeatDeutsch Wiederholen
dailyEnglisch Every dayDeutsch Jeden Tag
weeklyEnglisch Every MondayDeutsch Jeden Montag
monthlyEnglisch On the 1stDeutsch Am 1. des Monats
atEnglisch AtDeutsch Um
sendToEnglisch Send toDeutsch Senden an
toEmailEnglisch EmailDeutsch E-Mail
toFolderEnglisch Shared folderDeutsch Geteilter Ordner
addressEnglisch AddressDeutsch Adresse
folderEnglisch Folder pathDeutsch Ordnerpfad
addEnglisch Add scheduleDeutsch Export planen
addingEnglisch Adding…Deutsch Wird geplant…
addedEnglisch {name} added.Deutsch {name} hinzugefügt.
nameErrorEnglisch Give it a name.Deutsch Gib ihm einen Namen.
toErrorEnglisch Fill in where the file goes.Deutsch Trag ein, wohin die Datei geht.
historyEnglisch Export historyDeutsch Exportverlauf
historyTextEnglisch Links work for 7 days, then the files are deleted.Deutsch Links funktionieren 7 Tage, danach werden die Dateien gelöscht.
statusReadyEnglisch ReadyDeutsch Bereit
statusRunningEnglisch RunningDeutsch Läuft
statusExpiredEnglisch ExpiredDeutsch Abgelaufen
statusFailedEnglisch FailedDeutsch Fehlgeschlagen
expiresInEnglisch Link works {when}Deutsch Link gilt {when}
rerunEnglisch Export againDeutsch Erneut exportieren
retryEnglisch Try againDeutsch Erneut versuchen
byEnglisch by {name}Deutsch von {name}
allStatusEnglisch AllDeutsch Alle
archiveEnglisch Download your dataDeutsch Deine Daten herunterladen
archiveTextEnglisch A ZIP with everything you pick. We email you a link as soon as it is ready, usually within an hour.Deutsch Ein ZIP mit allem, was du wählst. Wir schicken dir einen Link, sobald es fertig ist, meist innerhalb einer Stunde.
whatInEnglisch What goes inDeutsch Was hinein kommt
totalEnglisch About {size}Deutsch Etwa {size}
confirmEnglisch Confirm with your passwordDeutsch Mit deinem Passwort bestätigen
passwordEnglisch PasswordDeutsch Passwort
passwordErrorEnglisch Enter your password to continue.Deutsch Gib dein Passwort ein, um weiterzumachen.
requestEnglisch Request archiveDeutsch Archiv anfordern
requestingEnglisch Requesting…Deutsch Wird angefordert…
stepsEnglisch What happens nextDeutsch So geht es weiter
step1Englisch We collect your dataDeutsch Wir sammeln deine Daten
step2Englisch We pack it into a ZIPDeutsch Wir packen sie in ein ZIP
step3Englisch You get an email with a linkDeutsch Du bekommst eine E-Mail mit Link
requestedEnglisch We are on itDeutsch Wir sind dran
requestedTextEnglisch The link goes to {email}. It works for 7 days and needs your password again.Deutsch Der Link geht an {email}. Er gilt 7 Tage und fragt noch einmal nach deinem Passwort.
etaEnglisch Ready in about {n} minutesDeutsch Fertig in etwa {n} Minuten

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Datenexport reagiert selbst auf die Tasten Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts und Leertaste.
  • Setzt aria-checked, aria-describedby, aria-invalid, aria-label und aria-live, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen radio, radiogroup und status.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Jedes Feld hat ein Label, sichtbar oder für Screenreader.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 1.150 Zeilen
components/ui/data-export.tsx
'use client';

import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { cn, type Locale, prefersReducedMotion, round, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Switch } from '@/components/ui/switch';
import { Sidebar, type SidebarProps } from '@/components/ui/sidebar';
import { useScrollFade } from '@/components/ui/motion';

export type DataExportVariant = 'quick-export' | 'columns' | 'scheduled' | 'history' | 'account-archive';
export type ExportFormat = 'csv' | 'xlsx' | 'json' | 'pdf';

export interface ExportColumn {
  id: string;
  label: string;
  /** Shown in the first rows of the preview */
  samples?: string[];
  on?: boolean;
}

export interface ScheduledExport {
  id: string;
  name: string;
  format: ExportFormat;
  /** e.g. Every Monday at 07:00 */
  when: string;
  /** Where the file goes, e.g. an email address or a folder */
  to: string;
  toKind: 'email' | 'folder';
  enabled: boolean;
  /** ISO date and time */
  next?: string;
}

export interface ExportFile {
  id: string;
  name: string;
  format: ExportFormat;
  rows: number;
  /** Bytes */
  size: number;
  /** ISO date and time */
  created: string;
  /** ISO date and time when the link stops working */
  expires?: string;
  by?: string;
  status: 'ready' | 'running' | 'expired' | 'failed';
}

export interface ArchivePart {
  id: string;
  label: string;
  description?: string;
  /** Rough size in bytes */
  size: number;
  icon?: IconName;
}

export interface DataExportProps {
  variant?: DataExportVariant;
  /** The app frame. Leave it out (or null) for a standalone screen. */
  sidebar?: Omit<SidebarProps, 'children'> | null;
  title: string;
  subtitle?: string;
  /** What gets exported, e.g. contacts */
  entity?: string;
  /** quick-export: rows in all data, in the current filter and selected */
  counts?: { all: number; filtered?: number; selected?: number };
  /** columns */
  columns?: ExportColumn[];
  /** scheduled */
  schedules?: ScheduledExport[];
  /** history */
  files?: ExportFile[];
  /** account-archive */
  parts?: ArchivePart[];
  /** account-archive: where the link goes */
  email?: string;
  /** ISO date and time that counts as now */
  now?: string;
  /** Called on export, save and download. Without it the block simulates the work. */
  onAction?: (action: string, data: Record<string, unknown>) => void | Promise<void>;
  labels?: Partial<(typeof TEXTS)['en']>;
  className?: string;
}

const TEXTS = {
  en: {
    format: 'Format',
    csv: 'CSV',
    xlsx: 'Excel',
    json: 'JSON',
    pdf: 'PDF',
    csvText: 'For any spreadsheet',
    xlsxText: 'With column widths',
    jsonText: 'For developers',
    pdfText: 'To print or send',
    which: 'Which rows',
    all: 'All {entity}',
    filtered: 'Current filter',
    selected: 'Selected only',
    rowsN: '{n} rows',
    options: 'Options',
    headers: 'Include column names',
    dates: 'Dates as YYYY-MM-DD',
    export: 'Export {n} rows',
    exporting: 'Preparing your file…',
    ready: 'Your file is ready',
    download: 'Download',
    another: 'Export again',
    emailToo: 'We also sent the link to your email.',
    columns: 'Columns',
    columnsText: 'Pick the columns and their order. The preview shows the first rows.',
    picked: '{n} of {total} columns',
    pickAll: 'All',
    pickNone: 'None',
    moveUp: 'Move {name} up',
    moveDown: 'Move {name} down',
    preview: 'Preview',
    noColumns: 'Pick at least one column.',
    saveSet: 'Save as preset',
    saved: 'Preset saved',
    scheduled: 'Scheduled exports',
    scheduledText: 'Fresh files arrive on their own, no one has to remember.',
    next: 'Next {when}',
    paused: 'Paused',
    toggle: 'Run {name}',
    newSchedule: 'New scheduled export',
    name: 'Name',
    every: 'Repeat',
    daily: 'Every day',
    weekly: 'Every Monday',
    monthly: 'On the 1st',
    at: 'At',
    sendTo: 'Send to',
    toEmail: 'Email',
    toFolder: 'Shared folder',
    address: 'Address',
    folder: 'Folder path',
    add: 'Add schedule',
    adding: 'Adding…',
    added: '{name} added.',
    nameError: 'Give it a name.',
    toError: 'Fill in where the file goes.',
    history: 'Export history',
    historyText: 'Links work for 7 days, then the files are deleted.',
    statusReady: 'Ready',
    statusRunning: 'Running',
    statusExpired: 'Expired',
    statusFailed: 'Failed',
    expiresIn: 'Link works {when}',
    rerun: 'Export again',
    retry: 'Try again',
    by: 'by {name}',
    allStatus: 'All',
    archive: 'Download your data',
    archiveText: 'A ZIP with everything you pick. We email you a link as soon as it is ready, usually within an hour.',
    whatIn: 'What goes in',
    total: 'About {size}',
    confirm: 'Confirm with your password',
    password: 'Password',
    passwordError: 'Enter your password to continue.',
    request: 'Request archive',
    requesting: 'Requesting…',
    steps: 'What happens next',
    step1: 'We collect your data',
    step2: 'We pack it into a ZIP',
    step3: 'You get an email with a link',
    requested: 'We are on it',
    requestedText: 'The link goes to {email}. It works for 7 days and needs your password again.',
    eta: 'Ready in about {n} minutes',
  },
  de: {
    format: 'Format',
    csv: 'CSV',
    xlsx: 'Excel',
    json: 'JSON',
    pdf: 'PDF',
    csvText: 'Für jede Tabelle',
    xlsxText: 'Mit Spaltenbreiten',
    jsonText: 'Für Entwickler',
    pdfText: 'Zum Drucken und Senden',
    which: 'Welche Zeilen',
    all: 'Alle {entity}',
    filtered: 'Aktueller Filter',
    selected: 'Nur ausgewählte',
    rowsN: '{n} Zeilen',
    options: 'Optionen',
    headers: 'Spaltennamen mitnehmen',
    dates: 'Datum als JJJJ-MM-TT',
    export: '{n} Zeilen exportieren',
    exporting: 'Deine Datei wird vorbereitet…',
    ready: 'Deine Datei ist fertig',
    download: 'Herunterladen',
    another: 'Erneut exportieren',
    emailToo: 'Den Link haben wir dir auch per E-Mail geschickt.',
    columns: 'Spalten',
    columnsText: 'Wähle Spalten und Reihenfolge. Die Vorschau zeigt die ersten Zeilen.',
    picked: '{n} von {total} Spalten',
    pickAll: 'Alle',
    pickNone: 'Keine',
    moveUp: '{name} nach oben',
    moveDown: '{name} nach unten',
    preview: 'Vorschau',
    noColumns: 'Wähle mindestens eine Spalte.',
    saveSet: 'Als Vorlage speichern',
    saved: 'Vorlage gespeichert',
    scheduled: 'Geplante Exporte',
    scheduledText: 'Frische Dateien kommen von selbst, niemand muss daran denken.',
    next: 'Nächster {when}',
    paused: 'Pausiert',
    toggle: '{name} ausführen',
    newSchedule: 'Neuer geplanter Export',
    name: 'Name',
    every: 'Wiederholen',
    daily: 'Jeden Tag',
    weekly: 'Jeden Montag',
    monthly: 'Am 1. des Monats',
    at: 'Um',
    sendTo: 'Senden an',
    toEmail: 'E-Mail',
    toFolder: 'Geteilter Ordner',
    address: 'Adresse',
    folder: 'Ordnerpfad',
    add: 'Export planen',
    adding: 'Wird geplant…',
    added: '{name} hinzugefügt.',
    nameError: 'Gib ihm einen Namen.',
    toError: 'Trag ein, wohin die Datei geht.',
    history: 'Exportverlauf',
    historyText: 'Links funktionieren 7 Tage, danach werden die Dateien gelöscht.',
    statusReady: 'Bereit',
    statusRunning: 'Läuft',
    statusExpired: 'Abgelaufen',
    statusFailed: 'Fehlgeschlagen',
    expiresIn: 'Link gilt {when}',
    rerun: 'Erneut exportieren',
    retry: 'Erneut versuchen',
    by: 'von {name}',
    allStatus: 'Alle',
    archive: 'Deine Daten herunterladen',
    archiveText: 'Ein ZIP mit allem, was du wählst. Wir schicken dir einen Link, sobald es fertig ist, meist innerhalb einer Stunde.',
    whatIn: 'Was hinein kommt',
    total: 'Etwa {size}',
    confirm: 'Mit deinem Passwort bestätigen',
    password: 'Passwort',
    passwordError: 'Gib dein Passwort ein, um weiterzumachen.',
    request: 'Archiv anfordern',
    requesting: 'Wird angefordert…',
    steps: 'So geht es weiter',
    step1: 'Wir sammeln deine Daten',
    step2: 'Wir packen sie in ein ZIP',
    step3: 'Du bekommst eine E-Mail mit Link',
    requested: 'Wir sind dran',
    requestedText: 'Der Link geht an {email}. Er gilt 7 Tage und fragt noch einmal nach deinem Passwort.',
    eta: 'Fertig in etwa {n} Minuten',
  },
};

type Texts = (typeof TEXTS)['en'];

const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));

/* ------------------------------------------------------------------ */
/* Motion of the app screens                                           */
/* ------------------------------------------------------------------ */
/*
 * The same small set in every app block (auth, dashboard, files, inbox …),
 * kept in the block so it ships on its own:
 *   useEnter   entrance that waits until its area scrolls in. The area gets
 *              data-kitm-scope and data-kitm={state}, children take kitm-wipe
 *              (charts draw from the left), kitm-grow-x, kitm-grow-y (bars),
 *              kitm-up and kitm-pop, with --kitm-d as delay.
 *   useGlide   a marker inside the active item ([data-glide]) glides over
 *              from where the previous one stood: tabs, segments, steps.
 *   kitm-in, kitm-fly, kitm-tick for things that appear after an action.
 * The server renders the end state, without JavaScript nothing waits longer
 * than 2.5 s, with reduced motion nothing moves. Only transform, opacity and
 * clip-path change, so no layout shifts.
 */
const useIso = typeof window === 'undefined' ? useEffect : useLayoutEffect;
const KITM_EASE = 'cubic-bezier(0.2, 0.7, 0.2, 1)';
const KITM_FX: [string, string, number][] = [
  ['wipe', 'clip-path:inset(0 100% 0 0)', 1100],
  ['grow-x', 'transform:scaleX(0)', 900],
  ['grow-y', 'transform:scaleY(0)', 800],
  ['up', 'opacity:0;transform:translateY(10px)', 600],
  ['pop', 'opacity:0;transform:scale(.92)', 450],
];
const KITM_CSS =
  KITM_FX.map(([k, v]) => `@keyframes kitm-${k}{from{${v}}}@keyframes kitm-${k}-h{from{${v}}}`).join('') +
  '@keyframes kitm-fly{from{opacity:0;transform:translateX(24px)}}@keyframes kitm-sheen{to{transform:translateX(130%)}}.kitm-sheen{transform:translateX(-130%)}@keyframes kitm-blink{50%{opacity:0}}@keyframes kitm-shake{20%,60%{transform:translateX(-5px)}40%,80%{transform:translateX(5px)}}@keyframes kitm-ring{20%{transform:rotate(14deg)}40%{transform:rotate(-11deg)}60%{transform:rotate(7deg)}80%{transform:rotate(-3deg)}}@keyframes kitm-next{from{opacity:0;transform:translateX(18px)}}@keyframes kitm-prev{from{opacity:0;transform:translateX(-18px)}}.kitm-caret{display:inline-block;width:.45em;height:1.1em;margin-left:.15em;vertical-align:-.2em;border-radius:2px;background:color-mix(in oklab,var(--primary) 80%,var(--foreground))}@keyframes kitm-flash{from{background-color:color-mix(in oklab,var(--primary) 14%,transparent)}}' +
  '.kitm-grow-x{transform-origin:left}.kitm-grow-y{transform-origin:bottom}' +
  '@media (prefers-reduced-motion:no-preference){' +
  KITM_FX.map(([k, , d]) => `[data-kitm=play] .kitm-${k}{animation:kitm-${k} var(--kitm-t,${d}ms) var(--ease-kit) var(--kitm-d,0ms) both}[data-kitm=wait] .kitm-${k}{animation:kitm-${k}-h 0s 1s both paused}`).join('') +
  '[data-kitm=play] .kitm-sheen{animation:kitm-sheen 1.6s var(--ease-kit) var(--kitm-d,400ms) both}.kitm-in{animation:kitm-up var(--kitm-t,.5s) var(--ease-kit) var(--kitm-d,0ms) both}.kitm-fly{animation:kitm-fly .55s var(--ease-kit) var(--kitm-d,0ms) both}.kitm-tick{animation:kitm-wipe .36s var(--ease-kit) both}.kitm-caret{animation:kitm-blink 1.05s steps(1) infinite}.kitm-shake{animation:kitm-shake .4s ease-in-out}.kitm-ring{transform-origin:50% 8%;animation:kitm-ring .9s var(--ease-kit) both}.kitm-next{animation:kitm-next .45s var(--ease-kit) both}.kitm-prev{animation:kitm-prev .45s var(--ease-kit) both}.kitm-flash{animation:kitm-flash 1.6s ease-out both}' +
  '}@media (scripting:enabled) and (prefers-reduced-motion:no-preference){' +
  KITM_FX.map(([k]) => `[data-kitm-scope]:not([data-kitm]) .kitm-${k}{animation:kitm-${k}-h 0s 2.5s both}`).join('') +
  '}';

function KitmStyle() {
  // React hoists it once into the head, however many app blocks render
  return (
    <style href="kitm" precedence="default">
      {KITM_CSS}
    </style>
  );
}

/** Entrance state of an area: undefined on the server, then wait (below the fold) or play */
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-kitm-scope': '', 'data-kitm': 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 };
};

/**
 * The marker ([data-glide]) is rendered inside the active item, so the server
 * and a page without JavaScript show it in place. After a change it starts
 * where the old one stood and glides into the new item.
 */
function useGlide<T extends HTMLElement>(key: unknown) {
  const ref = useRef<T>(null);
  const before = useRef<Box | null>(null);
  // Read during render, while the DOM still shows the old marker
  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: KITM_EASE });
  }, [key]);
  return ref;
}

/* ------------------------------------------------------------------ */
/* Parts                                                               */
/* ------------------------------------------------------------------ */

type P = Omit<DataExportProps, 'variant' | 'sidebar' | 'labels' | 'className'> & { t: Texts; locale: Locale; entity: string; now: string };

const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function run(onAction: DataExportProps['onAction'], action: string, data: Record<string, unknown>, ms = 700) {
  if (onAction) await onAction(action, data);
  else await wait(prefersReducedMotion() ? 0 : ms);
}

const MIN = 60000;
const toMin = (iso: string) => Math.round(Date.parse(iso + (iso.length <= 10 ? 'T00:00:00Z' : iso.endsWith('Z') ? '' : 'Z')) / MIN);
const num = (l: Locale, n: number) => new Intl.NumberFormat(l === 'de' ? 'de-DE' : 'en-US').format(n);
function bytes(l: Locale, n: number) {
  const units = ['B', 'KB', 'MB', 'GB'];
  let i = 0;
  let v = n;
  while (v >= 1000 && i < units.length - 1) {
    v /= 1000;
    i++;
  }
  return `${new Intl.NumberFormat(l === 'de' ? 'de-DE' : 'en-US', { maximumFractionDigits: v < 10 && i ? 1 : 0 }).format(v)} ${units[i]}`;
}
function rel(l: Locale, iso: string, now: string) {
  const rtf = new Intl.RelativeTimeFormat(l === 'de' ? 'de-DE' : 'en-US', { numeric: 'auto' });
  const d = toMin(iso) - toMin(now);
  const a = Math.abs(d);
  if (a < 60) return rtf.format(Math.round(d), 'minute');
  if (a < 1440) return rtf.format(Math.round(d / 60), 'hour');
  return rtf.format(Math.round(d / 1440), 'day');
}

/** Next run after now for a new schedule, as YYYY-MM-DDTHH:mm, computed in UTC so server and browser agree. */
function nextRun(every: 'daily' | 'weekly' | 'monthly', time: string, now: string) {
  const [h, m] = time.split(':').map(Number);
  const base = new Date(`${now.slice(0, 16)}:00Z`);
  const d = new Date(Date.UTC(base.getUTCFullYear(), base.getUTCMonth(), base.getUTCDate(), h || 0, m || 0));
  if (every === 'monthly') {
    d.setUTCDate(1);
    if (d <= base) d.setUTCMonth(d.getUTCMonth() + 1);
  } else if (every === 'weekly') {
    d.setUTCDate(d.getUTCDate() + ((8 - d.getUTCDay()) % 7));
    if (d <= base) d.setUTCDate(d.getUTCDate() + 7);
  } else if (d <= base) d.setUTCDate(d.getUTCDate() + 1);
  return d.toISOString().slice(0, 16);
}

const FORMAT_ICON: Record<ExportFormat, IconName> = { csv: 'table', xlsx: 'layout', json: 'file-code', pdf: 'file' };
const control = '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 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)] sm:h-10 sm:text-[14px]';

function Header({ title, subtitle, children }: { title: string; subtitle?: React.ReactNode; children?: React.ReactNode }) {
  return (
    <div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
      <div className="min-w-0">
        {subtitle && <p className="text-[13px] text-muted-foreground">{subtitle}</p>}
        <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="flex flex-wrap items-center gap-2">{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('flex min-w-0 flex-col rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]', 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-[13px] text-muted-foreground">{description}</p>}
          </div>
          {action}
        </div>
      )}
      <div className={cn('flex-1 p-4 sm:p-5', bodyClassName)}>{children}</div>
    </section>
  );
}

function Segmented<T extends string>({ options, value, onChange, label, className }: { options: { value: T; label: React.ReactNode }[]; 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}
          tabIndex={value === o.value ? 0 : -1}
          onClick={() => onChange(o.value)}
          onKeyDown={(e) => onKey(e, i)}
          className={cn(
            'relative inline-flex h-11 min-w-11 sm:h-10 sm:min-w-0 flex-1 shrink-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-md px-3 text-[13px] font-medium outline-none transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-[color-mix(in_oklab,var(--primary)_50%,transparent)] sm:h-8',
            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 inline-flex items-center gap-1.5">{o.label}</span>
        </button>
      ))}
    </div>
  );
}

function Live({ text }: { text: string }) {
  return (
    <p role="status" aria-live="polite" className="sr-only">
      {text}
    </p>
  );
}

/** A drawn file with its type on the fold */
function FileArt({ format, className }: { format: ExportFormat; className?: string }) {
  const tone = format === 'csv' || format === 'xlsx' ? 'bg-success' : format === 'json' ? 'bg-primary' : 'bg-destructive';
  return (
    <span aria-hidden className={cn('relative grid h-11 w-9 shrink-0 place-items-end rounded-md border border-border bg-card shadow-[var(--shadow-sm)]', className)}>
      <span className="absolute end-0 top-0 size-2.5 rounded-bl-[4px] border-b border-s border-border bg-surface-2" />
      <span className={cn('mb-1.5 me-auto ms-1 rounded-[3px] px-1 text-[8.5px] font-bold uppercase leading-[14px] text-white', tone)}>{format}</span>
    </span>
  );
}

/** A radio card in a grid, the picked one lit */
function Tiles<T extends string>({ label, value, onChange, items, className }: { label: string; value: T; onChange: (v: T) => void; items: { value: T; title: string; text: string; icon?: IconName; art?: React.ReactNode }[]; className?: string }) {
  const id = useId();
  return (
    <fieldset className={className}>
      <legend className="mb-2 text-[13px] font-medium text-foreground">{label}</legend>
      <div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
        {items.map((x) => {
          const on = x.value === value;
          return (
            <label key={x.value} className={cn('relative flex cursor-pointer flex-col gap-2 rounded-xl border p-3 transition-[border-color,background-color,box-shadow] duration-200 has-[:focus-visible]:outline has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-[var(--ring)]', on ? 'border-[color-mix(in_oklab,var(--primary)_60%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_6%,var(--card))] shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_12%,transparent)]' : 'border-border hover:border-border-strong')}>
              <input type="radio" name={id} checked={on} onChange={() => onChange(x.value)} className="peer sr-only" />
              <span className="flex items-start justify-between">
                {x.art ?? (x.icon && <Icon name={x.icon} size={18} className="text-muted-foreground" />)}
                <span aria-hidden className={cn('grid size-[18px] place-items-center rounded-full border transition-colors', on ? 'border-primary bg-primary' : 'border-border-strong')}>
                  <span className={cn('size-[7px] rounded-full bg-primary-foreground transition-transform duration-300', on ? 'scale-100' : 'scale-0')} />
                </span>
              </span>
              <span>
                <span className="block text-[14px] font-semibold text-foreground">{x.title}</span>
                <span className="block text-[12px] leading-snug text-muted-foreground">{x.text}</span>
              </span>
            </label>
          );
        })}
      </div>
    </fieldset>
  );
}

/* ------------------------------------------------------------------ */
/* quick-export                                                        */
/* ------------------------------------------------------------------ */

function QuickExport({ t, title, subtitle, entity, counts = { all: 0 }, locale, onAction }: P) {
  const id = useId();
  const [format, setFormat] = useState<ExportFormat>('csv');
  const [scope, setScope] = useState<'all' | 'filtered' | 'selected'>(counts.filtered !== undefined ? 'filtered' : 'all');
  const [headers, setHeaders] = useState(true);
  const [iso, setIso] = useState(false);
  const [state, setState] = useState<'idle' | 'busy' | 'done'>('idle');
  const [progress, setProgress] = useState(0);
  const [area, enter] = useEnter<HTMLDivElement>();
  const n = scope === 'all' ? counts.all : scope === 'filtered' ? counts.filtered ?? 0 : counts.selected ?? 0;
  const size = Math.round(n * (format === 'pdf' ? 420 : format === 'xlsx' ? 160 : format === 'json' ? 380 : 210));
  const file = `${entity.toLowerCase().replace(/\s+/g, '-')}-2026-10-19.${format}`;
  const go = async () => {
    setState('busy');
    setProgress(0);
    const steps = prefersReducedMotion() ? 1 : 16;
    for (let i = 1; i <= steps; i++) {
      await wait(70);
      setProgress(i / steps);
    }
    await run(onAction, 'export', { format, scope, headers, iso }, 0);
    setState('done');
  };
  const scopes: { v: typeof scope; label: string; n?: number }[] = [
    { v: 'all', label: fill(t.all, { entity }), n: counts.all },
    ...(counts.filtered !== undefined ? [{ v: 'filtered' as const, label: t.filtered, n: counts.filtered }] : []),
    ...(counts.selected !== undefined ? [{ v: 'selected' as const, label: t.selected, n: counts.selected }] : []),
  ];
  return (
    <Main>
      <Header title={title} subtitle={subtitle} />
      <div ref={area} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,1fr)_21rem] lg:items-start">
        <section className="kitm-up min-w-0 rounded-xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] sm:p-6">
          <Tiles
            label={t.format}
            value={format}
            onChange={(v) => {
              setFormat(v);
              setState('idle');
            }}
            items={(['csv', 'xlsx', 'json', 'pdf'] as ExportFormat[]).map((f) => ({ value: f, title: t[f], text: t[`${f}Text` as 'csvText'], art: <FileArt format={f} /> }))}
          />
          <fieldset className="mt-6">
            <legend className="mb-2 text-[13px] font-medium text-foreground">{t.which}</legend>
            <div className="grid gap-2">
              {scopes.map((s) => (
                <label key={s.v} className={cn('flex min-h-12 cursor-pointer items-center gap-3 rounded-lg border px-3.5 transition-colors', scope === s.v ? 'border-[color-mix(in_oklab,var(--primary)_60%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_5%,var(--card))]' : 'border-border hover:border-border-strong')}>
                  <input
                    type="radio"
                    name={`${id}-scope`}
                    checked={scope === s.v}
                    onChange={() => {
                      setScope(s.v);
                      setState('idle');
                    }}
                    className="size-4 accent-[var(--primary)]"
                  />
                  <span className="flex-1 text-[14px] text-foreground">{s.label}</span>
                  <span className="text-[13px] tabular-nums text-muted-foreground">{fill(t.rowsN, { n: num(locale, s.n ?? 0) })}</span>
                </label>
              ))}
            </div>
          </fieldset>
          <div className="mt-6 grid gap-1 border-t border-border pt-4">
            <p className="mb-1 text-[13px] font-medium text-foreground">{t.options}</p>
            <Switch checked={headers} onCheckedChange={setHeaders} label={t.headers} className="py-1.5" />
            <Switch checked={iso} onCheckedChange={setIso} label={t.dates} className="py-1.5" />
          </div>
        </section>
        {/* The file that comes out */}
        <aside className="kitm-up [--kitm-d:120ms] rounded-xl border border-border bg-card p-5 shadow-[var(--highlight),var(--shadow-sm)] lg:sticky lg:top-6">
          <div className="flex items-center gap-3.5">
            <FileArt key={format} format={format} className="kitm-pop h-14 w-11" />
            <div className="min-w-0">
              <p className="truncate text-[14px] font-semibold text-foreground">{file}</p>
              <p className="text-[12.5px] tabular-nums text-muted-foreground">
                {fill(t.rowsN, { n: num(locale, n) })} · ~{bytes(locale, size)}
              </p>
            </div>
          </div>
          <div className="mt-5" role="status" aria-live="polite">
            {state === 'busy' && (
              <div className="kitm-in">
                <p className="text-[13px] text-muted-foreground">{t.exporting}</p>
                <span className="mt-2 block h-2 overflow-hidden rounded-full bg-track">
                  <span className="block h-full origin-left rounded-full bg-primary transition-transform duration-100" style={{ transform: `scaleX(${round(progress, 3)})` }} />
                </span>
              </div>
            )}
            {state === 'done' && (
              <div className="kitm-in rounded-lg bg-[color-mix(in_oklab,var(--success)_9%,var(--card))] p-3.5">
                <p className="flex items-center gap-2 text-[14px] font-semibold text-foreground">
                  <Icon name="check-circle" size={16} className="text-success-foreground" />
                  {t.ready}
                </p>
                <p className="mt-1 text-[12.5px] text-muted-foreground">{t.emailToo}</p>
              </div>
            )}
          </div>
          <div className="mt-5 grid gap-2">
            {state === 'done' ? (
              <>
                <Button iconLeft="download" onClick={() => void onAction?.('download', { file })}>
                  {t.download}
                </Button>
                <Button variant="ghost" size="sm" onClick={() => setState('idle')}>
                  {t.another}
                </Button>
              </>
            ) : (
              <Button disabled={!n || state === 'busy'} iconLeft={state === 'busy' ? 'loader' : 'download'} onClick={go}>
                {state === 'busy' ? t.exporting : fill(t.export, { n: num(locale, n) })}
              </Button>
            )}
          </div>
        </aside>
      </div>
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* columns                                                             */
/* ------------------------------------------------------------------ */

function Columns({ t, title, subtitle, columns: start = [], onAction }: P) {
  const [cols, setCols] = useState(start.map((c) => ({ ...c, on: c.on ?? true })));
  const [saved, setSaved] = useState(false);
  const [area, enter] = useEnter<HTMLDivElement>();
  const listRef = useRef<HTMLUListElement>(null);
  const [rowRef, fade] = useScrollFade<HTMLDivElement>();
  const on = cols.filter((c) => c.on);
  const set = (fn: (l: typeof cols) => typeof cols) => {
    setCols(fn);
    setSaved(false);
  };
  const move = (i: number, d: number) =>
    set((l) => {
      const j = i + d;
      if (j < 0 || j >= l.length) return l;
      const c = [...l];
      [c[i], c[j]] = [c[j], c[i]];
      return c;
    });
  const before = useRef<Map<string, number>>(new Map());
  if (typeof window !== 'undefined' && listRef.current) before.current = new Map(Array.from(listRef.current.children, (el) => [(el as HTMLElement).dataset.id ?? '', el.getBoundingClientRect().top]));
  useIso(() => {
    if (!listRef.current || prefersReducedMotion()) return;
    Array.from(listRef.current.children).forEach((el) => {
      const a = before.current.get((el as HTMLElement).dataset.id ?? '');
      const b = el.getBoundingClientRect().top;
      if (a !== undefined && Math.abs(a - b) > 1) el.animate([{ transform: `translateY(${a - b}px)` }, { transform: 'none' }], { duration: 400, easing: KITM_EASE });
    });
  }, [cols.map((c) => c.id).join()]);
  const rows = Math.max(0, ...on.map((c) => c.samples?.length ?? 0));
  return (
    <Main>
      <Header title={title} subtitle={subtitle} />
      <p className="mt-2 text-[13.5px] text-muted-foreground">{t.columnsText}</p>
      <div ref={area} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[20rem_minmax(0,1fr)] lg:items-start">
        <Card
          title={t.columns}
          description={<span key={on.length} className="kitm-in tabular-nums">{fill(t.picked, { n: on.length, total: cols.length })}</span>}
          className="kitm-up"
          bodyClassName="p-0 sm:p-0"
          action={
            <span className="flex gap-1">
              <button type="button" onClick={() => set((l) => l.map((c) => ({ ...c, on: true })))} className="h-11 min-w-11 rounded-md px-2.5 text-[12.5px] font-medium text-muted-foreground hover:bg-accent hover:text-foreground sm:h-8 sm:min-w-0 sm:px-2">
                {t.pickAll}
              </button>
              <button type="button" onClick={() => set((l) => l.map((c) => ({ ...c, on: false })))} className="h-11 min-w-11 rounded-md px-2.5 text-[12.5px] font-medium text-muted-foreground hover:bg-accent hover:text-foreground sm:h-8 sm:min-w-0 sm:px-2">
                {t.pickNone}
              </button>
            </span>
          }
        >
          <ul ref={listRef} className="divide-y divide-border">
            {cols.map((c, i) => (
              <li key={c.id} data-id={c.id} className={cn('flex items-center gap-2 bg-card px-3 py-1.5 transition-colors', !c.on && 'bg-surface')}>
                <label className="flex min-h-11 min-w-0 flex-1 cursor-pointer items-center gap-3 px-1">
                  <input type="checkbox" checked={c.on} onChange={() => set((l) => l.map((x) => (x.id === c.id ? { ...x, on: !x.on } : x)))} className="size-4 shrink-0 accent-[var(--primary)]" />
                  <span className={cn('truncate text-[14px]', c.on ? 'text-foreground' : 'text-muted-foreground')}>{c.label}</span>
                </label>
                <button type="button" aria-label={fill(t.moveUp, { name: c.label })} disabled={i === 0} onClick={() => move(i, -1)} className="grid size-11 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-30 sm:size-8">
                  <Icon name="chevron-up" size={15} />
                </button>
                <button type="button" aria-label={fill(t.moveDown, { name: c.label })} disabled={i === cols.length - 1} onClick={() => move(i, 1)} className="grid size-11 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-30 sm:size-8">
                  <Icon name="chevron-down" size={15} />
                </button>
              </li>
            ))}
          </ul>
        </Card>
        <section className="kitm-up [--kitm-d:100ms] min-w-0 overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]">
          <div className="flex items-center justify-between gap-3 border-b border-border px-4 py-3.5 sm:px-5">
            <h2 className="text-[14px] font-semibold text-foreground">{t.preview}</h2>
            <Button
              size="sm"
              variant={saved ? 'ghost' : 'outline'}
              iconLeft={saved ? 'check' : 'bookmark'}
              disabled={!on.length}
              onClick={async () => {
                await run(onAction, 'save-columns', { columns: on.map((c) => c.id) });
                setSaved(true);
              }}
            >
              {saved ? t.saved : t.saveSet}
            </Button>
          </div>
          {on.length ? (
            <div ref={rowRef} className={cn('kit-no-scrollbar overflow-x-auto outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[color-mix(in_oklab,var(--primary)_50%,transparent)]', fade)} tabIndex={0} aria-label={t.preview}>
              <table className="w-full min-w-max border-collapse text-left text-[13px]">
                <thead>
                  <tr className="border-b border-border bg-surface">
                    {on.map((c) => (
                      <th key={c.id} scope="col" className="kitm-in whitespace-nowrap px-4 py-2.5 text-[12px] font-semibold text-foreground">
                        {c.label}
                      </th>
                    ))}
                  </tr>
                </thead>
                <tbody className="divide-y divide-border">
                  {Array.from({ length: rows }, (_, r) => (
                    <tr key={r}>
                      {on.map((c) => (
                        <td key={c.id} className="max-w-[14rem] truncate whitespace-nowrap px-4 py-2.5 tabular-nums text-muted-foreground">
                          {c.samples?.[r] ?? ''}
                        </td>
                      ))}
                    </tr>
                  ))}
                </tbody>
              </table>
            </div>
          ) : (
            <p className="px-5 py-12 text-center text-[13.5px] text-muted-foreground">{t.noColumns}</p>
          )}
        </section>
      </div>
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* scheduled                                                           */
/* ------------------------------------------------------------------ */

function Scheduled({ t, title, subtitle, schedules = [], now, locale, onAction }: P) {
  const id = useId();
  const [items, setItems] = useState(schedules);
  const [name, setName] = useState('');
  const [format, setFormat] = useState<ExportFormat>('xlsx');
  const [every, setEvery] = useState<'daily' | 'weekly' | 'monthly'>('weekly');
  const [time, setTime] = useState('07:00');
  const [toKind, setToKind] = useState<'email' | 'folder'>('email');
  const [to, setTo] = useState('');
  const [tried, setTried] = useState(false);
  const [busy, setBusy] = useState(false);
  const [msg, setMsg] = useState('');
  const [fresh, setFresh] = useState('');
  const [area, enter] = useEnter<HTMLDivElement>();
  const add = async (e: React.FormEvent) => {
    e.preventDefault();
    setTried(true);
    if (!name.trim() || !to.trim()) return;
    setBusy(true);
    await run(onAction, 'schedule', { name, format, every, time, toKind, to });
    const s: ScheduledExport = { id: `s${Date.now()}`, name: name.trim(), format, when: `${t[every]}, ${time}`, to: to.trim(), toKind, enabled: true, next: nextRun(every, time, now) };
    setItems((l) => [s, ...l]);
    setFresh(s.id);
    setName('');
    setTo('');
    setTried(false);
    setBusy(false);
    setMsg(fill(t.added, { name: s.name }));
  };
  return (
    <Main>
      <Header title={title} subtitle={subtitle} />
      <p className="mt-2 text-[13.5px] text-muted-foreground">{t.scheduledText}</p>
      <div ref={area} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,1fr)_22rem] lg:items-start">
        <ul className="grid grid-cols-[minmax(0,1fr)] gap-3">
          {items.map((s, i) => (
            <li key={s.id} className={cn('kitm-up flex items-start gap-4 rounded-xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] transition-opacity sm:p-5', !s.enabled && 'opacity-70', s.id === fresh && 'kitm-flash')} style={{ '--kitm-d': `${i * 60}ms` } as React.CSSProperties}>
              <FileArt format={s.format} />
              <div className="min-w-0 flex-1">
                <p className="truncate text-[15px] font-semibold text-foreground">{s.name}</p>
                <p className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-[12.5px] text-muted-foreground">
                  <span className="inline-flex items-center gap-1.5">
                    <Icon name="repeat" size={13} />
                    {s.when}
                  </span>
                  <span className="inline-flex min-w-0 items-center gap-1.5">
                    <Icon name={s.toKind === 'email' ? 'mail' : 'folder'} size={13} className="shrink-0" />
                    <span className="truncate">{s.to}</span>
                  </span>
                </p>
                <p className={cn('mt-1 text-[12.5px] font-medium', s.enabled ? 'text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]' : 'text-subtle-foreground')}>{s.enabled ? (s.next ? fill(t.next, { when: rel(locale, s.next, now) }) : '') : t.paused}</p>
              </div>
              <Switch
                checked={s.enabled}
                aria-label={fill(t.toggle, { name: s.name })}
                onCheckedChange={(v) => {
                  setItems((l) => l.map((x) => (x.id === s.id ? { ...x, enabled: v } : x)));
                  void run(onAction, 'toggle', { id: s.id, enabled: v });
                }}
              />
            </li>
          ))}
        </ul>
        <form noValidate onSubmit={add} className="kitm-up [--kitm-d:120ms] grid gap-4 rounded-xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] sm:p-5 lg:sticky lg:top-6">
          <h2 className="text-[14px] font-semibold text-foreground">{t.newSchedule}</h2>
          <div className="grid gap-1.5">
            <label className="text-[13px] font-medium text-foreground" htmlFor={`${id}-n`}>{t.name}</label>
            <input id={`${id}-n`} value={name} onChange={(e) => setName(e.target.value)} aria-invalid={tried && !name.trim()} aria-describedby={tried && !name.trim() ? `${id}-ne` : undefined} className={cn(control, tried && !name.trim() && 'border-[color-mix(in_oklab,var(--destructive)_70%,var(--border))]')} />
            {tried && !name.trim() && <span id={`${id}-ne`} className="kitm-in text-[12.5px] font-medium text-destructive">{t.nameError}</span>}
          </div>
          <div className="grid gap-1.5">
            <span className="text-[13px] font-medium text-foreground">{t.format}</span>
            <Segmented label={t.format} value={format} onChange={setFormat} className="flex w-full" options={(['csv', 'xlsx', 'json', 'pdf'] as ExportFormat[]).map((f) => ({ value: f, label: t[f] }))} />
          </div>
          <div className="grid grid-cols-[minmax(0,1fr)_6.5rem] gap-3">
            <label className="grid gap-1.5 text-[13px] font-medium text-foreground" htmlFor={`${id}-e`}>
              {t.every}
              <span className="relative block">
                <select id={`${id}-e`} value={every} onChange={(e) => setEvery(e.target.value as typeof every)} className={cn(control, 'appearance-none pe-8')}>
                  <option value="daily">{t.daily}</option>
                  <option value="weekly">{t.weekly}</option>
                  <option value="monthly">{t.monthly}</option>
                </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>
            <label className="grid gap-1.5 text-[13px] font-medium text-foreground" htmlFor={`${id}-t`}>
              {t.at}
              <input id={`${id}-t`} type="time" value={time} onChange={(e) => setTime(e.target.value)} className={cn(control, 'tabular-nums')} />
            </label>
          </div>
          <div className="grid gap-1.5">
            <span className="text-[13px] font-medium text-foreground">{t.sendTo}</span>
            <Segmented
              label={t.sendTo}
              value={toKind}
              onChange={setToKind}
              className="flex w-full"
              options={[
                { value: 'email', label: <><Icon name="mail" size={14} />{t.toEmail}</> },
                { value: 'folder', label: <><Icon name="folder" size={14} />{t.toFolder}</> },
              ]}
            />
            <input aria-label={toKind === 'email' ? t.address : t.folder} value={to} onChange={(e) => setTo(e.target.value)} placeholder={toKind === 'email' ? 'team@example.com' : '/exports/weekly'} aria-invalid={tried && !to.trim()} aria-describedby={tried && !to.trim() ? `${id}-te` : undefined} className={cn(control, 'mt-1', tried && !to.trim() && 'border-[color-mix(in_oklab,var(--destructive)_70%,var(--border))]')} />
            {tried && !to.trim() && <span id={`${id}-te`} className="kitm-in text-[12.5px] font-medium text-destructive">{t.toError}</span>}
          </div>
          <Button type="submit" disabled={busy} iconLeft={busy ? 'loader' : 'plus'}>
            {busy ? t.adding : t.add}
          </Button>
        </form>
      </div>
      <Live text={msg} />
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* history                                                             */
/* ------------------------------------------------------------------ */

function History({ t, title, subtitle, files = [], now, locale, onAction }: P) {
  const [items, setItems] = useState(files);
  const [filter, setFilter] = useState<'all' | ExportFile['status']>('all');
  const [area, enter] = useEnter<HTMLDivElement>();
  const shown = items.filter((f) => filter === 'all' || f.status === filter);
  const label: Record<ExportFile['status'], string> = { ready: t.statusReady, running: t.statusRunning, expired: t.statusExpired, failed: t.statusFailed };
  const tone: Record<ExportFile['status'], string> = {
    ready: 'bg-[color-mix(in_oklab,var(--success)_14%,transparent)] text-success-foreground',
    running: 'bg-[color-mix(in_oklab,var(--primary)_12%,transparent)] text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]',
    expired: 'bg-muted text-muted-foreground',
    failed: 'bg-[color-mix(in_oklab,var(--destructive)_11%,transparent)] text-destructive',
  };
  // A running export finishes on its own after a moment
  useEffect(() => {
    if (!items.some((f) => f.status === 'running')) return;
    const id = window.setTimeout(() => setItems((l) => l.map((f) => (f.status === 'running' ? { ...f, status: 'ready', expires: f.expires ?? now } : f))), prefersReducedMotion() ? 0 : 3200);
    return () => window.clearTimeout(id);
  }, [items, now]);
  const rerun = (f: ExportFile) => {
    const n: ExportFile = { ...f, id: `e${Date.now()}`, created: now, status: 'running', expires: undefined };
    setItems((l) => [n, ...l]);
    void onAction?.('rerun', { id: f.id });
  };
  return (
    <Main>
      <Header title={title} subtitle={subtitle}>
        <Segmented
          label={t.history}
          value={filter}
          onChange={setFilter}
          className="grid w-full grid-cols-2 gap-1 sm:flex sm:w-auto"
          options={[
            { value: 'all', label: t.allStatus },
            { value: 'ready', label: t.statusReady },
            { value: 'expired', label: t.statusExpired },
            { value: 'failed', label: t.statusFailed },
          ]}
        />
      </Header>
      <p className="mt-2 text-[13.5px] text-muted-foreground">{t.historyText}</p>
      <div ref={area} {...enter} className="mt-6">
        <ul className="kitm-up overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]">
          {shown.map((f) => (
            <li key={f.id} className={cn('grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-4 gap-y-2 border-b border-border px-4 py-3.5 last:border-b-0 sm:px-5 md:grid-cols-[auto_minmax(0,1.4fr)_minmax(0,1fr)_7rem_10rem]', f.status === 'running' && 'kitm-fly')}>
              <FileArt format={f.format} className={cn(f.status === 'expired' && 'opacity-50')} />
              <div className="min-w-0">
                <p className={cn('truncate text-[14px] font-medium', f.status === 'expired' ? 'text-muted-foreground' : 'text-foreground')}>{f.name}</p>
                <p className="truncate text-[12.5px] tabular-nums text-muted-foreground">
                  {fill(t.rowsN, { n: num(locale, f.rows) })} · {bytes(locale, f.size)}
                  <span className="md:hidden"> · {rel(locale, f.created, now)}</span>
                </p>
              </div>
              <p className="hidden min-w-0 text-[12.5px] text-muted-foreground md:block">
                <span className="block tabular-nums">{rel(locale, f.created, now)}</span>
                {f.by && <span className="block truncate">{fill(t.by, { name: f.by })}</span>}
              </p>
              <span className={cn('col-start-2 inline-flex h-6 w-fit items-center gap-1.5 rounded-md px-2 text-[12px] font-semibold md:col-start-auto', tone[f.status])}>
                {f.status === 'running' && <Icon name="loader" size={12} className="motion-safe:animate-spin" />}
                {label[f.status]}
              </span>
              <div className="col-start-3 row-span-2 row-start-1 flex items-center justify-end gap-1 md:col-start-auto md:row-span-1 md:row-start-auto">
                {f.status === 'ready' && (
                  <Button size="sm" variant="outline" iconLeft="download" onClick={() => void onAction?.('download', { id: f.id })} title={f.expires ? fill(t.expiresIn, { when: rel(locale, f.expires, now) }) : undefined}>
                    <span className="max-sm:sr-only">{t.download}</span>
                  </Button>
                )}
                {(f.status === 'expired' || f.status === 'failed') && (
                  <Button size="sm" variant="ghost" iconLeft="rotate-cw" onClick={() => rerun(f)}>
                    <span className="max-sm:sr-only">{f.status === 'failed' ? t.retry : t.rerun}</span>
                  </Button>
                )}
              </div>
            </li>
          ))}
        </ul>
      </div>
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* account-archive                                                     */
/* ------------------------------------------------------------------ */

function Archive({ t, title, subtitle, parts = [], email = '', locale, onAction }: P) {
  const id = useId();
  const [picked, setPicked] = useState<Set<string>>(new Set(parts.map((p) => p.id)));
  const [pw, setPw] = useState('');
  const [tried, setTried] = useState(false);
  const [state, setState] = useState<'idle' | 'busy' | 'done'>('idle');
  const [area, enter] = useEnter<HTMLDivElement>();
  const total = parts.filter((p) => picked.has(p.id)).reduce((a, p) => a + p.size, 0);
  const max = parts.reduce((a, p) => a + p.size, 0) || 1;
  const minutes = Math.max(5, Math.round(total / 40_000_000));
  const submit = async (e: React.FormEvent) => {
    e.preventDefault();
    setTried(true);
    if (!pw || !picked.size) return;
    setState('busy');
    await run(onAction, 'archive', { parts: [...picked] }, 900);
    setState('done');
  };
  const steps = [t.step1, t.step2, t.step3];
  return (
    <Main>
      <Header title={title} subtitle={subtitle} />
      <div ref={area} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,1fr)_20rem] lg:items-start">
        <form noValidate onSubmit={submit} className="kitm-up min-w-0 rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]">
          <div className="flex items-start gap-3.5 border-b border-border p-4 sm:p-6">
            <span className="grid size-10 shrink-0 place-items-center rounded-lg bg-[color-mix(in_oklab,var(--primary)_12%,var(--card))] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">
              <Icon name="archive" size={18} />
            </span>
            <p className="max-w-[40rem] text-pretty text-[14px] leading-relaxed text-muted-foreground">{t.archiveText}</p>
          </div>
          {state === 'done' ? (
            <div className="kitm-in px-4 py-10 text-center sm:px-6">
              <span className="mx-auto grid size-14 place-items-center rounded-full bg-[color-mix(in_oklab,var(--primary)_12%,var(--card))] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">
                <Icon name="archive" size={24} className="kitm-pop" />
              </span>
              <h3 className="mt-5 text-[18px] font-semibold text-foreground">{t.requested}</h3>
              <p className="mx-auto mt-2 max-w-[28rem] text-pretty text-[14px] text-muted-foreground">{fill(t.requestedText, { email })}</p>
              <p className="mt-4 inline-flex items-center gap-2 rounded-full bg-muted px-3 py-1 text-[12.5px] font-medium text-foreground">
                <Icon name="clock" size={13} />
                {fill(t.eta, { n: minutes })}
              </p>
            </div>
          ) : (
            <div className="grid gap-6 p-4 sm:p-6">
              <fieldset>
                <legend className="flex w-full items-baseline justify-between gap-3 text-[13px] font-medium text-foreground">
                  {t.whatIn}
                  <span key={total} className="kitm-in text-[12.5px] font-normal tabular-nums text-muted-foreground">
                    {fill(t.total, { size: bytes(locale, total) })}
                  </span>
                </legend>
                {/* One bar for the whole archive, each part as wide as its size */}
                <span aria-hidden className="mt-3 flex h-2.5 gap-[2px] overflow-hidden rounded-full bg-track">
                  {parts.map((p, i) => (
                    <span key={p.id} className="h-full transition-[flex-grow,opacity] duration-500 ease-[var(--ease-kit)]" style={{ flexGrow: picked.has(p.id) ? p.size / max : 0.0001, flexBasis: 0, background: `color-mix(in oklab, var(--primary) ${100 - i * 14}%, var(--muted))` }} />
                  ))}
                </span>
                <ul className="mt-4 grid gap-2 sm:grid-cols-2">
                  {parts.map((p) => {
                    const on = picked.has(p.id);
                    return (
                      <li key={p.id}>
                        <label className={cn('flex min-h-14 cursor-pointer items-start gap-3 rounded-lg border p-3 transition-[border-color,background-color]', on ? 'border-[color-mix(in_oklab,var(--primary)_50%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_5%,var(--card))]' : 'border-border hover:border-border-strong')}>
                          <input
                            type="checkbox"
                            checked={on}
                            onChange={() =>
                              setPicked((s) => {
                                const n = new Set(s);
                                n.has(p.id) ? n.delete(p.id) : n.add(p.id);
                                return n;
                              })
                            }
                            className="mt-0.5 size-4 shrink-0 accent-[var(--primary)]"
                          />
                          <span className="min-w-0 flex-1">
                            <span className="flex items-center justify-between gap-2 text-[13.5px] font-medium text-foreground">
                              <span className="flex min-w-0 items-center gap-2">
                                {p.icon && <Icon name={p.icon} size={14} className="shrink-0 text-muted-foreground" />}
                                <span className="truncate">{p.label}</span>
                              </span>
                              <span className="shrink-0 text-[12px] font-normal tabular-nums text-subtle-foreground">{bytes(locale, p.size)}</span>
                            </span>
                            {p.description && <span className="mt-0.5 block text-pretty text-[12.5px] leading-snug text-muted-foreground">{p.description}</span>}
                          </span>
                        </label>
                      </li>
                    );
                  })}
                </ul>
              </fieldset>
              <div className="grid gap-1.5 sm:max-w-sm">
                <label className="text-[13px] font-medium text-foreground" htmlFor={`${id}-pw`}>{t.confirm}</label>
                <input id={`${id}-pw`} type="password" autoComplete="current-password" value={pw} onChange={(e) => setPw(e.target.value)} placeholder={t.password} aria-invalid={tried && !pw} aria-describedby={tried && !pw ? `${id}-pwe` : undefined} className={cn(control, tried && !pw && 'border-[color-mix(in_oklab,var(--destructive)_70%,var(--border))]')} />
                {tried && !pw && <span id={`${id}-pwe`} className="kitm-in text-[12.5px] font-medium text-destructive">{t.passwordError}</span>}
              </div>
              <Button type="submit" disabled={state === 'busy' || !picked.size} iconLeft={state === 'busy' ? 'loader' : 'archive'} className="max-sm:w-full sm:justify-self-start">
                {state === 'busy' ? t.requesting : t.request}
              </Button>
            </div>
          )}
        </form>
        <aside className="kitm-up [--kitm-d:120ms] rounded-xl border border-border bg-surface p-4 sm:p-5 lg:sticky lg:top-6">
          <h2 className="text-[14px] font-semibold text-foreground">{t.steps}</h2>
          <ol className="relative mt-4 grid gap-4">
            <span aria-hidden className="absolute bottom-3 start-[11px] top-3 w-px bg-border" />
            {steps.map((s, i) => (
              <li key={s} className="relative flex items-center gap-3">
                <span className={cn('relative z-[1] grid size-6 shrink-0 place-items-center rounded-full text-[11.5px] font-semibold tabular-nums ring-4 ring-surface transition-colors duration-500', state === 'done' && i === 0 ? 'bg-primary text-primary-foreground' : 'bg-card text-foreground ring-offset-0 [box-shadow:inset_0_0_0_1px_var(--border)]')}>
                  {state === 'done' && i === 0 ? <Icon name="loader" size={12} className="motion-safe:animate-spin" /> : i + 1}
                </span>
                <span className="text-[13.5px] text-foreground">{s}</span>
              </li>
            ))}
          </ol>
        </aside>
      </div>
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* DataExport                                                          */
/* ------------------------------------------------------------------ */

/**
 * Export screens for apps: a quick export with format, rows and a ready
 * file, columns to pick and order with a preview, scheduled exports to an
 * address or folder, the export history and a full account archive.
 *   <DataExport variant="quick-export" title="Export" entity="contacts" counts={{ all, filtered, selected }} />
 */
export function DataExport({ variant = 'quick-export', sidebar, labels, className, entity = 'records', now = '2026-10-19T14:30', ...props }: DataExportProps) {
  const t = useTexts(TEXTS, labels);
  const locale = useLocale();
  const p: P = { ...props, t, locale, entity, now };
  const body = (() => {
    switch (variant) {
      case 'columns':
        return <Columns {...p} />;
      case 'scheduled':
        return <Scheduled {...p} />;
      case 'history':
        return <History {...p} />;
      case 'account-archive':
        return <Archive {...p} />;
      default:
        return <QuickExport {...p} />;
    }
  })();
  if (!sidebar)
    return (
      <div className={cn('min-h-dvh bg-background', className)}>
        <KitmStyle />
        {body}
      </div>
    );
  return (
    <Sidebar mobile="drawer" {...sidebar} className={className}>
      <KitmStyle />
      {body}
    </Sidebar>
  );
}

Häufige Fragen

Wie installiere ich Datenexport?

Mit aiw add @ai-website/data-export. Der Befehl legt die Datei components/ui/data-export.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, switch und sidebar. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Datenexport?

5 Varianten, Schneller Export, Spalten wählen, Geplante Exporte, Exportverlauf und Kontoarchiv. Du wählst sie mit der Prop variant, zum Beispiel variant="columns".

Kann ich die Texte von Datenexport übersetzen?

Ja. Datenexport bringt 83 eigene Texte auf Deutsch und Englisch mit, etwa „Format“, „CSV“ und „Excel“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Datenexport einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Datenexport mit aiw_components und installieren sie mit aiw_add @ai-website/data-export. Nenn im Prompt die Variante, etwa variant="quick-export", dann schreibt der Assistent deine Inhalte direkt in die Props.

Kann ich Datenexport ohne Code anpassen?

Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden DataExport-Code zum Kopieren.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert