Zum Inhalt springen
UI Kit
Accent

Unterlagen anfordern

Unterlagen anfordern und vollständig zurückbekommen. Eine Zeile je Unterlage, ein Ablegen für alles, das sich selbst einsortiert, eine Anfrage einer Kanzlei mit Frist und die Prüfung des Zurückgeschickten mit Grund, wenn eine neue Fassung nötig ist.

Eine von 4 Components in Anfrageformulare

4 Variants

Variants

Checkliste

Eine Zeile je Unterlage mit eigenem Hochladen, das Symbol wird zum Haken und die Zeile färbt sich, sobald sie da ist. Ein Balken oben füllt sich bis vollständig, Dateien werden auf Format und Größe geprüft, mit Grund.

Im Studio öffnen

Alles auf einmal

Eine große Fläche für alle Dateien, sie hebt sich und leuchtet, während Dateien darüber schweben. Jede Datei wird anhand des Namens einsortiert, ein falscher Tipp wird per Auswahl korrigiert, die Liste daneben hakt sich selbst ab.

Im Studio öffnen

Anfrage einer Kanzlei

Eine Kanzlei bittet um Unterlagen. Neben der Liste stehen, wer fragt, die Frist mit den verbleibenden Tagen und das Versprechen, dass nur sie es sieht. Jede Unterlage trägt einen Hinweis der fragenden Person.

Im Studio öffnen

Prüfstand

Was zurückkam und wo es steht, in einem Balken der Zustände und einer Zeile je Unterlage. Eine Unterlage, die eine neue Fassung braucht, sagt warum und bietet das Hochladen direkt an, alles andere bleibt, wie es ist.

Im Studio öffnen

Details

Was Unterlagen anfordern mitbringt, aus Registry und Quellcode gelesen.

Bereich
Content
Variants
4
Version
1.0.0 · seit 1.1.0
Datei
components/ui/file-request.tsx
Bringt mit
utilskitbuttoniconrevealsectionmotionform-flowselect
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 33 eigene Texte

Props

Die Schnittstelle FileRequestProps aus components/ui/file-request.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    FileRequestVariant

    checklistsingle-dropportalreview-status

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • documentsPflicht
    RequestedDocument[]
  • maxMboptional
    number

    Largest file in megabytes, default 20

  • requesteroptional
    { name: string; person?: string; initials?: string; message?: string; deadline?: string; daysLeft?: number }

    portal: who asks, until when, and a short message

  • stepsoptional
    DoneStep[]
  • submitLabeloptional
    string
  • doneoptional
    { title: string; text?: string }
  • onSubmitoptional
    (files: Record<string, File[]>) => void

Eingebaute Texte

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

Alle 33 Texte anzeigen
uploadEnglisch Add fileDeutsch Datei hinzufügen
replaceEnglisch ReplaceDeutsch Ersetzen
removeEnglisch Remove {f}Deutsch {f} entfernen
optionalEnglisch OptionalDeutsch Optional
doneEnglisch {n} of {total} readyDeutsch {n} von {total} bereit
completeEnglisch Everything is hereDeutsch Alles da
missingEnglisch Still missingDeutsch Fehlt noch
sendEnglisch Send documentsDeutsch Unterlagen senden
tooBigEnglisch {f} is larger than {n} MB. Please make it smaller or send it as a PDF, so it comes through.Deutsch {f} ist größer als {n} MB. Bitte verkleinere die Datei oder schick sie als PDF, damit sie durchkommt.
wrongTypeEnglisch {f} is not a type we can open here. Please use {a}.Deutsch {f} ist kein Format, das wir hier öffnen können. Bitte nutze {a}.
tooManyEnglisch Please add at most {n} files here.Deutsch Bitte füge hier höchstens {n} Dateien hinzu.
neededEnglisch Please add this document, we cannot finish without it.Deutsch Bitte füge diese Unterlage hinzu, ohne sie können wir nicht abschließen.
dropEnglisch Drop all files hereDeutsch Alle Dateien hier ablegen
dropOrEnglisch or choose them from your deviceDeutsch oder vom Gerät auswählen
dropHintEnglisch We sort them into the list by their names. Change the place if we guessed wrong.Deutsch Wir ordnen sie anhand der Namen in die Liste ein. Ändere den Platz, wenn wir falsch geraten haben.
unsortedEnglisch Not sorted yetDeutsch Noch nicht eingeordnet
pickPlaceEnglisch Choose where it belongsDeutsch Wähle, wohin sie gehört
unsortedNeededEnglisch Please choose where {f} belongs, so we file it right.Deutsch Bitte wähle, wohin {f} gehört, damit wir sie richtig ablegen.
fromEnglisch Request from {n}Deutsch Anfrage von {n}
dueEnglisch Please send by {d}Deutsch Bitte bis {d} senden
daysLeftEnglisch {n} days leftDeutsch noch {n} Tage
secureEnglisch Encrypted and only visible to {n}Deutsch Verschlüsselt und nur für {n} sichtbar
noteEnglisch NoteDeutsch Hinweis
acceptedEnglisch AcceptedDeutsch Angenommen
reviewEnglisch In reviewDeutsch Wird geprüft
rejectedEnglisch New version neededDeutsch Neue Fassung nötig
notYetEnglisch Not sent yetDeutsch Noch nicht gesendet
newVersionEnglisch Send new versionDeutsch Neue Fassung senden
sendUpdatesEnglisch Send the new versionsDeutsch Neue Fassungen senden
doneTitleEnglisch Thank you, your documents are with usDeutsch Danke, deine Unterlagen sind bei uns
doneTextEnglisch We check them within two working days and tell you if anything is missing.Deutsch Wir prüfen sie innerhalb von zwei Werktagen und sagen Bescheid, falls etwas fehlt.
againEnglisch Add more documentsDeutsch Weitere Unterlagen hinzufügen
chooseEnglisch Choose fileDeutsch Datei wählen

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Setzt aria-describedby, aria-invalid, aria-label und aria-live, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rolle alert.
  • 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

  • Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 1 Stellen im Code schalten Bewegung dann ab.

