Files
dejanandClaude Opus 5 6a0b36ddcc Geviertstriche aus Text, Kommentaren und Doku entfernen
467 Stellen. Nicht durch einen anderen Strich ersetzt, sondern durch das
Satzzeichen, das an der jeweiligen Stelle richtig ist: Doppelpunkt, wo eine
Erlaeuterung folgt; Komma, wo der Satz weiterlaeuft; Punkt, wo zwei
Hauptsaetze aneinanderstiessen. Ein Halbgeviertstrich waere nur derselbe
Griff mit kuerzerem Strich gewesen.

Regelbasiert ersetzt und danach nachgeprueft, weil eine Regel allein nicht
reicht. Gefunden und einzeln korrigiert:

- Eingeschobene Saetze mit Strichpaar. Nur der oeffnende Strich passte ins
  Muster, der schliessende blieb als "—," stehen. Drei Stellen, jetzt mit
  Klammern oder als eigener Satz.

- Doppelte Doppelpunkte, wo beide Striche eines Paars zu Doppelpunkten wurden
  ("Globale Regeln: Schrift, Groessen, Abstaende: kommen aus HiveGrid").
  Sieben Stellen in Doku und Oberflaeche, jetzt in Klammern.

- Pfade, denen ein Komma statt eines Doppelpunkts folgte.

- Ausgerichtete Kommentartabellen, in denen der Strich eine Spalte war.

Nicht angefasst: drei Geviertstriche, die keine Prosa sind, sondern
Tabellenplatzhalter fuer "kein Wert" (Trichter, Auftraege, Kunden). Dort ist
der Strich die Angabe selbst; ihn zu entfernen liesse leere Zellen zurueck.

tsc, lint, build, verify:carbon und verify:layout laufen sauber.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPeZNwSeU7qG3RRCwaxEZX
2026-08-31 20:51:19 +02:00

