Add statistics placeholder pages with sidebar navigation

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Simon GruberandClaude Sonnet 5.5 committed 2026-11-05 17:13:17 +01:00
1 parent b0dd62bc9b
commit 70bd7642d4
4 files changed
+179 -3

No files matched your search

+7 -2
View File
@@ -18,6 +18,7 @@ import {
} from "antd";
import Announcements from "./components/common/Announcements";
import SiteAdminView from "./views/SiteAdminView";
import StatisticsView from "./views/StatisticsView";
import TopNav from "./components/common/TopNav";
import WelcomeOnboardingModal from "./components/modals/WelcomeOnboardingModal";
import { navigateTo, parseRoute, subscribeToRouteChange } from "./lib/routing";
@@ -108,7 +109,7 @@ const warmComponentTokens = {
},
};
function RouteOutlet({ isLocked }: { isLocked: boolean }) {
function RouteOutlet({ isLocked, isAdmin }: { isLocked: boolean; isAdmin: boolean }) {
if (isLocked) {
return null;
}
@@ -140,6 +141,10 @@ function RouteOutlet({ isLocked }: { isLocked: boolean }) {
return <SiteAdminView section={route.section} />;
}
if (route.type === "stats") {
return <StatisticsView section={route.section} isAdmin={isAdmin} />;
}
return (
<Result
status="404"
@@ -216,7 +221,7 @@ function AppContent({
/>
<MemoAnnouncements announcements={announcements} />
<RouteOutlet isLocked={isLocked} key={userId} />
<RouteOutlet isLocked={isLocked} isAdmin={isAdmin} key={userId} />
</Space>
</Content>
<Footer
@@ -1,5 +1,6 @@
import React, { useEffect } from "react";
import {
BarChartOutlined,
CheckOutlined,
LogoutOutlined,
SettingOutlined,
@@ -134,6 +135,16 @@ export default function AccountSettingsPopoverContent({
<Divider style={{ margin: "8px 0" }} />
<Button
type="text"
block
icon={<BarChartOutlined />}
style={menuEntryStyle}
onClick={() => onNavigate("/stats/orderee")}
>
Statistics
</Button>
{isAdmin && (
<Button
type="text"
+13 -1
View File
@@ -3,9 +3,21 @@ export type AppRoute =
| { type: "create" }
| { type: "order"; orderId: string }
| { type: "admin"; orderId: string }
| { type: "siteAdmin"; section: "users" | "orders" };
| { type: "siteAdmin"; section: "users" | "orders" }
| { type: "stats"; section: StatsSection };
export type StatsSection = "orderee" | "manager" | "admin";
export function parseRoute(pathname: string): AppRoute {
const statsMatch = pathname.match(/^\/stats\/(orderee|manager|admin)$/);
if (statsMatch) {
return { type: "stats", section: statsMatch[1] as StatsSection };
}
if (pathname === "/stats") {
return { type: "stats", section: "orderee" };
}
if (pathname === "/admin/users") {
return { type: "siteAdmin", section: "users" };
}
+148
View File
@@ -0,0 +1,148 @@
import React, { useEffect, useState } from "react";
import {
BarChartOutlined,
CrownOutlined,
ShoppingCartOutlined,
UserOutlined,
} from "@ant-design/icons";
import { Card, Empty, List, Menu, Typography } from "antd";
import { navigateTo, StatsSection } from "../lib/routing";
import { apiService } from "../lib/services";
type SectionDef = {
key: StatsSection;
label: string;
icon: React.ReactNode;
audience: string;
/** Shown only to administrators. */
adminOnly?: boolean;
/** Shown only to users who own at least one order. */
ownersOnly?: boolean;
/** Planned statistics, shown on the placeholder page until they are implemented. */
planned: string[];
};
const SECTIONS: SectionDef[] = [
{
key: "orderee",
label: "My Orders",
icon: <ShoppingCartOutlined />,
audience: "For everyone who submits to group orders.",
planned: [
"Participation over time: orders joined per month, current streak",
"Favourite items and categories, plus items you always repeat",
"Total and average spend per order (where prices are configured)",
"Your usual order day and how early you submit before the deadline",
"Most frequent order groups and organizers",
"Open payments: what you still owe, and payment history",
],
},
{
key: "manager",
label: "Order Management",
icon: <UserOutlined />,
audience: "For users who create and run group orders.",
ownersOnly: true,
planned: [
"Orders created over time, and how many reached the ordering stage",
"Participants per order: average, trend, repeat vs. new participants",
"Submission timeline: when people respond relative to the deadline",
"Most popular items and categories across your orders",
"Order volume: total items and value per order",
"Payment status: paid vs. open, average time to payment",
"Participants who are invited but rarely submit",
],
},
{
key: "admin",
label: "Site Overview",
icon: <CrownOutlined />,
adminOnly: true,
audience: "For site administrators.",
planned: [
"Active users (daily/weekly/monthly) and new registrations",
"Unconfirmed accounts and pending email-change/migration requests",
"Orders per week, and the share with at least one submission",
"Top order managers and most active participants",
"Most ordered items across the site",
"Storage usage: uploaded images, payment files, orphaned data",
"Email volume and failures",
],
},
];
/** Placeholder statistics pages; each section lists the statistics planned for it. */
export default function StatisticsView({
section,
isAdmin,
}: {
section: StatsSection;
isAdmin: boolean;
}) {
const [ownsOrders, setOwnsOrders] = useState(false);
useEffect(() => {
let mounted = true;
apiService.orders
.getMine({ role: "owner", limit: 1 })
.then((res) => mounted && setOwnsOrders(res.pagination.total > 0))
.catch(() => {}); // Treat a failed lookup as "no own orders".
return () => {
mounted = false;
};
}, []);
const visible = SECTIONS.filter(
(s) => (!s.adminOnly || isAdmin) && (!s.ownersOnly || ownsOrders),
);
// Fall back to the first visible page when the requested one is hidden for this user.
const current = visible.find((s) => s.key === section) ?? visible[0];
return (
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: 16,
alignItems: "flex-start",
}}
>
{visible.length > 1 && (
<Menu
mode="inline"
selectedKeys={[`/stats/${current.key}`]}
items={visible.map(({ key, label, icon }) => ({
key: `/stats/${key}`,
label,
icon,
}))}
onClick={({ key }) => navigateTo(key)}
style={{ flex: "0 0 220px", borderRadius: 12 }}
/>
)}
<div style={{ flex: "1 1 400px", minWidth: 0 }}>
<Card
title={
<>
<BarChartOutlined /> {current.label}
</>
}
>
<Typography.Paragraph type="secondary">
{current.audience}
</Typography.Paragraph>
<Empty
description="Statistics are coming soon."
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
<Typography.Text strong>Planned</Typography.Text>
<List
size="small"
dataSource={current.planned}
renderItem={(item) => <List.Item>{item}</List.Item>}
/>
</Card>
</div>
</div>
);
}