Benutzer-UI: Suche, Restspalte, Inline-Bestaetigung, Detaildialog
Docker Image / test (push) Successful in 2m16s
Docker Image / image (push) Successful in 31s

This commit is contained in:
loki committed 2026-10-11 22:03:32 +02:00
1 parent 5939a7f785
commit 66ca955ff8
1 file changed
+148 -32
+148 -32
View File
@@ -4,29 +4,58 @@ import { useCallback, useEffect, useState } from "react";
import {
createUser,
deleteUser,
getTeam,
listUsers,
setUserRole,
type ManagedUser,
type TeamMember,
} from "@/lib/api-client";
import { formatDays, shortGermanDate } from "@/lib/format";
const ROLES = ["benutzer", "teamleiter", "admin"] as const;
export function UsersTab({ selfId }: { selfId: string }) {
const [users, setUsers] = useState<ManagedUser[] | null>(null);
const [remainingById, setRemainingById] = useState<Record<string, number>>({});
const [failed, setFailed] = useState(false);
const [query, setQuery] = useState("");
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState<(typeof ROLES)[number]>("benutzer");
const [busy, setBusy] = useState(false);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
const [detailsId, setDetailsId] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [teamById, setTeamById] = useState<Record<string, TeamMember>>({});
const reload = useCallback(async () => {
try {
const data = await listUsers();
setUsers(data.users);
const [usersResult, teamResult] = await Promise.allSettled([
listUsers(),
getTeam(new Date().getFullYear()),
]);
if (usersResult.status === "rejected") {
throw usersResult.reason;
}
setUsers(usersResult.value.users);
if (teamResult.status === "fulfilled") {
setRemainingById(
Object.fromEntries(
teamResult.value.team.map((member) => [
member.id,
member.remainingUnits,
]),
),
);
setTeamById(
Object.fromEntries(
teamResult.value.team.map((member) => [member.id, member]),
),
);
}
setFailed(false);
} catch {
setFailed(true);
@@ -83,6 +112,7 @@ export function UsersTab({ selfId }: { selfId: string }) {
async function remove(id: string) {
setMessage(null);
setError(null);
setDeleting(true);
try {
await deleteUser(id);
setConfirmDelete(null);
@@ -94,6 +124,8 @@ export function UsersTab({ selfId }: { selfId: string }) {
? removeError.message
: "Löschen fehlgeschlagen.",
);
} finally {
setDeleting(false);
}
}
@@ -114,23 +146,56 @@ export function UsersTab({ selfId }: { selfId: string }) {
);
}
const needle = query.trim().toLowerCase();
const visible =
needle === ""
? users
: users.filter(
(user) =>
user.name.toLowerCase().includes(needle) ||
user.email.toLowerCase().includes(needle),
);
return (
<section aria-label="Benutzer">
<h2>Benutzer</h2>
<table>
<label>
Suchen
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Name oder E-Mail"
/>
</label>
<table className="users-table">
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">E-Mail</th>
<th scope="col">Rest</th>
<th scope="col">Rolle</th>
<th scope="col">Aktion</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
{visible.map((user) => (
<tr key={user.id}>
<th scope="row">{user.name}</th>
<td>{user.email}</td>
<th scope="row">
<button
type="button"
className="link-button"
aria-label={`Details zu ${user.name}`}
onClick={() => setDetailsId(user.id)}
>
<strong>{user.name}</strong>
</button>
<span className="muted-note">{user.email}</span>
</th>
<td>
{remainingById[user.id] !== undefined
? formatDays(remainingById[user.id])
: "–"}
</td>
<td>
<label>
Rolle
@@ -148,43 +213,94 @@ export function UsersTab({ selfId }: { selfId: string }) {
</label>
</td>
<td>
{user.id !== selfId && confirmDelete !== user.id && (
<button
type="button"
className="btn-secondary"
aria-label={`${user.name} löschen`}
onClick={() => setConfirmDelete(user.id)}
>
Löschen
</button>
)}
{user.id !== selfId &&
(confirmDelete === user.id ? (
<>
<button
type="button"
className="btn-danger"
disabled={deleting}
onClick={() => remove(user.id)}
>
Ja, löschen
</button>
<button
type="button"
className="btn-secondary"
onClick={() => setConfirmDelete(null)}
>
Abbrechen
</button>
</>
) : (
<button
type="button"
className="btn-secondary"
aria-label={`${user.name} löschen`}
onClick={() => setConfirmDelete(user.id)}
>
Löschen
</button>
))}
</td>
</tr>
))}
</tbody>
</table>
{confirmDelete && (
<div className="confirm-row">
<p>Wirklich löschen? Alle Zeitdaten dieses Kontos entfallen.</p>
<div>
{visible.length === 0 && <p>Keine Treffer.</p>}
{detailsId && teamById[detailsId] && (
<div className="popup-overlay">
<div
role="dialog"
aria-label={teamById[detailsId].name}
className="popup"
>
<button
type="button"
className="btn-danger"
onClick={() => remove(confirmDelete)}
className="modal-close"
aria-label="Schließen"
onClick={() => setDetailsId(null)}
>
Ja, löschen
</button>
<button
type="button"
className="btn-secondary"
onClick={() => setConfirmDelete(null)}
>
Abbrechen
✕
</button>
<h2>{teamById[detailsId].name}</h2>
<p>
Resturlaub: {formatDays(teamById[detailsId].remainingUnits)}
{teamById[detailsId].pendingUnits > 0 &&
` (davon ${formatDays(teamById[detailsId].pendingUnits)} beantragt)`}
</p>
<h3>Buchungen</h3>
{teamById[detailsId].requests.length === 0 ? (
<p>Keine Buchungen.</p>
) : (
<ul>
{teamById[detailsId].requests.map((request) => (
<li key={request.id}>
{request.startDate === request.endDate
? shortGermanDate(request.startDate)
: `${shortGermanDate(request.startDate)} – ${shortGermanDate(request.endDate)}`}{" "}
· {formatDays(request.units)} · {request.status}
</li>
))}
</ul>
)}
<h3>Krankmeldungen</h3>
{teamById[detailsId].sickDays.length === 0 ? (
<p>Keine Krankmeldungen.</p>
) : (
<ul>
{teamById[detailsId].sickDays.map((day) => (
<li key={day.date}>
{shortGermanDate(day.date)} · {formatDays(day.units)}
{day.note ? ` · ${day.note}` : ""}
</li>
))}
</ul>
)}
</div>
</div>
)}
<form onSubmit={submit} aria-label="Benutzer anlegen">
<form onSubmit={submit} aria-label="Benutzer anlegen" className="form-card">
<h3>Benutzer anlegen</h3>
<label>
Name