User/Usergroup Pages first draft

This commit is contained in:
root
2025-10-27 19:36:44 +00:00
parent a76567969a
commit d65585b821
12 changed files with 769 additions and 51 deletions
+318 -10
View File
@@ -5,25 +5,333 @@ import {
CardHeader,
CardBody,
Typography,
Avatar,
Chip,
Tooltip,
Progress,
Collapse,
Button,
ButtonGroup,
Select,
Option,
Input,
useSelect
Input
} from "@material-tailwind/react";
import { PaginationControls, usePage } from "@/components/PageProvider.jsx";
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
import { useToast } from "@/components/ToastProvider.jsx";
export function Usergroups() {
const [groups, setGroups] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [searchQuery, setSearchQuery] = useState("");
const { showToast } = useToast();
const { maintenance } = useMaintenance();
const maintenanceActive = Boolean(maintenance?.active);
const {
page,
perPage,
perPageOptions,
perPageIsAll,
handlePerPageChange,
setPage,
setTotals,
resetPagination
} = usePage();
useEffect(() => () => resetPagination(), [resetPagination]);
const fetchGroups = useCallback(async () => {
setLoading(true);
setError("");
try {
const response = await axios.get("/api/groups");
const items = Array.isArray(response.data?.items) ? response.data.items : [];
const normalized = items
.map((item) => ({
id: item.id,
name: item.name || "",
description: item.description || "",
memberCount: Number(item.memberCount) || 0,
members: Array.isArray(item.members)
? item.members.map((member) => ({
id: member.id,
username: member.username || ""
})).filter((member) => member.username)
: [],
createdAt: item.createdAt || null,
updatedAt: item.updatedAt || null
}))
.sort((a, b) => a.name.localeCompare(b.name, "de-DE"));
setGroups(normalized);
} catch (err) {
const message = err.response?.data?.error || err.message || "Unbekannter Fehler";
setError(message);
showToast({
variant: "error",
title: "Benutzergruppen konnten nicht geladen werden",
description: message
});
} finally {
setLoading(false);
}
}, [showToast]);
useEffect(() => {
fetchGroups();
}, [fetchGroups]);
const handleRefresh = useCallback(() => {
fetchGroups();
}, [fetchGroups]);
const handleSearchChange = useCallback((event) => {
setSearchQuery(event.target.value);
}, []);
const handleClearSearch = useCallback(() => {
setSearchQuery("");
}, []);
useEffect(() => {
if (page !== 1) {
setPage(1);
}
}, [searchQuery, page, setPage]);
const filteredGroups = useMemo(() => {
const query = searchQuery.trim().toLowerCase();
if (!query) {
return groups;
}
return groups.filter((group) => {
const haystacks = [
group.name,
group.description,
...group.members.map((member) => member.username)
].map((value) => String(value || "").toLowerCase());
return haystacks.some((value) => value.includes(query));
});
}, [groups, searchQuery]);
const perPageNumber = useMemo(() => {
if (perPageIsAll) {
return null;
}
const numeric = Number(perPage);
return Number.isFinite(numeric) && numeric > 0 ? numeric : null;
}, [perPage, perPageIsAll]);
const paginatedGroups = useMemo(() => {
if (perPageIsAll || !perPageNumber) {
return filteredGroups;
}
const startIndex = (page - 1) * perPageNumber;
return filteredGroups.slice(startIndex, startIndex + perPageNumber);
}, [filteredGroups, page, perPageIsAll, perPageNumber]);
const filteredCount = filteredGroups.length;
const visibleCount = paginatedGroups.length;
useEffect(() => {
if (perPageIsAll) {
setTotals(filteredCount, filteredCount);
if (page !== 1) {
setPage(1);
}
return;
}
const numeric = Number(perPage);
if (!Number.isFinite(numeric) || numeric <= 0) {
setTotals(filteredCount, filteredCount);
return;
}
const maxPages = Math.max(1, Math.ceil(filteredCount / numeric));
if (page > maxPages) {
setPage(maxPages);
return;
}
setTotals(filteredCount, visibleCount);
}, [filteredCount, visibleCount, page, perPage, perPageIsAll, setPage, setTotals]);
const formatTimestamp = useCallback((value) => {
if (!value) {
return "";
}
const normalized = typeof value === "string" ? value.replace(" ", "T") : value;
const parsed = new Date(normalized);
if (Number.isNaN(parsed.getTime())) {
return value;
}
return new Intl.DateTimeFormat("de-DE", {
dateStyle: "short",
timeStyle: "short"
}).format(parsed);
}, []);
return (
<div className="mt-12">
<Card className="border border-blue-gray-100 shadow-sm">
<CardHeader
floated={false}
shadow={false}
color="transparent"
className="m-0 flex flex-col gap-4 bg-transparent p-6 md:flex-row md:items-center md:justify-between"
>
<div>
<Typography variant="h4" color="blue-gray">
Benutzergruppen
</Typography>
<Typography variant="small" color="gray" className="font-normal">
Übersicht aller Gruppen inklusive Mitgliederliste.
</Typography>
</div>
<div className="flex flex-col items-stretch gap-3 md:flex-row md:items-center">
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-blue-gray-400">
Einträge pro Seite
</label>
<select
value={perPage}
onChange={handlePerPageChange}
className="w-full rounded-lg border border-blue-gray-100 px-3 py-2 text-sm text-blue-gray-700 shadow-sm focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-200 md:w-40"
>
{perPageOptions.map(({ value, label }) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
<Button
variant="outlined"
color="blue"
onClick={handleRefresh}
disabled={loading}
className="whitespace-nowrap"
>
{loading ? "Lädt ..." : "Aktualisieren"}
</Button>
</div>
</CardHeader>
<CardBody className="pt-0">
{maintenanceActive && (
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
Wartungsmodus aktiv Änderungen sind deaktiviert. Die Liste kann dennoch angezeigt werden.
</div>
)}
<div className="mb-6 flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div className="w-full md:max-w-md">
<Input
label="Suchen nach Gruppenname, Beschreibung oder Mitglied"
value={searchQuery}
onChange={handleSearchChange}
disabled={loading && !groups.length}
crossOrigin=""
/>
</div>
{searchQuery && (
<Button
variant="text"
color="blue-gray"
onClick={handleClearSearch}
className="w-full md:w-auto"
>
Suche zurücksetzen
</Button>
)}
</div>
{error && (
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
{error}
</div>
)}
<div className="overflow-x-auto rounded-lg border border-blue-gray-50">
<table className="w-full min-w-[720px] table-auto text-left">
<thead>
<tr className="bg-blue-gray-50/50 text-xs uppercase tracking-wide text-blue-gray-400">
<th className="px-6 py-4 font-semibold">Gruppenname</th>
<th className="px-6 py-4 font-semibold">Beschreibung</th>
<th className="px-6 py-4 font-semibold">Mitglieder</th>
<th className="px-6 py-4 font-semibold">Anzahl</th>
<th className="px-6 py-4 font-semibold">Erstellt am</th>
<th className="px-6 py-4 font-semibold">Zuletzt aktualisiert</th>
</tr>
</thead>
<tbody>
{loading && groups.length === 0 ? (
<tr>
<td colSpan={6} className="px-6 py-8 text-center text-blue-gray-400">
Gruppen werden geladen ...
</td>
</tr>
) : paginatedGroups.length === 0 ? (
<tr>
<td colSpan={6} className="px-6 py-8 text-center text-blue-gray-400">
Keine Gruppen gefunden.
</td>
</tr>
) : (
paginatedGroups.map((group, index) => {
const rowClass = index === paginatedGroups.length - 1 ? "" : "border-b border-blue-gray-50";
return (
<tr key={group.id} className={`text-sm text-blue-gray-700 ${rowClass}`}>
<td className="px-6 py-4 font-medium text-blue-gray-900">{group.name || ""}</td>
<td className="px-6 py-4">
{group.description ? (
group.description
) : (
<span className="text-blue-gray-400"></span>
)}
</td>
<td className="px-6 py-4">
{group.members.length > 0 ? (
<div className="flex flex-wrap gap-2">
{group.members.map((member) => (
<Chip
key={`${group.id}-${member.id}-${member.username}`}
value={member.username}
size="sm"
color="blue-gray"
variant="ghost"
/>
))}
</div>
) : (
<span className="text-blue-gray-400">Keine Mitglieder</span>
)}
</td>
<td className="px-6 py-4">
<Chip
value={group.memberCount.toLocaleString("de-DE")}
size="sm"
color={group.memberCount > 0 ? "green" : "blue-gray"}
variant="ghost"
/>
</td>
<td className="px-6 py-4">{formatTimestamp(group.createdAt)}</td>
<td className="px-6 py-4">{formatTimestamp(group.updatedAt)}</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
<div className="mt-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<Typography variant="small" color="gray">
{!loading && groups.length === 0
? "Noch keine Gruppen angelegt."
: `${filteredCount.toLocaleString("de-DE")} Gruppen gefunden`}
</Typography>
<PaginationControls disabled={loading && groups.length === 0} />
</div>
</CardBody>
</Card>
</div>
);
}
export default Usergroups;
+303 -9
View File
@@ -5,23 +5,317 @@ import {
CardHeader,
CardBody,
Typography,
Avatar,
Chip,
Tooltip,
Progress,
Collapse,
Button,
ButtonGroup,
Select,
Option,
Input,
useSelect
Input
} from "@material-tailwind/react";
import { PaginationControls, usePage } from "@/components/PageProvider.jsx";
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
import { useToast } from "@/components/ToastProvider.jsx";
export function Users() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [searchQuery, setSearchQuery] = useState("");
const { showToast } = useToast();
const { maintenance } = useMaintenance();
const maintenanceActive = Boolean(maintenance?.active);
const {
page,
perPage,
perPageOptions,
perPageIsAll,
handlePerPageChange,
setPage,
setTotals,
resetPagination
} = usePage();
useEffect(() => () => resetPagination(), [resetPagination]);
const fetchUsers = useCallback(async () => {
setLoading(true);
setError("");
try {
const response = await axios.get("/api/users");
const items = Array.isArray(response.data?.items) ? response.data.items : [];
const sorted = items
.map((item) => ({
id: item.id,
username: item.username || "",
email: item.email || "",
isActive: Boolean(item.isActive),
lastLogin: item.lastLogin || null,
createdAt: item.createdAt || null,
updatedAt: item.updatedAt || null,
groups: Array.isArray(item.groups) ? item.groups : []
}))
.sort((a, b) => a.username.localeCompare(b.username, "de-DE"));
setUsers(sorted);
} catch (err) {
const message = err.response?.data?.error || err.message || "Unbekannter Fehler";
setError(message);
showToast({
variant: "error",
title: "Benutzer konnten nicht geladen werden",
description: message
});
} finally {
setLoading(false);
}
}, [showToast]);
useEffect(() => {
fetchUsers();
}, [fetchUsers]);
const handleRefresh = useCallback(() => {
fetchUsers();
}, [fetchUsers]);
const handleSearchChange = useCallback((event) => {
setSearchQuery(event.target.value);
}, []);
const handleClearSearch = useCallback(() => {
setSearchQuery("");
}, []);
useEffect(() => {
if (page !== 1) {
setPage(1);
}
}, [searchQuery, page, setPage]);
const filteredUsers = useMemo(() => {
const query = searchQuery.trim().toLowerCase();
if (!query) {
return users;
}
return users.filter((user) => {
const haystacks = [
user.username,
user.email,
...user.groups
].map((value) => String(value || "").toLowerCase());
return haystacks.some((value) => value.includes(query));
});
}, [users, searchQuery]);
const perPageNumber = useMemo(() => {
if (perPageIsAll) {
return null;
}
const numeric = Number(perPage);
return Number.isFinite(numeric) && numeric > 0 ? numeric : null;
}, [perPage, perPageIsAll]);
const paginatedUsers = useMemo(() => {
if (perPageIsAll || !perPageNumber) {
return filteredUsers;
}
const startIndex = (page - 1) * perPageNumber;
return filteredUsers.slice(startIndex, startIndex + perPageNumber);
}, [filteredUsers, page, perPageIsAll, perPageNumber]);
const filteredCount = filteredUsers.length;
const visibleCount = paginatedUsers.length;
useEffect(() => {
if (perPageIsAll) {
setTotals(filteredCount, filteredCount);
if (page !== 1) {
setPage(1);
}
return;
}
const numeric = Number(perPage);
if (!Number.isFinite(numeric) || numeric <= 0) {
setTotals(filteredCount, filteredCount);
return;
}
const maxPages = Math.max(1, Math.ceil(filteredCount / numeric));
if (page > maxPages) {
setPage(maxPages);
return;
}
setTotals(filteredCount, visibleCount);
}, [filteredCount, visibleCount, page, perPage, perPageIsAll, setPage, setTotals]);
const formatTimestamp = useCallback((value) => {
if (!value) {
return "";
}
const normalized = typeof value === "string" ? value.replace(" ", "T") : value;
const parsed = new Date(normalized);
if (Number.isNaN(parsed.getTime())) {
return value;
}
return new Intl.DateTimeFormat("de-DE", {
dateStyle: "short",
timeStyle: "short"
}).format(parsed);
}, []);
return (
<div className="mt-12">
<Card className="border border-blue-gray-100 shadow-sm">
<CardHeader
floated={false}
shadow={false}
color="transparent"
className="m-0 flex flex-col gap-4 bg-transparent p-6 md:flex-row md:items-center md:justify-between"
>
<div>
<Typography variant="h4" color="blue-gray">
Benutzerverwaltung
</Typography>
<Typography variant="small" color="gray" className="font-normal">
Übersicht aller im System angelegten Benutzer.
</Typography>
</div>
<div className="flex flex-col items-stretch gap-3 md:flex-row md:items-center">
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-blue-gray-400">
Einträge pro Seite
</label>
<select
value={perPage}
onChange={handlePerPageChange}
className="w-full rounded-lg border border-blue-gray-100 px-3 py-2 text-sm text-blue-gray-700 shadow-sm focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-200 md:w-40"
>
{perPageOptions.map(({ value, label }) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
<Button
variant="outlined"
color="blue"
onClick={handleRefresh}
disabled={loading}
className="whitespace-nowrap"
>
{loading ? "Lädt ..." : "Aktualisieren"}
</Button>
</div>
</CardHeader>
<CardBody className="pt-0">
{maintenanceActive && (
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
Wartungsmodus aktiv Änderungen sind deaktiviert. Die Liste kann dennoch angezeigt werden.
</div>
)}
<div className="mb-6 flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div className="w-full md:max-w-md">
<Input
label="Suchen nach Name, E-Mail oder Gruppe"
value={searchQuery}
onChange={handleSearchChange}
disabled={loading && !users.length}
crossOrigin=""
/>
</div>
{searchQuery && (
<Button
variant="text"
color="blue-gray"
onClick={handleClearSearch}
className="w-full md:w-auto"
>
Suche zurücksetzen
</Button>
)}
</div>
{error && (
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
{error}
</div>
)}
<div className="overflow-x-auto rounded-lg border border-blue-gray-50">
<table className="w-full min-w-[720px] table-auto text-left">
<thead>
<tr className="bg-blue-gray-50/50 text-xs uppercase tracking-wide text-blue-gray-400">
<th className="px-6 py-4 font-semibold">Benutzername</th>
<th className="px-6 py-4 font-semibold">E-Mail</th>
<th className="px-6 py-4 font-semibold">Gruppen</th>
<th className="px-6 py-4 font-semibold">Status</th>
<th className="px-6 py-4 font-semibold">Letzte Anmeldung</th>
<th className="px-6 py-4 font-semibold">Erstellt am</th>
</tr>
</thead>
<tbody>
{loading && users.length === 0 ? (
<tr>
<td colSpan={6} className="px-6 py-8 text-center text-blue-gray-400">
Benutzerdaten werden geladen ...
</td>
</tr>
) : paginatedUsers.length === 0 ? (
<tr>
<td colSpan={6} className="px-6 py-8 text-center text-blue-gray-400">
Keine Benutzer gefunden.
</td>
</tr>
) : (
paginatedUsers.map((user, index) => {
const rowClass = index === paginatedUsers.length - 1 ? "" : "border-b border-blue-gray-50";
return (
<tr key={user.id} className={`text-sm text-blue-gray-700 ${rowClass}`}>
<td className="px-6 py-4 font-medium text-blue-gray-900">{user.username || ""}</td>
<td className="px-6 py-4">{user.email || ""}</td>
<td className="px-6 py-4">
{user.groups.length > 0 ? (
<div className="flex flex-wrap gap-2">
{user.groups.map((group) => (
<Chip key={group} value={group} size="sm" color="blue-gray" variant="ghost" />
))}
</div>
) : (
<span className="text-blue-gray-400"></span>
)}
</td>
<td className="px-6 py-4">
<Chip
value={user.isActive ? "Aktiv" : "Deaktiviert"}
size="sm"
color={user.isActive ? "green" : "red"}
variant="ghost"
/>
</td>
<td className="px-6 py-4">{formatTimestamp(user.lastLogin)}</td>
<td className="px-6 py-4">{formatTimestamp(user.createdAt)}</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
<div className="mt-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<Typography variant="small" color="gray">
{!loading && users.length === 0
? "Noch keine Benutzer angelegt."
: `${filteredCount.toLocaleString("de-DE")} Benutzer gefunden`}
</Typography>
<PaginationControls disabled={loading && users.length === 0} />
</div>
</CardBody>
</Card>
</div>
);
}