feat: Excel-artige DataTable (Spaltenbreite/Sortieren/Filtern) für alle Tabellen

- Portierte DataTable-Komponente (aus Vorrania): Spalten per Drag breiter ziehen
  und verschieben, Klick-Sortierung, Filterzeile je Spalte, Spalten ein-/ausblenden;
  Breiten/Reihenfolge/Filter pro Tabelle im Browser gemerkt (localStorage)
- Zu langer Zellinhalt wird mit „…" abgeschnitten (Titel bleibt sichtbar, Excel-artig)
- Umgestellt: Buchungen, Import-Prüfansicht (inkl. interaktiver Zellen), Import-
  Verlauf, Benutzer
- Neue Skeleton-Komponente für den Ladezustand

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-19 22:58:01 +02:00
co-authored by Claude Opus 4.8
parent e02ef7eb8f
commit 1d5b23c127
6 changed files with 685 additions and 173 deletions
+58 -45
View File
@@ -1,10 +1,11 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, ApiError } from "../api";
import { useAuth } from "../auth";
import { useConfirm } from "../confirm";
import { useToast } from "../toast";
import Icon from "../components/Icon";
import DataTable from "../components/DataTable";
function randomPassword(len = 12) {
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789";
@@ -95,6 +96,55 @@ export default function Users() {
}
}
const userColumns = useMemo(() => [
{
key: "name", header: t("users.username"), grow: true, min: 180,
filterText: (u) => u.display_name || u.username,
render: (u) => (
<span className="ellipsis" style={{ display: "block" }}>
<strong>{u.display_name || u.username}</strong>
{u.display_name && <span className="muted small"> · {u.username}</span>}
{u.id === self?.id && <span className="muted small"> {t("users.you")}</span>}
</span>
),
},
{
key: "role", header: t("users.role"), width: 120,
filterText: (u) => (u.role === "admin" ? t("common.admin") : t("common.user")),
render: (u) => <span className="badge">{u.role === "admin" ? t("common.admin") : t("common.user")}</span>,
},
{
key: "status", header: t("users.status"), width: 120,
filterText: (u) => (u.is_active ? t("users.active") : t("users.inactive")),
render: (u) => (
<span className={`badge ${u.is_active ? "ok" : ""}`}>
{u.is_active ? t("users.active") : t("users.inactive")}
</span>
),
},
{
key: "actions", header: t("common.actions"), width: 132, fixed: true,
render: (u) => (
<div className="nowrap" style={{ textAlign: "right" }}>
<button className="btn sm ghost" title={t("users.resetPassword")} onClick={() => passwortZuruecksetzen(u)}>
<Icon name="key" size={15} />
</button>
{u.id !== self?.id && (
<>
<button className="btn sm ghost" title={u.is_active ? t("users.inactive") : t("users.active")} onClick={() => aktivUmschalten(u)}>
<Icon name={u.is_active ? "eye-off" : "eye"} size={15} />
</button>
<button className="btn sm danger" title={t("common.delete")} onClick={() => loeschen(u)}>
<Icon name="trash" size={15} />
</button>
</>
)}
</div>
),
},
// eslint-disable-next-line react-hooks/exhaustive-deps
], [t, self]);
return (
<div>
<h1>{t("users.title")}</h1>
@@ -145,50 +195,13 @@ export default function Users() {
</div>
<div className="card">
<table className="table">
<thead>
<tr>
<th>{t("users.username")}</th>
<th>{t("users.role")}</th>
<th>{t("users.status")}</th>
<th style={{ textAlign: "right" }}>{t("common.actions")}</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id}>
<td>
<strong>{u.display_name || u.username}</strong>
{u.display_name && <span className="muted small"> · {u.username}</span>}
{u.id === self?.id && <span className="muted small"> {t("users.you")}</span>}
</td>
<td>
<span className="badge">{u.role === "admin" ? t("common.admin") : t("common.user")}</span>
</td>
<td>
<span className={`badge ${u.is_active ? "ok" : ""}`}>
{u.is_active ? t("users.active") : t("users.inactive")}
</span>
</td>
<td style={{ textAlign: "right", whiteSpace: "nowrap" }}>
<button className="btn sm ghost" title={t("users.resetPassword")} onClick={() => passwortZuruecksetzen(u)}>
<Icon name="key" size={15} />
</button>
{u.id !== self?.id && (
<>
<button className="btn sm ghost" title={u.is_active ? t("users.inactive") : t("users.active")} onClick={() => aktivUmschalten(u)}>
<Icon name={u.is_active ? "eye-off" : "eye"} size={15} />
</button>
<button className="btn sm danger" title={t("common.delete")} onClick={() => loeschen(u)}>
<Icon name="trash" size={15} />
</button>
</>
)}
</td>
</tr>
))}
</tbody>
</table>
<DataTable
id="users"
rows={users}
getRowKey={(u) => u.id}
empty={t("users.title")}
columns={userColumns}
/>
</div>
</div>
);