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:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user