Quellcode

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

Datei anzeigen, 685 Zeilen
components/ui/file-request.tsx
'use client';

import { useId, useRef, useState } from 'react';
import { cn, round, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { NativeSelect } from '@/components/ui/select';
import { Reveal } from '@/components/ui/reveal';
import { Section } from '@/components/ui/section';
import { Stagger } from '@/components/ui/motion';
import { DonePanel, ErrorSummary, FlowHead, type DoneStep, type FlowError } from '@/components/ui/form-flow';

/*
 * Asking for documents and getting them back complete. Every document has
 * its own place, the list says what is still missing, and files are checked
 * for type and size on the way in, with a reason when they are refused.
 *
 *   checklist      one row per document with its own upload, a bar fills to complete
 *   single-drop    drop everything at once, files sort themselves into the list
 *   portal         a request from a firm with a deadline, a note per document and a lock
 *   review-status  what came back: accepted, in review, or a new version needed with the reason
 *
 * Sends { [documentId]: File[] } to `onSubmit`.
 */

export type FileRequestVariant = 'checklist' | 'single-drop' | 'portal' | 'review-status';

export interface RequestedDocument {
  id: string;
  title: string;
  hint?: string;
  icon?: IconName;
  optional?: boolean;
  /** Accepted types, e.g. ".pdf,image/*" */
  accept?: string;
  /** Most files for this document */
  max?: number;
  /** single-drop: words in a file name that belong to this document */
  keywords?: string[];
  /** portal: a note from the person who asks */
  note?: string;
  /** review-status: where it stands */
  status?: 'accepted' | 'review' | 'rejected' | 'missing';
  /** review-status: why a new version is needed */
  reason?: string;
  /** review-status: the file already sent */
  file?: string;
}

export interface FileRequestProps {
  variant?: FileRequestVariant;
  eyebrow?: React.ReactNode;
  title?: React.ReactNode;
  description?: React.ReactNode;
  documents: RequestedDocument[];
  /** Largest file in megabytes, default 20 */
  maxMb?: number;
  /** portal: who asks, until when, and a short message */
  requester?: { name: string; person?: string; initials?: string; message?: string; deadline?: string; daysLeft?: number };
  steps?: DoneStep[];
  submitLabel?: string;
  done?: { title: string; text?: string };
  onSubmit?: (files: Record<string, File[]>) => void;
  labels?: Partial<Texts>;
  className?: string;
}

const TEXTS = {
  en: {
    upload: 'Add file',
    replace: 'Replace',
    remove: 'Remove {f}',
    optional: 'Optional',
    done: '{n} of {total} ready',
    complete: 'Everything is here',
    missing: 'Still missing',
    send: 'Send documents',
    tooBig: '{f} is larger than {n} MB. Please make it smaller or send it as a PDF, so it comes through.',
    wrongType: '{f} is not a type we can open here. Please use {a}.',
    tooMany: 'Please add at most {n} files here.',
    needed: 'Please add this document, we cannot finish without it.',
    drop: 'Drop all files here',
    dropOr: 'or choose them from your device',
    dropHint: 'We sort them into the list by their names. Change the place if we guessed wrong.',
    unsorted: 'Not sorted yet',
    pickPlace: 'Choose where it belongs',
    unsortedNeeded: 'Please choose where {f} belongs, so we file it right.',
    from: 'Request from {n}',
    due: 'Please send by {d}',
    daysLeft: '{n} days left',
    secure: 'Encrypted and only visible to {n}',
    note: 'Note',
    accepted: 'Accepted',
    review: 'In review',
    rejected: 'New version needed',
    notYet: 'Not sent yet',
    newVersion: 'Send new version',
    sendUpdates: 'Send the new versions',
    doneTitle: 'Thank you, your documents are with us',
    doneText: 'We check them within two working days and tell you if anything is missing.',
    again: 'Add more documents',
    choose: 'Choose file',
  },
  de: {
    upload: 'Datei hinzufügen',
    replace: 'Ersetzen',
    remove: '{f} entfernen',
    optional: 'Optional',
    done: '{n} von {total} bereit',
    complete: 'Alles da',
    missing: 'Fehlt noch',
    send: 'Unterlagen senden',
    tooBig: '{f} ist größer als {n} MB. Bitte verkleinere die Datei oder schick sie als PDF, damit sie durchkommt.',
    wrongType: '{f} ist kein Format, das wir hier öffnen können. Bitte nutze {a}.',
    tooMany: 'Bitte füge hier höchstens {n} Dateien hinzu.',
    needed: 'Bitte füge diese Unterlage hinzu, ohne sie können wir nicht abschließen.',
    drop: 'Alle Dateien hier ablegen',
    dropOr: 'oder vom Gerät auswählen',
    dropHint: 'Wir ordnen sie anhand der Namen in die Liste ein. Ändere den Platz, wenn wir falsch geraten haben.',
    unsorted: 'Noch nicht eingeordnet',
    pickPlace: 'Wähle, wohin sie gehört',
    unsortedNeeded: 'Bitte wähle, wohin {f} gehört, damit wir sie richtig ablegen.',
    from: 'Anfrage von {n}',
    due: 'Bitte bis {d} senden',
    daysLeft: 'noch {n} Tage',
    secure: 'Verschlüsselt und nur für {n} sichtbar',
    note: 'Hinweis',
    accepted: 'Angenommen',
    review: 'Wird geprüft',
    rejected: 'Neue Fassung nötig',
    notYet: 'Noch nicht gesendet',
    newVersion: 'Neue Fassung senden',
    sendUpdates: 'Neue Fassungen senden',
    doneTitle: 'Danke, deine Unterlagen sind bei uns',
    doneText: 'Wir prüfen sie innerhalb von zwei Werktagen und sagen Bescheid, falls etwas fehlt.',
    again: 'Weitere Unterlagen hinzufügen',
    choose: 'Datei wählen',
  },
};

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

const fmtSize = (b: number, locale: 'en' | 'de') => {
  const mb = b / 1e6;
  const n = mb >= 1 ? round(mb, 1) : round(b / 1e3, 0);
  return `${n.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US')} ${mb >= 1 ? 'MB' : 'KB'}`;
};

/** Does the file match an accept string like ".pdf,image/*"? */
function accepts(file: File, accept?: string) {
  if (!accept) return true;
  const name = file.name.toLowerCase();
  return accept.split(',').some((a) => {
    const x = a.trim().toLowerCase();
    if (x.startsWith('.')) return name.endsWith(x);
    if (x.endsWith('/*')) return file.type.startsWith(x.slice(0, -1));
    return file.type === x;
  });
}

const readable = (accept?: string) => (accept ?? '').split(',').map((a) => a.trim().replace(/^\./, '').replace('image/*', 'JPG, PNG').toUpperCase()).join(', ');

/** Files per document with the checks every variant shares */
function useFiles(p: FileRequestProps, t: Texts) {
  const [files, setFiles] = useState<Record<string, File[]>>({});
  const [errors, setErrors] = useState<Record<string, string>>({});
  const max = (p.maxMb ?? 20) * 1e6;
  const add = (doc: RequestedDocument, list: File[]) => {
    const current = files[doc.id] ?? [];
    for (const f of list) {
      if (!accepts(f, doc.accept)) return setErrors((e) => ({ ...e, [doc.id]: t.wrongType.replace('{f}', f.name).replace('{a}', readable(doc.accept)) }));
      if (f.size > max) return setErrors((e) => ({ ...e, [doc.id]: t.tooBig.replace('{f}', f.name).replace('{n}', String(p.maxMb ?? 20)) }));
    }
    if (current.length + list.length > (doc.max ?? 1) && (doc.max ?? 1) > 1) return setErrors((e) => ({ ...e, [doc.id]: t.tooMany.replace('{n}', String(doc.max)) }));
    setFiles((x) => ({ ...x, [doc.id]: (doc.max ?? 1) > 1 ? [...current, ...list] : list.slice(0, 1) }));
    setErrors((e) => Object.fromEntries(Object.entries(e).filter(([k]) => k !== doc.id)));
  };
  const remove = (doc: RequestedDocument, i: number) => setFiles((x) => ({ ...x, [doc.id]: (x[doc.id] ?? []).filter((_, j) => j !== i) }));
  return { files, setFiles, errors, setErrors, add, remove };
}

/** A real file input behind a button-like label, keyboard and screen readers included */
function PickButton({ id, doc, label, onFiles, variant = 'outline', describedBy }: { id: string; doc: RequestedDocument; label: string; onFiles: (f: File[]) => void; variant?: 'outline' | 'primary'; describedBy?: string }) {
  return (
    <label
      htmlFor={id}
      className={cn(
        'inline-flex h-11 shrink-0 cursor-pointer items-center justify-center gap-2 rounded-lg px-4 text-[13.5px] font-medium transition-[background-color,border-color,box-shadow] duration-200 has-[:focus-visible]:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_28%,transparent)] sm:h-9',
        variant === 'primary' ? 'bg-primary text-primary-foreground hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))]' : 'border border-border-strong bg-background text-foreground hover:bg-accent',
      )}
    >
      <input
        id={id}
        type="file"
        accept={doc.accept}
        multiple={(doc.max ?? 1) > 1}
        aria-describedby={describedBy}
        aria-invalid={describedBy ? true : undefined}
        onChange={(e) => {
          const list = Array.from(e.target.files ?? []);
          e.target.value = '';
          if (list.length) onFiles(list);
        }}
        className="sr-only"
      />
      <Icon name="upload" size={15} />
      {label}
    </label>
  );
}

function FileChip({ f, onRemove, t }: { f: File; onRemove: () => void; t: Texts }) {
  const locale = useLocale();
  return (
    <li className="kit-rise flex min-w-0 items-center gap-2.5 rounded-lg border border-border bg-background py-1.5 ps-3 pe-1">
      <Icon name="file-check" size={16} className="shrink-0 text-success" />
      <span className="min-w-0 flex-1 truncate text-[13px] text-foreground">{f.name}</span>
      <span className="shrink-0 text-[12px] tabular-nums text-muted-foreground">{fmtSize(f.size, locale)}</span>
      <button type="button" aria-label={t.remove.replace('{f}', f.name)} onClick={onRemove} className="grid size-9 shrink-0 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-[color-mix(in_oklab,var(--foreground)_6%,transparent)] hover:text-foreground focus-visible:shadow-[0_0_0_2px_color-mix(in_oklab,var(--primary)_45%,transparent)] focus-visible:outline-none max-sm:size-11">
        <Icon name="x" size={15} />
      </button>
    </li>
  );
}

function Progress({ n, total, t }: { n: number; total: number; t: Texts }) {
  const share = total ? n / total : 0;
  const complete = n >= total;
  return (
    <div>
      <p aria-live="polite" className="flex items-baseline justify-between gap-3 text-[13px]">
        <span className={cn('font-medium', complete ? 'text-success-foreground' : 'text-foreground')}>{complete ? t.complete : t.done.replace('{n}', String(n)).replace('{total}', String(total))}</span>
      </p>
      <div className="mt-2 h-1.5 overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--foreground)_10%,transparent)]">
        <div className={cn('h-full origin-left rounded-full transition-[transform,background-color] duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none', complete ? 'bg-success' : 'bg-primary')} style={{ transform: `scaleX(${round(share, 3)})` }} />
      </div>
    </div>
  );
}

function Done({ p, t, onAgain, className }: { p: FileRequestProps; t: Texts; onAgain: () => void; className?: string }) {
  return (
    <div className={cn('kit-card mx-auto p-6 sm:p-10', className)}>
      <DonePanel
        title={p.done?.title ?? t.doneTitle}
        text={p.done?.text ?? t.doneText}
        steps={p.steps}
        actions={
          <Button variant="outline" iconLeft="plus" onClick={onAgain}>
            {t.again}
          </Button>
        }
      />
    </div>
  );
}

/** Checks required documents, returns the summary lines and focuses the first */
function useSubmit(p: FileRequestProps, t: Texts, fs: ReturnType<typeof useFiles>, prefix: string) {
  const [done, setDone] = useState(false);
  const summaryRef = useRef<HTMLDivElement>(null);
  const [summary, setSummary] = useState<FlowError[]>([]);
  const submit = (e: React.FormEvent) => {
    e.preventDefault();
    const missing = p.documents.filter((d) => !d.optional && !(fs.files[d.id]?.length) && d.status !== 'accepted' && d.status !== 'review');
    if (missing.length) {
      fs.setErrors((x) => ({ ...x, ...Object.fromEntries(missing.map((d) => [d.id, t.needed])) }));
      setSummary(missing.map((d) => ({ field: `${prefix}-${d.id}`, message: d.title })));
      requestAnimationFrame(() => summaryRef.current?.focus());
      return;
    }
    setSummary([]);
    p.onSubmit?.(fs.files);
    setDone(true);
  };
  return { done, setDone, summaryRef, summary, submit };
}

/* ------------------------------------------------------------------ */
/* checklist and portal rows                                           */
/* ------------------------------------------------------------------ */

function DocRow({ d, id, t, fs, showNote }: { d: RequestedDocument; id: string; t: Texts; fs: ReturnType<typeof useFiles>; showNote?: boolean }) {
  const list = fs.files[d.id] ?? [];
  const ready = list.length > 0;
  const err = fs.errors[d.id];
  return (
    <li className={cn('px-5 py-4 transition-colors duration-500 sm:px-6', ready && 'bg-[color-mix(in_oklab,var(--success)_4%,transparent)]')}>
      <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
        <span className={cn('grid size-10 shrink-0 place-items-center rounded-xl border transition-[background-color,border-color,color] duration-500 max-sm:hidden', ready ? 'border-[color-mix(in_oklab,var(--success)_40%,var(--border))] bg-[color-mix(in_oklab,var(--success)_12%,var(--card))] text-success' : 'border-border bg-surface text-muted-foreground')}>
          <Icon name={ready ? 'check' : (d.icon ?? 'file')} size={18} strokeWidth={ready ? 2.2 : 1.6} />
        </span>
        <div className="min-w-0 flex-1">
          <p className="flex flex-wrap items-center gap-x-2 text-[15px] font-medium text-foreground">
            {d.title}
            {d.optional ? <span className="text-[12px] font-normal text-subtle-foreground">{t.optional}</span> : !ready && <span className="rounded-full bg-[color-mix(in_oklab,var(--foreground)_7%,transparent)] px-2 py-px text-[11.5px] font-normal text-muted-foreground">{t.missing}</span>}
          </p>
          {d.hint && <p className="mt-0.5 text-[13px] text-muted-foreground">{d.hint}</p>}
        </div>
        <PickButton id={`${id}-${d.id}`} doc={d} label={ready && (d.max ?? 1) === 1 ? t.replace : t.upload} onFiles={(f) => fs.add(d, f)} describedBy={err ? `${id}-${d.id}-err` : undefined} />
      </div>
      {showNote && d.note && (
        <p className="mt-3 flex gap-2.5 rounded-lg bg-surface px-3.5 py-2.5 text-[13px] leading-snug text-muted-foreground sm:ms-14">
          <Icon name="message-circle" size={15} className="mt-0.5 shrink-0" />
          <span>
            <span className="sr-only">{t.note}: </span>
            {d.note}
          </span>
        </p>
      )}
      {list.length > 0 && (
        <ul className="mt-3 grid gap-1.5 sm:ms-14">
          {list.map((f, i) => (
            <FileChip key={`${f.name}-${i}`} f={f} t={t} onRemove={() => fs.remove(d, i)} />
          ))}
        </ul>
      )}
      {err && (
        <p id={`${id}-${d.id}-err`} className="kit-rise mt-2.5 flex items-start gap-1.5 text-[13px] font-medium text-destructive sm:ms-14">
          <Icon name="alert-circle" size={15} strokeWidth={1.8} className="mt-[2.5px] shrink-0" />
          {err}
        </p>
      )}
    </li>
  );
}

function Checklist({ p, t }: { p: FileRequestProps; t: Texts }) {
  const id = useId();
  const fs = useFiles(p, t);
  const s = useSubmit(p, t, fs, id);
  const required = p.documents.filter((d) => !d.optional);
  const ready = required.filter((d) => fs.files[d.id]?.length).length;
  if (s.done) return <Done p={p} t={t} onAgain={() => (fs.setFiles({}), s.setDone(false))} className="max-w-[46rem]" />;
  return (
    <Reveal effect="rise" className="mx-auto max-w-[46rem]">
      <form noValidate onSubmit={s.submit} className="kit-card overflow-hidden">
        <div className="grid gap-5 border-b border-border px-5 py-5 sm:px-6">
          <Progress n={ready} total={required.length} t={t} />
          <ErrorSummary ref={s.summaryRef} errors={s.summary} />
        </div>
        <Stagger step={70} as="ul" className="divide-y divide-border">
          {p.documents.map((d) => (
            <DocRow key={d.id} d={d} id={id} t={t} fs={fs} />
          ))}
        </Stagger>
        <div className="flex flex-col gap-3 border-t border-border bg-surface px-5 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-6">
          <p className="text-[13px] text-muted-foreground">{readable([...new Set(p.documents.flatMap((d) => (d.accept ?? '').split(',').map((x) => x.trim())).filter(Boolean))].join(','))} · {p.maxMb ?? 20} MB</p>
          <Button type="submit" icon="send" className="max-sm:w-full">
            {p.submitLabel ?? t.send}
          </Button>
        </div>
      </form>
    </Reveal>
  );
}

function Portal({ p, t }: { p: FileRequestProps; t: Texts }) {
  const id = useId();
  const fs = useFiles(p, t);
  const s = useSubmit(p, t, fs, id);
  const r = p.requester ?? { name: '' };
  const required = p.documents.filter((d) => !d.optional);
  const ready = required.filter((d) => fs.files[d.id]?.length).length;
  const initials = r.initials ?? r.name.split(' ').map((x) => x[0]).join('').slice(0, 2);
  if (s.done) return <Done p={p} t={t} onAgain={() => (fs.setFiles({}), s.setDone(false))} className="max-w-[48rem]" />;
  return (
    <div className="mx-auto grid max-w-[64rem] grid-cols-1 gap-6 lg:grid-cols-[18rem_minmax(0,1fr)] lg:gap-8">
      {/* Who asks, until when, how it is protected. A bar on phones, a column on desktop. */}
      <Reveal effect="rise" className="lg:sticky lg:top-24 lg:self-start">
        <aside className="rounded-2xl border border-border bg-surface p-5 sm:p-6">
          <div className="flex items-center gap-3">
            <span className="grid size-11 shrink-0 place-items-center rounded-xl bg-[color-mix(in_oklab,var(--primary)_16%,var(--card))] text-[14px] font-semibold text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]">{initials}</span>
            <span className="min-w-0">
              <span className="block text-[12.5px] text-muted-foreground">{t.from.replace('{n}', r.name)}</span>
              {r.person && <span className="block truncate text-[15px] font-semibold text-foreground">{r.person}</span>}
            </span>
          </div>
          {r.message && <p className="mt-4 text-pretty text-[14px] leading-relaxed text-foreground">{r.message}</p>}
          <div className="mt-5 grid gap-3 border-t border-border pt-5 sm:grid-cols-2 lg:grid-cols-1">
            {r.deadline && (
              <p className="flex items-center gap-2.5 text-[13.5px] text-foreground">
                <Icon name="calendar" size={16} className="shrink-0 text-muted-foreground" />
                <span>
                  {t.due.replace('{d}', r.deadline)}
                  {r.daysLeft !== undefined && <span className={cn('ms-2 rounded-full px-2 py-px text-[12px] font-medium', r.daysLeft <= 3 ? 'bg-[color-mix(in_oklab,var(--warning)_16%,var(--card))] text-warning-foreground' : 'bg-[color-mix(in_oklab,var(--foreground)_7%,transparent)] text-muted-foreground')}>{t.daysLeft.replace('{n}', String(r.daysLeft))}</span>}
                </span>
              </p>
            )}
            <p className="flex items-center gap-2.5 text-[13.5px] text-muted-foreground">
              <Icon name="lock" size={16} className="shrink-0" />
              {t.secure.replace('{n}', r.name)}
            </p>
          </div>
          <div className="mt-5">
            <Progress n={ready} total={required.length} t={t} />
          </div>
        </aside>
      </Reveal>
      <Reveal effect="rise" delay={100} className="min-w-0">
        <form noValidate onSubmit={s.submit} className="kit-card overflow-hidden">
          {s.summary.length > 0 && (
            <div className="border-b border-border p-5 sm:p-6">
              <ErrorSummary ref={s.summaryRef} errors={s.summary} />
            </div>
          )}
          <Stagger step={70} as="ul" className="divide-y divide-border">
            {p.documents.map((d) => (
              <DocRow key={d.id} d={d} id={id} t={t} fs={fs} showNote />
            ))}
          </Stagger>
          <div className="flex justify-end border-t border-border bg-surface px-5 py-4 sm:px-6">
            <Button type="submit" icon="send" magnetic className="max-sm:w-full">
              {p.submitLabel ?? t.send}
            </Button>
          </div>
        </form>
      </Reveal>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* single-drop                                                         */
/* ------------------------------------------------------------------ */

function guess(f: File, docs: RequestedDocument[]) {
  const name = f.name.toLowerCase();
  return docs.find((d) => d.keywords?.some((k) => name.includes(k.toLowerCase())))?.id ?? '';
}

function SingleDrop({ p, t }: { p: FileRequestProps; t: Texts }) {
  const id = useId();
  const locale = useLocale();
  const [items, setItems] = useState<{ file: File; doc: string }[]>([]);
  const [over, setOver] = useState(false);
  const [error, setError] = useState('');
  const [done, setDone] = useState(false);
  const [summary, setSummary] = useState<FlowError[]>([]);
  const summaryRef = useRef<HTMLDivElement>(null);
  const max = (p.maxMb ?? 20) * 1e6;
  const required = p.documents.filter((d) => !d.optional);
  const ready = required.filter((d) => items.some((x) => x.doc === d.id)).length;
  const take = (list: File[]) => {
    const big = list.find((f) => f.size > max);
    if (big) setError(t.tooBig.replace('{f}', big.name).replace('{n}', String(p.maxMb ?? 20)));
    else setError('');
    setItems((x) => [...x, ...list.filter((f) => f.size <= max).map((file) => ({ file, doc: guess(file, p.documents) }))]);
  };
  if (done) return <Done p={p} t={t} onAgain={() => (setItems([]), setDone(false))} className="max-w-[48rem]" />;
  return (
    <form
      noValidate
      className="mx-auto grid max-w-[64rem] grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_20rem] lg:gap-8"
      onSubmit={(e) => {
        e.preventDefault();
        const unsorted = items.map((x, i) => ({ ...x, i })).filter((x) => !x.doc);
        const missing = required.filter((d) => !items.some((x) => x.doc === d.id));
        const lines: FlowError[] = [...unsorted.map((x) => ({ field: `${id}-f${x.i}`, message: t.unsortedNeeded.replace('{f}', x.file.name) })), ...missing.map((d) => ({ field: `${id}-drop`, message: d.title }))];
        setSummary(lines);
        if (lines.length) {
          requestAnimationFrame(() => summaryRef.current?.focus());
          return;
        }
        const out: Record<string, File[]> = {};
        for (const x of items) (out[x.doc] ??= []).push(x.file);
        p.onSubmit?.(out);
        setDone(true);
      }}
    >
      <div className="grid min-w-0 gap-5">
        <ErrorSummary ref={summaryRef} errors={summary} />
        {/* One zone for everything, it lifts and lights up while files hover over it */}
        <Reveal effect="scale">
          <label
            htmlFor={`${id}-drop`}
            onDragOver={(e) => {
              e.preventDefault();
              setOver(true);
            }}
            onDragLeave={() => setOver(false)}
            onDrop={(e) => {
              e.preventDefault();
              setOver(false);
              take(Array.from(e.dataTransfer.files));
            }}
            className={cn(
              'flex cursor-pointer flex-col items-center justify-center rounded-2xl border-2 border-dashed px-6 py-12 text-center transition-[border-color,background-color,transform] duration-300 has-[:focus-visible]:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_28%,transparent)] sm:py-16',
              over ? 'scale-[1.01] border-primary bg-[color-mix(in_oklab,var(--primary)_7%,var(--card))]' : 'border-border-strong bg-card hover:border-[color-mix(in_oklab,var(--primary)_45%,var(--border-strong))]',
            )}
          >
            <input
              id={`${id}-drop`}
              type="file"
              multiple
              aria-invalid={summary.some((x) => x.field === `${id}-drop`) || undefined}
              onChange={(e) => {
                const list = Array.from(e.target.files ?? []);
                e.target.value = '';
                if (list.length) take(list);
              }}
              className="sr-only"
            />
            <span className={cn('grid size-14 place-items-center rounded-2xl bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))] transition-transform duration-500 ease-[cubic-bezier(0.34,1.6,0.64,1)]', over && '-translate-y-1 scale-110')}>
              <Icon name="cloud-upload" size={26} />
            </span>
            <span className="mt-5 text-[17px] font-semibold text-foreground">{t.drop}</span>
            <span className="mt-1 text-[14px] text-muted-foreground">{t.dropOr}</span>
            <span className="mt-4 max-w-[26rem] text-pretty text-[12.5px] leading-snug text-subtle-foreground">{t.dropHint}</span>
          </label>
        </Reveal>
        {error && (
          <p role="alert" className="kit-rise flex items-start gap-1.5 text-[13px] font-medium text-destructive">
            <Icon name="alert-circle" size={15} strokeWidth={1.8} className="mt-[2.5px] shrink-0" />
            {error}
          </p>
        )}
        {items.length > 0 && (
          <ul className="grid gap-2">
            {items.map((x, i) => (
              <li key={`${x.file.name}-${i}`} className="kit-rise grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 rounded-xl border border-border bg-card p-3 sm:grid-cols-[auto_minmax(0,1fr)_14rem_auto]">
                <Icon name={x.doc ? 'file-check' : 'file'} size={18} className={x.doc ? 'text-success' : 'text-muted-foreground'} />
                <span className="min-w-0">
                  <span className="block truncate text-[14px] text-foreground">{x.file.name}</span>
                  <span className="block text-[12px] tabular-nums text-muted-foreground">{fmtSize(x.file.size, locale)}</span>
                </span>
                <button type="button" aria-label={t.remove.replace('{f}', x.file.name)} onClick={() => setItems((y) => y.filter((_, j) => j !== i))} className="grid size-11 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-[color-mix(in_oklab,var(--foreground)_6%,transparent)] hover:text-foreground focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_28%,transparent)] focus-visible:outline-none sm:order-last sm:size-9">
                  <Icon name="x" size={16} />
                </button>
                <NativeSelect
                  id={`${id}-f${i}`}
                  aria-label={`${x.file.name}, ${t.pickPlace}`}
                  size="sm"
                  placeholder={t.unsorted}
                  invalid={!x.doc && summary.length > 0}
                  options={p.documents.map((d) => ({ value: d.id, label: d.title }))}
                  value={x.doc}
                  onChange={(e) => setItems((y) => y.map((z, j) => (j === i ? { ...z, doc: e.target.value } : z)))}
                  className="col-span-3 sm:col-span-1"
                />
              </li>
            ))}
          </ul>
        )}
      </div>
      {/* The list fills itself as files are sorted */}
      <Reveal effect="rise" delay={120} className="lg:sticky lg:top-24 lg:self-start">
        <aside className="kit-card p-5 sm:p-6">
          <Progress n={ready} total={required.length} t={t} />
          <ul className="mt-5 grid gap-3">
            {p.documents.map((d) => {
              const n = items.filter((x) => x.doc === d.id).length;
              return (
                <li key={d.id} className="flex items-center gap-3 text-[14px]">
                  <span className={cn('grid size-6 shrink-0 place-items-center rounded-full border transition-[background-color,border-color,transform] duration-500 ease-[cubic-bezier(0.34,1.6,0.64,1)]', n ? 'scale-110 border-success bg-success text-background' : 'border-border-strong')}>
                    {n > 0 && <Icon name="check" size={13} strokeWidth={2.6} />}
                  </span>
                  <span className={cn('min-w-0 flex-1', n ? 'text-foreground' : 'text-muted-foreground')}>{d.title}</span>
                  {d.optional ? <span className="text-[12px] text-subtle-foreground">{t.optional}</span> : n > 1 ? <span className="text-[12px] tabular-nums text-muted-foreground">{n}</span> : null}
                </li>
              );
            })}
          </ul>
          <Button type="submit" icon="send" className="mt-6 w-full">
            {p.submitLabel ?? t.send}
          </Button>
        </aside>
      </Reveal>
    </form>
  );
}

/* ------------------------------------------------------------------ */
/* review-status                                                       */
/* ------------------------------------------------------------------ */

const STATUS: Record<NonNullable<RequestedDocument['status']>, { icon: IconName; tone: string; label: keyof Texts }> = {
  accepted: { icon: 'check-circle', tone: 'text-success-foreground bg-[color-mix(in_oklab,var(--success)_12%,var(--card))]', label: 'accepted' },
  review: { icon: 'hourglass', tone: 'text-foreground bg-[color-mix(in_oklab,var(--foreground)_7%,transparent)]', label: 'review' },
  rejected: { icon: 'alert-circle', tone: 'text-warning-foreground bg-[color-mix(in_oklab,var(--warning)_16%,var(--card))]', label: 'rejected' },
  missing: { icon: 'circle', tone: 'text-muted-foreground bg-[color-mix(in_oklab,var(--foreground)_6%,transparent)]', label: 'notYet' },
};

function ReviewStatus({ p, t }: { p: FileRequestProps; t: Texts }) {
  const id = useId();
  const fs = useFiles(p, t);
  const s = useSubmit(p, t, fs, id);
  const open = p.documents.filter((d) => d.status === 'rejected' || d.status === 'missing');
  const counts = (['accepted', 'review', 'rejected'] as const).map((k) => ({ k, n: p.documents.filter((d) => d.status === k).length }));
  if (s.done) return <Done p={p} t={t} onAgain={() => (fs.setFiles({}), s.setDone(false))} className="max-w-[48rem]" />;
  return (
    <Reveal effect="rise" className="mx-auto max-w-[50rem]">
      <form noValidate onSubmit={s.submit} className="kit-card overflow-hidden">
        {/* A stacked bar of the states, then the counts */}
        <div className="border-b border-border px-5 py-5 sm:px-6">
          <div aria-hidden className="flex h-2 gap-1 overflow-hidden rounded-full">
            {p.documents.map((d) => (
              <span key={d.id} className={cn('h-full flex-1 rounded-full transition-colors duration-500', d.status === 'accepted' ? 'bg-success' : d.status === 'review' ? 'bg-[color-mix(in_oklab,var(--foreground)_30%,transparent)]' : d.status === 'rejected' ? 'bg-warning' : 'bg-[color-mix(in_oklab,var(--foreground)_10%,transparent)]', fs.files[d.id]?.length && 'bg-primary')} />
            ))}
          </div>
          <p className="mt-3 flex flex-wrap gap-x-5 gap-y-1 text-[13px] text-muted-foreground">
            {counts
              .filter((c) => c.n)
              .map((c) => (
                <span key={c.k} className="tabular-nums">
                  <span className="font-semibold text-foreground">{c.n}</span> {t[STATUS[c.k].label]}
                </span>
              ))}
          </p>
          {s.summary.length > 0 && <ErrorSummary ref={s.summaryRef} errors={s.summary} className="mt-4" />}
        </div>
        <Stagger step={70} as="ul" className="divide-y divide-border">
          {p.documents.map((d) => {
            const st = STATUS[d.status ?? 'missing'];
            const fresh = fs.files[d.id] ?? [];
            const canSend = d.status === 'rejected' || d.status === 'missing';
            return (
              <li key={d.id} className="px-5 py-4 sm:px-6">
                <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
                  <div className="min-w-0 flex-1">
                    <p className="text-[15px] font-medium text-foreground">{d.title}</p>
                    {d.file && <p className="mt-0.5 truncate text-[13px] text-muted-foreground">{d.file}</p>}
                  </div>
                  <span className={cn('inline-flex h-7 shrink-0 items-center gap-1.5 self-start rounded-full px-2.5 text-[12.5px] font-medium sm:self-center', fresh.length ? 'bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-foreground' : st.tone)}>
                    <Icon name={fresh.length ? 'upload' : st.icon} size={14} />
                    {fresh.length ? t.review : t[st.label]}
                  </span>
                  {canSend && <PickButton id={`${id}-${d.id}`} doc={d} label={d.status === 'rejected' ? t.newVersion : t.choose} onFiles={(f) => fs.add(d, f)} variant={d.status === 'rejected' && !fresh.length ? 'primary' : 'outline'} describedBy={d.reason ? `${id}-${d.id}-why` : undefined} />}
                </div>
                {d.status === 'rejected' && d.reason && !fresh.length && (
                  <p id={`${id}-${d.id}-why`} className="kit-rise mt-3 flex gap-2.5 rounded-lg bg-[color-mix(in_oklab,var(--warning)_16%,var(--card))] px-3.5 py-2.5 text-[13.5px] leading-snug text-warning-foreground">
                    <Icon name="info" size={16} className="mt-px shrink-0" />
                    {d.reason}
                  </p>
                )}
                {fresh.length > 0 && (
                  <ul className="mt-3 grid gap-1.5">
                    {fresh.map((f, i) => (
                      <FileChip key={`${f.name}-${i}`} f={f} t={t} onRemove={() => fs.remove(d, i)} />
                    ))}
                  </ul>
                )}
                {fs.errors[d.id] && (
                  <p className="kit-rise mt-2.5 flex items-start gap-1.5 text-[13px] font-medium text-destructive">
                    <Icon name="alert-circle" size={15} strokeWidth={1.8} className="mt-[2.5px] shrink-0" />
                    {fs.errors[d.id]}
                  </p>
                )}
              </li>
            );
          })}
        </Stagger>
        {open.length > 0 && (
          <div className="flex justify-end border-t border-border bg-surface px-5 py-4 sm:px-6">
            <Button type="submit" icon="send" className="max-sm:w-full">
              {p.submitLabel ?? t.sendUpdates}
            </Button>
          </div>
        )}
      </form>
    </Reveal>
  );
}

/* ------------------------------------------------------------------ */
/* Section                                                             */
/* ------------------------------------------------------------------ */

/**
 * Collect documents.
 *   <FileRequest variant="checklist" documents={[{ id, title, hint, accept: '.pdf', max: 1 }]} onSubmit={(files) => …} />
 */
export function FileRequest(props: FileRequestProps) {
  const { variant = 'checklist', eyebrow, title, description, labels, className } = props;
  const t = useTexts(TEXTS, labels);
  return (
    <Section className={className}>
      <FlowHead eyebrow={eyebrow} title={title} description={description} align="center" className="mb-10 sm:mb-14" />
      {variant === 'checklist' && <Checklist p={props} t={t} />}
      {variant === 'single-drop' && <SingleDrop p={props} t={t} />}
      {variant === 'portal' && <Portal p={props} t={t} />}
      {variant === 'review-status' && <ReviewStatus p={props} t={t} />}
    </Section>
  );
}

Häufige Fragen

Wie installiere ich Unterlagen anfordern?

Mit aiw add @ai-website/file-request. Der Befehl legt die Datei components/ui/file-request.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, reveal, section, motion, form-flow und select. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Unterlagen anfordern?

4 Varianten, Checkliste, Alles auf einmal, Anfrage einer Kanzlei und Prüfstand. Du wählst sie mit der Prop variant, zum Beispiel variant="single-drop".

Kann ich die Texte von Unterlagen anfordern übersetzen?

Ja. Unterlagen anfordern bringt 33 eigene Texte auf Deutsch und Englisch mit, etwa „Datei hinzufügen“, „Ersetzen“ und „Optional“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Unterlagen anfordern einbauen?

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

Kann ich Unterlagen anfordern ohne Code anpassen?

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

Oft gesucht

Direkt zu

In die Zwischenablage kopiert