Polish user management: single card with persisted tabs

- Tabs inside one card, selected tab remembered across reloads
- Register and refresh buttons in the card header
- Table stats (count / selected) below each table

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Simon GruberandClaude Sonnet 5.5 committed 2026-11-05 15:55:40 +01:00
1 parent 908756190a
commit d2bf52fea1
1 file changed
+79 -40
@@ -1,8 +1,9 @@
import React, { useMemo, useState } from "react";
import React, { useEffect, useMemo, useState } from "react";
import { CopyOutlined, CrownOutlined, DeleteOutlined, MailOutlined, ReloadOutlined, UserAddOutlined } from "@ant-design/icons";
import { Alert, Button, Card, Form, Input, Popconfirm, Popover, Space, Table, Tabs, Tag, Tooltip, Typography, message } from "antd";
import type { ColumnsType } from "antd/es/table";
import type { ColumnsType, TableProps } from "antd/es/table";
import { apiService } from "../../lib/services";
import { getStoredValue, setStoredValue } from "../../lib/storage";
import { useApiRequest } from "../../hooks/useApiRequest";
import AsyncContent from "../utils/AsyncContent";
import type {
@@ -13,9 +14,30 @@ import type {
RegistrationLink,
} from "../../lib/types";
const TAB_STORAGE_KEY = "lunchtime.siteAdmin.usersTab";
const loadUsers = () => apiService.admin.listUsers();
const loadLinks = () => apiService.admin.listRegistrationLinks();
/** Tracks how many rows the table currently shows after its column filters ("selected" count). */
function useFilteredCount(total: number, dataSource: unknown) {
const [filtered, setFiltered] = useState<number | null>(null);
useEffect(() => setFiltered(null), [dataSource]);
return {
count: filtered ?? total,
onTableChange: ((_p: unknown, _f: unknown, _s: unknown, extra: { currentDataSource: unknown[] }) =>
setFiltered(extra.currentDataSource.length)) as TableProps<any>["onChange"],
};
}
function TableStats({ count, total, noun }: { count: number; total: number; noun: string }) {
return (
<Typography.Text type="secondary" style={{ display: "block", marginTop: 8 }}>
{count === total ? `${total} ${noun}` : `${count} of ${total} ${noun} selected`}
</Typography.Text>
);
}
function formatDateTime(iso: string | null) {
return iso ? new Date(iso).toLocaleString() : "–";
}
@@ -180,21 +202,21 @@ function UserActions({ user, onChanged }: { user: AdminUser; onChanged: () => vo
function UsersCard({ users, onReload }: { users: AdminUser[]; onReload: () => void }) {
const columns = useMemo(() => createUserColumns(onReload), [onReload]);
const { count, onTableChange } = useFilteredCount(users.length, users);
return (
<Card
title="All users"
extra={<Button icon={<ReloadOutlined />} onClick={onReload} aria-label="Reload users" />}
>
<>
<Table
rowKey="id"
size="small"
columns={columns}
dataSource={users}
onChange={onTableChange}
pagination={{ pageSize: 20, hideOnSinglePage: true }}
scroll={{ x: "max-content" }}
/>
</Card>
<TableStats count={count} total={users.length} noun="users" />
</>
);
}
@@ -221,13 +243,15 @@ function RegistrationLinksCard({
active,
previous,
onChanged,
created,
onDismissCreated,
}: {
active: ActiveRegistrationLink[];
previous: PreviousRegistrationLink[];
onChanged: () => void;
created: CreatedRegistrationLink | null;
onDismissCreated: () => void;
}) {
const [created, setCreated] = useState<CreatedRegistrationLink | null>(null);
const rows: LinkRow[] = useMemo(
() =>
[...active.map((link) => ({ ...link, status: "active" as const })), ...previous].sort((a, b) =>
@@ -236,6 +260,8 @@ function RegistrationLinksCard({
[active, previous],
);
const { count, onTableChange } = useFilteredCount(rows.length, rows);
const columns: ColumnsType<LinkRow> = [
{ title: "Email", dataIndex: "email" },
{
@@ -275,27 +301,14 @@ function RegistrationLinksCard({
];
return (
<Card
title="Registration links"
extra={
<Space>
<RegisterUserButton
onCreated={(link) => {
setCreated(link);
onChanged();
}}
/>
<Button icon={<ReloadOutlined />} onClick={onChanged} aria-label="Reload registration links" />
</Space>
}
>
<>
{created && (
<Alert
style={{ marginBottom: 16 }}
type="success"
showIcon
closable
onClose={() => setCreated(null)}
onClose={onDismissCreated}
message={`Registration link for ${created.email}`}
description={
<Space direction="vertical" style={{ width: "100%" }}>
@@ -314,11 +327,13 @@ function RegistrationLinksCard({
size="small"
columns={columns}
dataSource={rows}
onChange={onTableChange}
pagination={{ pageSize: 15, hideOnSinglePage: true }}
locale={{ emptyText: "No registration links" }}
scroll={{ x: "max-content" }}
/>
</Card>
<TableStats count={count} total={rows.length} noun="links" />
</>
);
}
@@ -332,16 +347,40 @@ export default function UserManagement() {
};
const [created, setCreated] = useState<CreatedRegistrationLink | null>(null);
const [activeTab, setActiveTab] = useState(() => {
const stored = getStoredValue(TAB_STORAGE_KEY);
return stored === "links" ? "links" : "users";
});
const selectTab = (key: string) => {
setActiveTab(key);
setStoredValue(TAB_STORAGE_KEY, key);
};
return (
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
<Typography.Title level={3} style={{ margin: 0 }}>
User Management
</Typography.Title>
<Card
title="User Management"
extra={
<Space>
<RegisterUserButton
onCreated={(link) => {
setCreated(link);
selectTab("links"); // the new link is shown on that tab
reloadAll();
}}
/>
<Button icon={<ReloadOutlined />} onClick={reloadAll} aria-label="Reload" />
</Space>
}
>
<Tabs
activeKey={activeTab}
onChange={selectTab}
items={[
{
key: "users",
label: `Users${users.data ? ` (${users.data.users.length})` : ""}`,
label: "Users",
children: (
<AsyncContent loading={users.loading && !users.data} error={users.error} onRetry={users.run}>
<UsersCard users={users.data?.users ?? []} onReload={reloadAll} />
@@ -352,19 +391,19 @@ export default function UserManagement() {
key: "links",
label: "Registration links",
children: (
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
<AsyncContent loading={links.loading && !links.data} error={links.error} onRetry={links.run}>
<RegistrationLinksCard
active={links.data?.active ?? []}
previous={links.data?.previous ?? []}
onChanged={reloadAll}
/>
</AsyncContent>
</Space>
<AsyncContent loading={links.loading && !links.data} error={links.error} onRetry={links.run}>
<RegistrationLinksCard
active={links.data?.active ?? []}
previous={links.data?.previous ?? []}
onChanged={reloadAll}
created={created}
onDismissCreated={() => setCreated(null)}
/>
</AsyncContent>
),
},
]}
/>
</Space>
</Card>
);
}