222 lines
8.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import React from "react";
import {
FeatherArrowDown,
FeatherArrowUp,
FeatherChevronLeft,
FeatherChevronRight,
FeatherSearch,
} from "@subframe/core";
export interface Spalte<T> {
key: string;
titel: string;
/** Zellinhalt; darf beliebige Knoten liefern (Badges, Links). */
zelle: (zeile: T) => React.ReactNode;
/** Sortierwert, ohne diesen ist die Spalte nicht sortierbar. */
sortWert?: (zeile: T) => number | string;
/** Zahlen gehören rechtsbündig und in Tabellenziffern. */
numerisch?: boolean;
breite?: string;
}
/**
* Tabelle für Arbeitslisten (offene Rechnungen, Aufträge, Kunden).
*
* Enthält die Interaktionen, die man in einer Demo wirklich braucht:
* Sortieren per Klick auf den Spaltenkopf, Volltextsuche, Blättern.
* Alles clientseitig, bei echten Datenmengen gehört das auf den Server.
*/
export function DataTable<T extends { id: string }>({
zeilen,
spalten,
proSeite = 8,
suchbar = true,
suchFelder,
leerText = "Keine Einträge",
}: {
zeilen: T[];
spalten: Spalte<T>[];
proSeite?: number;
suchbar?: boolean;
/** Felder, die die Suche durchsucht. */
suchFelder?: (zeile: T) => string;
leerText?: string;
}) {
const [suche, setSuche] = React.useState("");
const [seite, setSeite] = React.useState(0);
const [sortierung, setSortierung] = React.useState<{ key: string; richtung: "asc" | "desc" } | null>(
null
);
const gefiltert = React.useMemo(() => {
if (!suche.trim() || !suchFelder) return zeilen;
const begriff = suche.toLowerCase();
return zeilen.filter((zeile) => suchFelder(zeile).toLowerCase().includes(begriff));
}, [zeilen, suche, suchFelder]);
const sortiert = React.useMemo(() => {
if (!sortierung) return gefiltert;
const spalte = spalten.find((s) => s.key === sortierung.key);
if (!spalte?.sortWert) return gefiltert;
return [...gefiltert].sort((a, b) => {
const wertA = spalte.sortWert!(a);
const wertB = spalte.sortWert!(b);
const vergleich =
typeof wertA === "number" && typeof wertB === "number"
? wertA - wertB
: String(wertA).localeCompare(String(wertB), "de");
return sortierung.richtung === "asc" ? vergleich : -vergleich;
});
}, [gefiltert, sortierung, spalten]);
const seiten = Math.max(1, Math.ceil(sortiert.length / proSeite));
const aktuelleSeite = Math.min(seite, seiten - 1);
const sichtbar = sortiert.slice(aktuelleSeite * proSeite, (aktuelleSeite + 1) * proSeite);
const sortiereNach = (key: string) => {
setSeite(0);
setSortierung((aktuell) =>
aktuell?.key === key
? { key, richtung: aktuell.richtung === "asc" ? "desc" : "asc" }
: { key, richtung: "desc" }
);
};
return (
<div className="flex min-w-0 flex-col gap-3">
{suchbar && suchFelder ? (
<div className="flex h-8 items-center gap-2 rounded-md border border-solid border-border-default px-2.5">
<FeatherSearch className="text-[14px] text-text-neutral-tertiary" />
<input
value={suche}
onChange={(event) => {
setSuche(event.target.value);
setSeite(0);
}}
placeholder="In Liste suchen …"
className="w-full bg-transparent text-[13px] outline-none placeholder:text-text-neutral-subtle"
/>
</div>
) : null}
{/* Der waagerechte Scrollbereich der Tabelle. Ohne min-w-0 am Elternteil
greift er nicht: Ein Flex-Kind wird nicht schmaler als sein Inhalt,
die Tabelle bliebe also breit und schöbe stattdessen die Seite auf.
Vorher trug der Bereich zusätzlich -mx-1/px-1: das machte ihn 8 px
breiter als seine Spalte und ließ die Karte um 4 px überlaufen. Den
Fokusring der Sortierknöpfe deckt bereits das px-2 der Kopfzelle. */}
<div className="dv-scroll w-full min-w-0 overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="border-b border-solid border-border-default">
{spalten.map((spalte) => {
const aktiv = sortierung?.key === spalte.key;
return (
<th
key={spalte.key}
style={{ width: spalte.breite }}
aria-sort={
!spalte.sortWert
? undefined
: aktiv
? sortierung!.richtung === "asc"
? "ascending"
: "descending"
: "none"
}
className={`px-2 pb-2 text-[12px] font-medium text-text-neutral-tertiary ${
spalte.numerisch ? "text-right" : "text-left"
}`}
>
{spalte.sortWert ? (
<button
onClick={() => sortiereNach(spalte.key)}
className={`inline-flex items-center gap-1 hover:text-text-neutral-primary ${
spalte.numerisch ? "flex-row-reverse" : ""
} ${aktiv ? "text-text-neutral-primary" : ""}`}
>
{spalte.titel}
{aktiv ? (
sortierung!.richtung === "asc" ? (
<FeatherArrowUp className="text-[11px]" />
) : (
<FeatherArrowDown className="text-[11px]" />
)
) : null}
</button>
) : (
spalte.titel
)}
</th>
);
})}
</tr>
</thead>
<tbody>
{sichtbar.map((zeile) => (
<tr
key={zeile.id}
className="border-b border-solid border-border-default last:border-b-0"
>
{spalten.map((spalte) => (
<td
key={spalte.key}
className={`px-2 py-2.5 text-[13px] text-text-neutral-primary ${
spalte.numerisch ? "text-right tabular-nums" : "text-left"
}`}
>
{spalte.zelle(zeile)}
</td>
))}
</tr>
))}
{!sichtbar.length ? (
<tr>
<td
colSpan={spalten.length}
className="px-2 py-10 text-center text-[13px] text-text-neutral-tertiary"
>
{leerText}
</td>
</tr>
) : null}
</tbody>
</table>
</div>
{sortiert.length > proSeite ? (
<div className="dv-no-print flex items-center justify-between">
<span className="text-[12px] text-text-neutral-tertiary">
{aktuelleSeite * proSeite + 1}–{Math.min((aktuelleSeite + 1) * proSeite, sortiert.length)} von{" "}
{sortiert.length}
</span>
<div className="flex items-center gap-1">
<button
onClick={() => setSeite((s) => Math.max(0, s - 1))}
disabled={aktuelleSeite === 0}
aria-label="Vorherige Seite"
className="flex h-8 w-8 items-center justify-center rounded-md border border-solid border-border-default text-text-neutral-secondary disabled:opacity-40 enabled:hover:bg-neutral-50"
>
<FeatherChevronLeft className="text-[14px]" />
</button>
<span className="px-1 text-[12px] tabular-nums text-text-neutral-secondary">
{aktuelleSeite + 1} / {seiten}
</span>
<button
onClick={() => setSeite((s) => Math.min(seiten - 1, s + 1))}
disabled={aktuelleSeite >= seiten - 1}
aria-label="Nächste Seite"
className="flex h-8 w-8 items-center justify-center rounded-md border border-solid border-border-default text-text-neutral-secondary disabled:opacity-40 enabled:hover:bg-neutral-50"
>
<FeatherChevronRight className="text-[14px]" />
</button>
</div>
</div>
) : null}
</div>
);
}