Olympus/frontend/athena/app/users/page.tsx

306 lines
9.2 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Plus } from "lucide-react";
import ConfirmDialog from "@/components/common/ConfirmDialog";
import SearchInput from "@/components/common/SearchInput";
import { Button } from "@/components/ui/button";
import UserFormDialog from "@/components/users/UserFormDialog";
import UserTable from "@/components/users/UserTable";
import { api } from "@/lib/api";
import type { User, UserPayload, UserRole } from "@/types/user";
const pageSize = 10;
type StatusFilter = "all" | "active" | "inactive";
function getErrorMessage(error: unknown) {
if (typeof error === "object" && error !== null && "response" in error) {
const response = (error as { response?: { data?: { detail?: string } } }).response;
return response?.data?.detail ?? "Aktion konnte nicht abgeschlossen werden";
}
return "Aktion konnte nicht abgeschlossen werden";
}
function getSortableValue(user: User, key: string) {
const value = user[key as keyof User];
return typeof value === "string" ? value.toLowerCase() : value;
}
export default function UsersPage() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [formError, setFormError] = useState("");
const [deleteError, setDeleteError] = useState("");
const [pending, setPending] = useState(false);
const [search, setSearch] = useState("");
const [roleFilter, setRoleFilter] = useState<UserRole | "all">("all");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [sortKey, setSortKey] = useState("created_at");
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");
const [page, setPage] = useState(1);
const [formOpen, setFormOpen] = useState(false);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [deleteUser, setDeleteUser] = useState<User | null>(null);
const loadUsers = useCallback(async () => {
try {
const response = await api.get<User[]>("/users");
setUsers(response.data);
} catch (err) {
setError(getErrorMessage(err));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
queueMicrotask(() => {
void loadUsers();
});
}, [loadUsers]);
const filteredUsers = useMemo(() => {
const term = search.trim().toLowerCase();
return users
.filter((user) => {
if (!term) {
return true;
}
return [
user.first_name,
user.last_name,
user.username,
user.email,
user.role,
].some((value) => value.toLowerCase().includes(term));
})
.filter((user) => roleFilter === "all" || user.role === roleFilter)
.filter((user) => {
if (statusFilter === "all") {
return true;
}
return statusFilter === "active" ? user.is_active : !user.is_active;
})
.sort((first, second) => {
const firstValue = getSortableValue(first, sortKey);
const secondValue = getSortableValue(second, sortKey);
if (firstValue === secondValue) {
return 0;
}
const result = firstValue > secondValue ? 1 : -1;
return sortDirection === "asc" ? result : -result;
});
}, [roleFilter, search, sortDirection, sortKey, statusFilter, users]);
const pageCount = Math.max(1, Math.ceil(filteredUsers.length / pageSize));
const currentPage = Math.min(page, pageCount);
const pageUsers = filteredUsers.slice(
(currentPage - 1) * pageSize,
currentPage * pageSize,
);
function handleSort(key: string) {
if (sortKey === key) {
setSortDirection((current) => (current === "asc" ? "desc" : "asc"));
return;
}
setSortKey(key);
setSortDirection("asc");
}
function openCreateDialog() {
setSelectedUser(null);
setFormError("");
setFormOpen(true);
}
function openEditDialog(user: User) {
setSelectedUser(user);
setFormError("");
setFormOpen(true);
}
async function saveUser(payload: UserPayload) {
setPending(true);
setFormError("");
try {
if (selectedUser) {
const response = await api.put<User>(`/users/${selectedUser.id}`, payload);
setUsers((current) => current.map((user) => (
user.id === selectedUser.id ? response.data : user
)));
} else {
const response = await api.post<User>("/users", payload);
setUsers((current) => [response.data, ...current]);
}
setFormOpen(false);
} catch (err) {
setFormError(getErrorMessage(err));
} finally {
setPending(false);
}
}
async function confirmDelete() {
if (!deleteUser) {
return;
}
setPending(true);
setDeleteError("");
try {
await api.delete(`/users/${deleteUser.id}`);
setUsers((current) => current.filter((user) => user.id !== deleteUser.id));
setDeleteUser(null);
} catch (err) {
setDeleteError(getErrorMessage(err));
} finally {
setPending(false);
}
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<h1 className="text-3xl font-bold text-slate-950">Benutzer</h1>
<p className="mt-1 text-sm text-slate-500">
{filteredUsers.length} von {users.length} Benutzern
</p>
</div>
<Button type="button" onClick={openCreateDialog}>
<Plus size={16} />
Neuer Benutzer
</Button>
</div>
<div className="flex flex-col gap-3 rounded-lg border bg-white p-4 lg:flex-row lg:items-center">
<SearchInput
value={search}
onChange={(value) => {
setSearch(value);
setPage(1);
}}
placeholder="Benutzer suchen"
/>
<select
value={roleFilter}
onChange={(event) => {
setRoleFilter(event.target.value as UserRole | "all");
setPage(1);
}}
className="h-8 rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
>
<option value="all">Alle Rollen</option>
<option value="admin">Admin</option>
<option value="manager">Manager</option>
<option value="user">Benutzer</option>
</select>
<select
value={statusFilter}
onChange={(event) => {
setStatusFilter(event.target.value as StatusFilter);
setPage(1);
}}
className="h-8 rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
>
<option value="all">Alle Status</option>
<option value="active">Aktiv</option>
<option value="inactive">Inaktiv</option>
</select>
</div>
<UserTable
users={pageUsers}
loading={loading}
error={error}
sortKey={sortKey}
sortDirection={sortDirection}
onSort={handleSort}
onEdit={openEditDialog}
onDelete={setDeleteUser}
/>
<div className="flex items-center justify-between text-sm text-slate-600">
<span>
Seite {currentPage} von {pageCount}
</span>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
disabled={currentPage === 1}
onClick={() => setPage((current) => Math.max(1, current - 1))}
>
Zurück
</Button>
<Button
type="button"
variant="outline"
disabled={currentPage === pageCount}
onClick={() => setPage((current) => Math.min(pageCount, current + 1))}
>
Weiter
</Button>
</div>
</div>
<UserFormDialog
open={formOpen}
user={selectedUser}
pending={pending}
serverError={formError}
onOpenChange={setFormOpen}
onSubmit={saveUser}
/>
<ConfirmDialog
open={Boolean(deleteUser)}
title="Benutzer löschen"
description="Möchten Sie den Benutzer wirklich löschen?"
pending={pending}
onOpenChange={(open) => {
if (!open) {
setDeleteUser(null);
setDeleteError("");
}
}}
onConfirm={confirmDelete}
>
{deleteUser && (
<dl className="grid gap-2 text-sm">
<div className="flex justify-between gap-4">
<dt className="text-slate-500">Benutzername</dt>
<dd className="font-medium">{deleteUser.username}</dd>
</div>
<div className="flex justify-between gap-4">
<dt className="text-slate-500">E-Mail</dt>
<dd className="font-medium">{deleteUser.email}</dd>
</div>
<div className="flex justify-between gap-4">
<dt className="text-slate-500">Rolle</dt>
<dd className="font-medium">{deleteUser.role}</dd>
</div>
{deleteError && <p className="text-red-600">{deleteError}</p>}
</dl>
)}
</ConfirmDialog>
</div>
);
}