Add admin recent-opens drill-in popup for top apps and users
Task #41: Show a usage history popup when admins click a top apps row or a most-active users row on the admin dashboard. Changes: - API: extracted the existing range/from/to parser in artifacts/api-server/src/routes/stats.ts into a shared parseRangeWindow() helper so the same window logic powers the existing /stats/admin endpoint and two new ones. - API: added GET /stats/admin/app-opens/by-app/:appId returning the last 100 opens for an app inside the selected window, including the user (id, username, displayName, avatarUrl) and a totalCount. requireAuth + requireAdmin guarded. - API: added GET /stats/admin/app-opens/by-user/:userId with the same shape but per-app metadata for each open. - Spec: added matching paths and AdminAppOpensByApp / AdminAppOpensByUser schemas in lib/api-spec/openapi.yaml, reran orval codegen for api-client-react and api-zod. - UI: in artifacts/teaboy-os/src/pages/admin.tsx the Top apps and Most active users leaderboard rows now open a drill-in modal instead of immediately navigating away. The modal shows a scrollable timeline of recent opens (timestamp + user/app), respects the dashboard's current range selector (7d/30d/90d/custom), and exposes an "Edit app" / "View user" footer button that preserves the previous jump-to behaviour. Loading, error, and empty states are handled. - i18n: added admin.dashboard.drillIn keys in en.json and ar.json (titles, subtitle, empty/error, footer buttons). Verification: pnpm run typecheck passes. Follow-ups proposed: #47 (clickable rows inside the popup), Replit-Task-Id: f2fbe652-a788-4f25-9fb2-9ef2c535c8da #48 (paginate beyond 100 opens).
This commit is contained in:
@@ -484,6 +484,82 @@ export interface AdminStats {
|
||||
mostActiveUsers: TopUserItem[];
|
||||
}
|
||||
|
||||
export interface AppOpenByAppEntry {
|
||||
id: number;
|
||||
createdAt: string;
|
||||
userId: number;
|
||||
username: string;
|
||||
/** @nullable */
|
||||
displayNameAr?: string | null;
|
||||
/** @nullable */
|
||||
displayNameEn?: string | null;
|
||||
/** @nullable */
|
||||
avatarUrl?: string | null;
|
||||
}
|
||||
|
||||
export type AdminAppOpensByAppRange =
|
||||
(typeof AdminAppOpensByAppRange)[keyof typeof AdminAppOpensByAppRange];
|
||||
|
||||
export const AdminAppOpensByAppRange = {
|
||||
"7d": "7d",
|
||||
"30d": "30d",
|
||||
"90d": "90d",
|
||||
custom: "custom",
|
||||
} as const;
|
||||
|
||||
export interface AdminAppOpensByApp {
|
||||
appId: number;
|
||||
slug: string;
|
||||
nameAr: string;
|
||||
nameEn: string;
|
||||
iconName: string;
|
||||
color: string;
|
||||
range: AdminAppOpensByAppRange;
|
||||
rangeDays: number;
|
||||
rangeFrom: string;
|
||||
rangeTo: string;
|
||||
totalCount: number;
|
||||
opens: AppOpenByAppEntry[];
|
||||
}
|
||||
|
||||
export interface AppOpenByUserEntry {
|
||||
id: number;
|
||||
createdAt: string;
|
||||
appId: number;
|
||||
slug: string;
|
||||
nameAr: string;
|
||||
nameEn: string;
|
||||
iconName: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export type AdminAppOpensByUserRange =
|
||||
(typeof AdminAppOpensByUserRange)[keyof typeof AdminAppOpensByUserRange];
|
||||
|
||||
export const AdminAppOpensByUserRange = {
|
||||
"7d": "7d",
|
||||
"30d": "30d",
|
||||
"90d": "90d",
|
||||
custom: "custom",
|
||||
} as const;
|
||||
|
||||
export interface AdminAppOpensByUser {
|
||||
userId: number;
|
||||
username: string;
|
||||
/** @nullable */
|
||||
displayNameAr?: string | null;
|
||||
/** @nullable */
|
||||
displayNameEn?: string | null;
|
||||
/** @nullable */
|
||||
avatarUrl?: string | null;
|
||||
range: AdminAppOpensByUserRange;
|
||||
rangeDays: number;
|
||||
rangeFrom: string;
|
||||
rangeTo: string;
|
||||
totalCount: number;
|
||||
opens: AppOpenByUserEntry[];
|
||||
}
|
||||
|
||||
export type GetAdminStatsParams = {
|
||||
/**
|
||||
* Time range for trend stats. Use "custom" with from/to.
|
||||
@@ -508,3 +584,35 @@ export const GetAdminStatsRange = {
|
||||
"90d": "90d",
|
||||
custom: "custom",
|
||||
} as const;
|
||||
|
||||
export type GetAdminAppOpensByAppParams = {
|
||||
range?: GetAdminAppOpensByAppRange;
|
||||
from?: string;
|
||||
to?: string;
|
||||
};
|
||||
|
||||
export type GetAdminAppOpensByAppRange =
|
||||
(typeof GetAdminAppOpensByAppRange)[keyof typeof GetAdminAppOpensByAppRange];
|
||||
|
||||
export const GetAdminAppOpensByAppRange = {
|
||||
"7d": "7d",
|
||||
"30d": "30d",
|
||||
"90d": "90d",
|
||||
custom: "custom",
|
||||
} as const;
|
||||
|
||||
export type GetAdminAppOpensByUserParams = {
|
||||
range?: GetAdminAppOpensByUserRange;
|
||||
from?: string;
|
||||
to?: string;
|
||||
};
|
||||
|
||||
export type GetAdminAppOpensByUserRange =
|
||||
(typeof GetAdminAppOpensByUserRange)[keyof typeof GetAdminAppOpensByUserRange];
|
||||
|
||||
export const GetAdminAppOpensByUserRange = {
|
||||
"7d": "7d",
|
||||
"30d": "30d",
|
||||
"90d": "90d",
|
||||
custom: "custom",
|
||||
} as const;
|
||||
|
||||
Reference in New Issue
Block a user