Add admin trend stats and sign-up sparkline
Task #13: Show admin trends like new sign-ups this week. Backend - Added GET /api/stats/admin (admin-only) in artifacts/api-server/src/routes/stats.ts. - Returns: newUsersLast7Days, newUsersPrev7Days, activeServices, inactiveServices, signupsByDay (7-day series with zero-filled days, computed via date_trunc on usersTable.createdAt). API spec / codegen - Added /stats/admin path and AdminStats schema in lib/api-spec/openapi.yaml. - Re-ran @workspace/api-spec codegen, regenerating react-query hooks (useGetAdminStats) and zod schemas. Frontend (artifacts/teaboy-os/src/pages/admin.tsx) - Wired useGetAdminStats in AdminPage (enabled only for admins). - Extended DashboardSection with two trend cards (new users 7d w/ delta vs previous 7d, active services with inactive count) and a small bar chart of sign-ups over the last 7 days. - Added matching i18n keys (en/ar) under admin.dashboard. Notes - Avoided sending all users to the client for trend computation, as recommended in the task brief. - Verified pnpm typecheck passes and the new endpoint returns 401 to unauthenticated callers. Replit-Task-Id: dd11d7f7-5569-47b4-878e-ad63043eda31
This commit is contained in:
@@ -13,7 +13,7 @@ import {
|
||||
messagesTable,
|
||||
messageReadsTable,
|
||||
} from "@workspace/db";
|
||||
import { requireAuth } from "../middlewares/auth";
|
||||
import { requireAuth, requireAdmin } from "../middlewares/auth";
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
@@ -111,4 +111,67 @@ router.get("/stats/home", requireAuth, async (req, res): Promise<void> => {
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/stats/admin", requireAuth, requireAdmin, async (_req, res): Promise<void> => {
|
||||
const now = new Date();
|
||||
const startOfTodayUtc = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
||||
const sevenDaysAgo = new Date(startOfTodayUtc.getTime() - 6 * 24 * 60 * 60 * 1000);
|
||||
const fourteenDaysAgo = new Date(startOfTodayUtc.getTime() - 13 * 24 * 60 * 60 * 1000);
|
||||
const last7Start = sevenDaysAgo;
|
||||
const prev7Start = fourteenDaysAgo;
|
||||
|
||||
const [newUsersLast7Row] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(usersTable)
|
||||
.where(sql`${usersTable.createdAt} >= ${last7Start.toISOString()}`);
|
||||
|
||||
const [newUsersPrev7Row] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(usersTable)
|
||||
.where(
|
||||
and(
|
||||
sql`${usersTable.createdAt} >= ${prev7Start.toISOString()}`,
|
||||
sql`${usersTable.createdAt} < ${last7Start.toISOString()}`,
|
||||
),
|
||||
);
|
||||
|
||||
const [activeServicesRow] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(servicesTable)
|
||||
.where(eq(servicesTable.isAvailable, true));
|
||||
|
||||
const [inactiveServicesRow] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(servicesTable)
|
||||
.where(eq(servicesTable.isAvailable, false));
|
||||
|
||||
const signupRows = await db
|
||||
.select({
|
||||
day: sql<string>`to_char(date_trunc('day', ${usersTable.createdAt} AT TIME ZONE 'UTC'), 'YYYY-MM-DD')`,
|
||||
count: sql<number>`count(*)`,
|
||||
})
|
||||
.from(usersTable)
|
||||
.where(sql`${usersTable.createdAt} >= ${last7Start.toISOString()}`)
|
||||
.groupBy(sql`date_trunc('day', ${usersTable.createdAt} AT TIME ZONE 'UTC')`);
|
||||
|
||||
const countsByDay = new Map<string, number>();
|
||||
for (const row of signupRows) {
|
||||
countsByDay.set(row.day, Number(row.count));
|
||||
}
|
||||
|
||||
const signupsByDay: { date: string; count: number }[] = [];
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const d = new Date(startOfTodayUtc.getTime() - i * 24 * 60 * 60 * 1000);
|
||||
const key = d.toISOString().slice(0, 10);
|
||||
signupsByDay.push({ date: key, count: countsByDay.get(key) ?? 0 });
|
||||
}
|
||||
|
||||
res.json({
|
||||
newUsersLast7Days: Number(newUsersLast7Row?.count ?? 0),
|
||||
newUsersPrev7Days: Number(newUsersPrev7Row?.count ?? 0),
|
||||
activeServices: Number(activeServicesRow?.count ?? 0),
|
||||
inactiveServices: Number(inactiveServicesRow?.count ?? 0),
|
||||
signupsByDay,
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -116,7 +116,13 @@
|
||||
"recentActivity": "آخر النشاطات",
|
||||
"latestUser": "آخر مستخدم",
|
||||
"latestApp": "آخر تطبيق",
|
||||
"none": "—"
|
||||
"none": "—",
|
||||
"newUsers7d": "مستخدمون جدد (٧ أيام)",
|
||||
"vsPrev7d": "الأسبوع السابق: {{count}}",
|
||||
"activeServices": "الخدمات النشطة",
|
||||
"inactiveServices": "غير نشطة: {{count}}",
|
||||
"signupsTrend": "التسجيلات",
|
||||
"last7Days": "آخر ٧ أيام"
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
|
||||
@@ -116,7 +116,13 @@
|
||||
"recentActivity": "Recent Activity",
|
||||
"latestUser": "Latest User",
|
||||
"latestApp": "Latest App",
|
||||
"none": "—"
|
||||
"none": "—",
|
||||
"newUsers7d": "New users (7d)",
|
||||
"vsPrev7d": "Previous 7d: {{count}}",
|
||||
"activeServices": "Active services",
|
||||
"inactiveServices": "Inactive: {{count}}",
|
||||
"signupsTrend": "Sign-ups",
|
||||
"last7Days": "Last 7 days"
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
|
||||
@@ -20,13 +20,15 @@ import {
|
||||
useGetAppSettings,
|
||||
getGetAppSettingsQueryKey,
|
||||
useUpdateAppSettings,
|
||||
useGetAdminStats,
|
||||
getGetAdminStatsQueryKey,
|
||||
} from "@workspace/api-client-react";
|
||||
import type { App, Service, UserProfile, AppSettings } from "@workspace/api-client-react";
|
||||
import type { App, Service, UserProfile, AppSettings, AdminStats } from "@workspace/api-client-react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useUpload, type UploadResponse } from "@workspace/object-storage-web";
|
||||
import { resolveServiceImageUrl } from "@/lib/image-url";
|
||||
import { ArrowRight, ArrowLeft, Settings, Plus, Pencil, Trash2, Shield, Upload, Loader2, LayoutDashboard, Grid2X2, Coffee, Users as UsersIcon, Menu as MenuIcon } from "lucide-react";
|
||||
import { ArrowRight, ArrowLeft, Settings, Plus, Pencil, Trash2, Shield, Upload, Loader2, LayoutDashboard, Grid2X2, Coffee, Users as UsersIcon, Menu as MenuIcon, TrendingUp, TrendingDown, UserPlus, Activity } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -79,6 +81,9 @@ export default function AdminPage() {
|
||||
const { data: services } = useListServices({ query: { queryKey: getListServicesQueryKey() } });
|
||||
const { data: users } = useListUsers({ query: { queryKey: getListUsersQueryKey() } });
|
||||
const { data: appSettings } = useGetAppSettings({ query: { queryKey: getGetAppSettingsQueryKey() } });
|
||||
const { data: adminStats } = useGetAdminStats({
|
||||
query: { queryKey: getGetAdminStatsQueryKey(), enabled: isAdmin },
|
||||
});
|
||||
|
||||
const createApp = useCreateApp();
|
||||
const updateApp = useUpdateApp();
|
||||
@@ -384,6 +389,7 @@ export default function AdminPage() {
|
||||
services={services}
|
||||
users={users}
|
||||
appSettings={appSettings}
|
||||
adminStats={adminStats}
|
||||
lang={lang}
|
||||
/>
|
||||
)}
|
||||
@@ -722,12 +728,14 @@ function DashboardSection({
|
||||
services,
|
||||
users,
|
||||
appSettings,
|
||||
adminStats,
|
||||
lang,
|
||||
}: {
|
||||
apps: App[] | undefined;
|
||||
services: Service[] | undefined;
|
||||
users: UserProfile[] | undefined;
|
||||
appSettings: AppSettings | undefined;
|
||||
adminStats: AdminStats | undefined;
|
||||
lang: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -770,6 +778,24 @@ function DashboardSection({
|
||||
},
|
||||
];
|
||||
|
||||
const newUsers = adminStats?.newUsersLast7Days ?? 0;
|
||||
const prevUsers = adminStats?.newUsersPrev7Days ?? 0;
|
||||
const userDelta = newUsers - prevUsers;
|
||||
const userDeltaPct = prevUsers > 0 ? Math.round((userDelta / prevUsers) * 100) : null;
|
||||
const activeServices = adminStats?.activeServices ?? 0;
|
||||
const inactiveServices = adminStats?.inactiveServices ?? 0;
|
||||
const signups = adminStats?.signupsByDay ?? [];
|
||||
const maxSignup = Math.max(1, ...signups.map((s) => s.count));
|
||||
const dayLabel = (iso: string) => {
|
||||
try {
|
||||
return new Date(iso + "T00:00:00Z").toLocaleDateString(dateLocale, {
|
||||
weekday: "short",
|
||||
});
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
@@ -786,6 +812,82 @@ function DashboardSection({
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="glass-panel rounded-2xl p-4 flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0 text-sky-600 bg-sky-100/70">
|
||||
<UserPlus size={20} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs text-muted-foreground truncate">{t("admin.dashboard.newUsers7d")}</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<div className="text-xl font-semibold text-foreground">{newUsers}</div>
|
||||
{userDelta !== 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
"text-xs flex items-center gap-0.5 font-medium",
|
||||
userDelta > 0 ? "text-emerald-600" : "text-rose-600",
|
||||
)}
|
||||
>
|
||||
{userDelta > 0 ? <TrendingUp size={12} /> : <TrendingDown size={12} />}
|
||||
{userDeltaPct !== null
|
||||
? `${userDelta > 0 ? "+" : ""}${userDeltaPct}%`
|
||||
: `${userDelta > 0 ? "+" : ""}${userDelta}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground mt-0.5">
|
||||
{t("admin.dashboard.vsPrev7d", { count: prevUsers })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass-panel rounded-2xl p-4 flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0 text-amber-600 bg-amber-100/70">
|
||||
<Activity size={20} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs text-muted-foreground truncate">{t("admin.dashboard.activeServices")}</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<div className="text-xl font-semibold text-foreground">{activeServices}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
/ {activeServices + inactiveServices}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground mt-0.5">
|
||||
{t("admin.dashboard.inactiveServices", { count: inactiveServices })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass-panel rounded-2xl p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-semibold text-foreground">
|
||||
{t("admin.dashboard.signupsTrend")}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{t("admin.dashboard.last7Days")}</div>
|
||||
</div>
|
||||
<div className="flex items-end justify-between gap-1.5 h-28 px-1" dir="ltr">
|
||||
{signups.map((s) => {
|
||||
const heightPct = (s.count / maxSignup) * 100;
|
||||
return (
|
||||
<div key={s.date} className="flex-1 flex flex-col items-center gap-1.5 min-w-0">
|
||||
<div className="text-[10px] font-medium text-foreground leading-none">{s.count}</div>
|
||||
<div className="w-full flex-1 flex items-end">
|
||||
<div
|
||||
className="w-full rounded-t-md bg-gradient-to-t from-primary/40 to-primary transition-all"
|
||||
style={{ height: `${Math.max(heightPct, s.count > 0 ? 6 : 2)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground truncate w-full text-center">
|
||||
{dayLabel(s.date)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass-panel rounded-2xl p-4 space-y-3">
|
||||
<div className="text-sm font-semibold text-foreground">
|
||||
{t("admin.dashboard.recentActivity")}
|
||||
|
||||
@@ -305,3 +305,16 @@ export interface HomeStats {
|
||||
unreadMessages: number;
|
||||
totalUsers: number;
|
||||
}
|
||||
|
||||
export type AdminStatsSignupsByDayItem = {
|
||||
date: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export interface AdminStats {
|
||||
newUsersLast7Days: number;
|
||||
newUsersPrev7Days: number;
|
||||
activeServices: number;
|
||||
inactiveServices: number;
|
||||
signupsByDay: AdminStatsSignupsByDayItem[];
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import type {
|
||||
AdminStats,
|
||||
App,
|
||||
AppSettings,
|
||||
AuthUser,
|
||||
@@ -2977,3 +2978,78 @@ export function useGetHomeStats<
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get admin dashboard trend stats
|
||||
*/
|
||||
export const getGetAdminStatsUrl = () => {
|
||||
return `/api/stats/admin`;
|
||||
};
|
||||
|
||||
export const getAdminStats = async (
|
||||
options?: RequestInit,
|
||||
): Promise<AdminStats> => {
|
||||
return customFetch<AdminStats>(getGetAdminStatsUrl(), {
|
||||
...options,
|
||||
method: "GET",
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetAdminStatsQueryKey = () => {
|
||||
return [`/api/stats/admin`] as const;
|
||||
};
|
||||
|
||||
export const getGetAdminStatsQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getAdminStats>>,
|
||||
TError = ErrorType<unknown>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getAdminStats>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}) => {
|
||||
const { query: queryOptions, request: requestOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getGetAdminStatsQueryKey();
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getAdminStats>>> = ({
|
||||
signal,
|
||||
}) => getAdminStats({ signal, ...requestOptions });
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getAdminStats>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetAdminStatsQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getAdminStats>>
|
||||
>;
|
||||
export type GetAdminStatsQueryError = ErrorType<unknown>;
|
||||
|
||||
/**
|
||||
* @summary Get admin dashboard trend stats
|
||||
*/
|
||||
|
||||
export function useGetAdminStats<
|
||||
TData = Awaited<ReturnType<typeof getAdminStats>>,
|
||||
TError = ErrorType<unknown>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getAdminStats>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetAdminStatsQueryOptions(options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
@@ -704,6 +704,19 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/HomeStats"
|
||||
|
||||
/stats/admin:
|
||||
get:
|
||||
operationId: getAdminStats
|
||||
tags: [stats]
|
||||
summary: Get admin dashboard trend stats
|
||||
responses:
|
||||
"200":
|
||||
description: Admin stats
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AdminStats"
|
||||
|
||||
components:
|
||||
schemas:
|
||||
HealthStatus:
|
||||
@@ -1287,3 +1300,33 @@ components:
|
||||
- unreadNotifications
|
||||
- unreadMessages
|
||||
- totalUsers
|
||||
|
||||
AdminStats:
|
||||
type: object
|
||||
properties:
|
||||
newUsersLast7Days:
|
||||
type: integer
|
||||
newUsersPrev7Days:
|
||||
type: integer
|
||||
activeServices:
|
||||
type: integer
|
||||
inactiveServices:
|
||||
type: integer
|
||||
signupsByDay:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
date:
|
||||
type: string
|
||||
count:
|
||||
type: integer
|
||||
required:
|
||||
- date
|
||||
- count
|
||||
required:
|
||||
- newUsersLast7Days
|
||||
- newUsersPrev7Days
|
||||
- activeServices
|
||||
- inactiveServices
|
||||
- signupsByDay
|
||||
|
||||
@@ -688,3 +688,19 @@ export const GetHomeStatsResponse = zod.object({
|
||||
unreadMessages: zod.number(),
|
||||
totalUsers: zod.number(),
|
||||
});
|
||||
|
||||
/**
|
||||
* @summary Get admin dashboard trend stats
|
||||
*/
|
||||
export const GetAdminStatsResponse = zod.object({
|
||||
newUsersLast7Days: zod.number(),
|
||||
newUsersPrev7Days: zod.number(),
|
||||
activeServices: zod.number(),
|
||||
inactiveServices: zod.number(),
|
||||
signupsByDay: zod.array(
|
||||
zod.object({
|
||||
date: zod.string(),
|
||||
count: zod.number(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user