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:
@@ -112,17 +112,32 @@ router.get("/stats/home", requireAuth, async (req, res): Promise<void> => {
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/stats/admin", requireAuth, requireAdmin, async (req, res): Promise<void> => {
|
||||
const rangeParam = typeof req.query.range === "string" ? req.query.range : "7d";
|
||||
const fromParam = typeof req.query.from === "string" ? req.query.from : undefined;
|
||||
const toParam = typeof req.query.to === "string" ? req.query.to : undefined;
|
||||
const allowedRanges = ["7d", "30d", "90d", "custom"] as const;
|
||||
type RangeKey = typeof allowedRanges[number];
|
||||
let range: RangeKey = (allowedRanges as readonly string[]).includes(rangeParam)
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const allowedRanges = ["7d", "30d", "90d", "custom"] as const;
|
||||
type RangeKey = typeof allowedRanges[number];
|
||||
|
||||
type RangeWindow = {
|
||||
range: RangeKey;
|
||||
rangeStart: Date;
|
||||
rangeEndExclusive: Date;
|
||||
rangeDays: number;
|
||||
prevRangeStart: Date;
|
||||
rangeFromStr: string;
|
||||
rangeToStr: string;
|
||||
};
|
||||
|
||||
function parseRangeWindow(query: {
|
||||
range?: unknown;
|
||||
from?: unknown;
|
||||
to?: unknown;
|
||||
}): { window: RangeWindow } | { error: string } {
|
||||
const rangeParam = typeof query.range === "string" ? query.range : "7d";
|
||||
const fromParam = typeof query.from === "string" ? query.from : undefined;
|
||||
const toParam = typeof query.to === "string" ? query.to : undefined;
|
||||
const range: RangeKey = (allowedRanges as readonly string[]).includes(rangeParam)
|
||||
? (rangeParam as RangeKey)
|
||||
: "7d";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const isoDateRe = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const parseUtcDate = (s: string): Date | null => {
|
||||
if (!isoDateRe.test(s)) return null;
|
||||
@@ -141,28 +156,20 @@ router.get("/stats/admin", requireAuth, requireAdmin, async (req, res): Promise<
|
||||
|
||||
if (range === "custom") {
|
||||
if (!fromParam || !toParam) {
|
||||
res.status(400).json({
|
||||
error: "Missing from/to: 'range=custom' requires both 'from' and 'to' as YYYY-MM-DD dates",
|
||||
});
|
||||
return;
|
||||
return { error: "Missing from/to: 'range=custom' requires both 'from' and 'to' as YYYY-MM-DD dates" };
|
||||
}
|
||||
const fromDate = parseUtcDate(fromParam);
|
||||
const toDate = parseUtcDate(toParam);
|
||||
if (!fromDate || !toDate) {
|
||||
res.status(400).json({
|
||||
error: "Invalid from/to: expected YYYY-MM-DD dates",
|
||||
});
|
||||
return;
|
||||
return { error: "Invalid from/to: expected YYYY-MM-DD dates" };
|
||||
}
|
||||
if (fromDate.getTime() > toDate.getTime()) {
|
||||
res.status(400).json({ error: "Invalid from/to: 'from' must be on or before 'to'" });
|
||||
return;
|
||||
return { error: "Invalid from/to: 'from' must be on or before 'to'" };
|
||||
}
|
||||
const MAX_DAYS = 366;
|
||||
const days = Math.round((toDate.getTime() - fromDate.getTime()) / DAY_MS) + 1;
|
||||
if (days > MAX_DAYS) {
|
||||
res.status(400).json({ error: "Range too large" });
|
||||
return;
|
||||
return { error: "Range too large" };
|
||||
}
|
||||
rangeStart = fromDate;
|
||||
rangeEndExclusive = new Date(toDate.getTime() + DAY_MS);
|
||||
@@ -177,6 +184,35 @@ router.get("/stats/admin", requireAuth, requireAdmin, async (req, res): Promise<
|
||||
const rangeFromStr = rangeStart.toISOString().slice(0, 10);
|
||||
const rangeToStr = new Date(rangeEndExclusive.getTime() - DAY_MS).toISOString().slice(0, 10);
|
||||
|
||||
return {
|
||||
window: {
|
||||
range,
|
||||
rangeStart,
|
||||
rangeEndExclusive,
|
||||
rangeDays,
|
||||
prevRangeStart,
|
||||
rangeFromStr,
|
||||
rangeToStr,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
router.get("/stats/admin", requireAuth, requireAdmin, async (req, res): Promise<void> => {
|
||||
const parsed = parseRangeWindow(req.query);
|
||||
if ("error" in parsed) {
|
||||
res.status(400).json({ error: parsed.error });
|
||||
return;
|
||||
}
|
||||
const {
|
||||
range,
|
||||
rangeStart,
|
||||
rangeEndExclusive,
|
||||
rangeDays,
|
||||
prevRangeStart,
|
||||
rangeFromStr,
|
||||
rangeToStr,
|
||||
} = parsed.window;
|
||||
|
||||
const [newUsersInRangeRow] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(usersTable)
|
||||
@@ -391,4 +427,192 @@ router.get("/stats/admin", requireAuth, requireAdmin, async (req, res): Promise<
|
||||
});
|
||||
});
|
||||
|
||||
const RECENT_OPENS_LIMIT = 100;
|
||||
|
||||
router.get(
|
||||
"/stats/admin/app-opens/by-app/:appId",
|
||||
requireAuth,
|
||||
requireAdmin,
|
||||
async (req, res): Promise<void> => {
|
||||
const appId = Number(req.params.appId);
|
||||
if (!Number.isInteger(appId) || appId <= 0) {
|
||||
res.status(400).json({ error: "Invalid appId" });
|
||||
return;
|
||||
}
|
||||
const parsed = parseRangeWindow(req.query);
|
||||
if ("error" in parsed) {
|
||||
res.status(400).json({ error: parsed.error });
|
||||
return;
|
||||
}
|
||||
const { range, rangeStart, rangeEndExclusive, rangeDays, rangeFromStr, rangeToStr } =
|
||||
parsed.window;
|
||||
|
||||
const [app] = await db
|
||||
.select({
|
||||
id: appsTable.id,
|
||||
slug: appsTable.slug,
|
||||
nameAr: appsTable.nameAr,
|
||||
nameEn: appsTable.nameEn,
|
||||
iconName: appsTable.iconName,
|
||||
color: appsTable.color,
|
||||
})
|
||||
.from(appsTable)
|
||||
.where(eq(appsTable.id, appId));
|
||||
if (!app) {
|
||||
res.status(404).json({ error: "App not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const [totalRow] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(appOpensTable)
|
||||
.where(
|
||||
and(
|
||||
eq(appOpensTable.appId, appId),
|
||||
sql`${appOpensTable.createdAt} >= ${rangeStart.toISOString()}`,
|
||||
sql`${appOpensTable.createdAt} < ${rangeEndExclusive.toISOString()}`,
|
||||
),
|
||||
);
|
||||
|
||||
const opensRows = await db
|
||||
.select({
|
||||
id: appOpensTable.id,
|
||||
createdAt: appOpensTable.createdAt,
|
||||
userId: usersTable.id,
|
||||
username: usersTable.username,
|
||||
displayNameAr: usersTable.displayNameAr,
|
||||
displayNameEn: usersTable.displayNameEn,
|
||||
avatarUrl: usersTable.avatarUrl,
|
||||
})
|
||||
.from(appOpensTable)
|
||||
.innerJoin(usersTable, eq(appOpensTable.userId, usersTable.id))
|
||||
.where(
|
||||
and(
|
||||
eq(appOpensTable.appId, appId),
|
||||
sql`${appOpensTable.createdAt} >= ${rangeStart.toISOString()}`,
|
||||
sql`${appOpensTable.createdAt} < ${rangeEndExclusive.toISOString()}`,
|
||||
),
|
||||
)
|
||||
.orderBy(sql`${appOpensTable.createdAt} desc`)
|
||||
.limit(RECENT_OPENS_LIMIT);
|
||||
|
||||
res.json({
|
||||
appId: app.id,
|
||||
slug: app.slug,
|
||||
nameAr: app.nameAr,
|
||||
nameEn: app.nameEn,
|
||||
iconName: app.iconName,
|
||||
color: app.color,
|
||||
range,
|
||||
rangeDays,
|
||||
rangeFrom: rangeFromStr,
|
||||
rangeTo: rangeToStr,
|
||||
totalCount: Number(totalRow?.count ?? 0),
|
||||
opens: opensRows.map((r) => ({
|
||||
id: r.id,
|
||||
createdAt:
|
||||
r.createdAt instanceof Date ? r.createdAt.toISOString() : String(r.createdAt),
|
||||
userId: r.userId,
|
||||
username: r.username,
|
||||
displayNameAr: r.displayNameAr,
|
||||
displayNameEn: r.displayNameEn,
|
||||
avatarUrl: r.avatarUrl,
|
||||
})),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/stats/admin/app-opens/by-user/:userId",
|
||||
requireAuth,
|
||||
requireAdmin,
|
||||
async (req, res): Promise<void> => {
|
||||
const userId = Number(req.params.userId);
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
res.status(400).json({ error: "Invalid userId" });
|
||||
return;
|
||||
}
|
||||
const parsed = parseRangeWindow(req.query);
|
||||
if ("error" in parsed) {
|
||||
res.status(400).json({ error: parsed.error });
|
||||
return;
|
||||
}
|
||||
const { range, rangeStart, rangeEndExclusive, rangeDays, rangeFromStr, rangeToStr } =
|
||||
parsed.window;
|
||||
|
||||
const [user] = await db
|
||||
.select({
|
||||
id: usersTable.id,
|
||||
username: usersTable.username,
|
||||
displayNameAr: usersTable.displayNameAr,
|
||||
displayNameEn: usersTable.displayNameEn,
|
||||
avatarUrl: usersTable.avatarUrl,
|
||||
})
|
||||
.from(usersTable)
|
||||
.where(eq(usersTable.id, userId));
|
||||
if (!user) {
|
||||
res.status(404).json({ error: "User not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const [totalRow] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(appOpensTable)
|
||||
.where(
|
||||
and(
|
||||
eq(appOpensTable.userId, userId),
|
||||
sql`${appOpensTable.createdAt} >= ${rangeStart.toISOString()}`,
|
||||
sql`${appOpensTable.createdAt} < ${rangeEndExclusive.toISOString()}`,
|
||||
),
|
||||
);
|
||||
|
||||
const opensRows = await db
|
||||
.select({
|
||||
id: appOpensTable.id,
|
||||
createdAt: appOpensTable.createdAt,
|
||||
appId: appsTable.id,
|
||||
slug: appsTable.slug,
|
||||
nameAr: appsTable.nameAr,
|
||||
nameEn: appsTable.nameEn,
|
||||
iconName: appsTable.iconName,
|
||||
color: appsTable.color,
|
||||
})
|
||||
.from(appOpensTable)
|
||||
.innerJoin(appsTable, eq(appOpensTable.appId, appsTable.id))
|
||||
.where(
|
||||
and(
|
||||
eq(appOpensTable.userId, userId),
|
||||
sql`${appOpensTable.createdAt} >= ${rangeStart.toISOString()}`,
|
||||
sql`${appOpensTable.createdAt} < ${rangeEndExclusive.toISOString()}`,
|
||||
),
|
||||
)
|
||||
.orderBy(sql`${appOpensTable.createdAt} desc`)
|
||||
.limit(RECENT_OPENS_LIMIT);
|
||||
|
||||
res.json({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
displayNameAr: user.displayNameAr,
|
||||
displayNameEn: user.displayNameEn,
|
||||
avatarUrl: user.avatarUrl,
|
||||
range,
|
||||
rangeDays,
|
||||
rangeFrom: rangeFromStr,
|
||||
rangeTo: rangeToStr,
|
||||
totalCount: Number(totalRow?.count ?? 0),
|
||||
opens: opensRows.map((r) => ({
|
||||
id: r.id,
|
||||
createdAt:
|
||||
r.createdAt instanceof Date ? r.createdAt.toISOString() : String(r.createdAt),
|
||||
appId: r.appId,
|
||||
slug: r.slug,
|
||||
nameAr: r.nameAr,
|
||||
nameEn: r.nameEn,
|
||||
iconName: r.iconName,
|
||||
color: r.color,
|
||||
})),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -319,7 +319,17 @@
|
||||
"leaderboardEmpty": "لا يوجد نشاط بعد",
|
||||
"openCount": "{{count}} فتحة",
|
||||
"viewAppDetails": "عرض تفاصيل {{name}}",
|
||||
"viewUserDetails": "عرض تفاصيل {{name}}"
|
||||
"viewUserDetails": "عرض تفاصيل {{name}}",
|
||||
"drillIn": {
|
||||
"loading": "جارٍ التحميل…",
|
||||
"appTitle": "آخر الفتحات · {{name}}",
|
||||
"userTitle": "آخر الفتحات · {{name}}",
|
||||
"subtitle": "{{count}} فتحة في {{range}}",
|
||||
"empty": "لا توجد فتحات في هذا النطاق",
|
||||
"loadError": "تعذّر تحميل آخر الفتحات.",
|
||||
"editApp": "تعديل التطبيق",
|
||||
"viewUser": "عرض المستخدم"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
|
||||
@@ -316,7 +316,17 @@
|
||||
"leaderboardEmpty": "No activity yet",
|
||||
"openCount": "{{count}} opens",
|
||||
"viewAppDetails": "View details for {{name}}",
|
||||
"viewUserDetails": "View details for {{name}}"
|
||||
"viewUserDetails": "View details for {{name}}",
|
||||
"drillIn": {
|
||||
"loading": "Loading…",
|
||||
"appTitle": "Recent opens · {{name}}",
|
||||
"userTitle": "Recent opens · {{name}}",
|
||||
"subtitle": "{{count}} opens in {{range}}",
|
||||
"empty": "No opens in this range",
|
||||
"loadError": "Couldn't load recent opens.",
|
||||
"editApp": "Edit app",
|
||||
"viewUser": "View user"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useState, useEffect, useRef, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "wouter";
|
||||
import {
|
||||
@@ -22,9 +22,21 @@ import {
|
||||
useUpdateAppSettings,
|
||||
useGetAdminStats,
|
||||
getGetAdminStatsQueryKey,
|
||||
useGetAdminAppOpensByApp,
|
||||
getGetAdminAppOpensByAppQueryKey,
|
||||
useGetAdminAppOpensByUser,
|
||||
getGetAdminAppOpensByUserQueryKey,
|
||||
useAdminIssueResetLink,
|
||||
} from "@workspace/api-client-react";
|
||||
import type { App, Service, UserProfile, AppSettings, AdminStats, GetAdminStatsQueryError } from "@workspace/api-client-react";
|
||||
import type {
|
||||
App,
|
||||
Service,
|
||||
UserProfile,
|
||||
AppSettings,
|
||||
AdminStats,
|
||||
GetAdminStatsQueryError,
|
||||
GetAdminStatsParams,
|
||||
} 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";
|
||||
@@ -514,6 +526,7 @@ export default function AdminPage() {
|
||||
onCustomFromChange={setCustomFrom}
|
||||
onCustomToChange={setCustomTo}
|
||||
onApplyCustom={() => setAppliedCustom({ from: customFrom, to: customTo })}
|
||||
statsQueryParams={statsQueryParams}
|
||||
onSelectApp={handleSelectAppFromDashboard}
|
||||
onSelectUser={handleSelectUserFromDashboard}
|
||||
/>
|
||||
@@ -979,6 +992,7 @@ function DashboardSection({
|
||||
onCustomFromChange,
|
||||
onCustomToChange,
|
||||
onApplyCustom,
|
||||
statsQueryParams,
|
||||
onSelectApp,
|
||||
onSelectUser,
|
||||
}: {
|
||||
@@ -996,10 +1010,16 @@ function DashboardSection({
|
||||
onCustomFromChange: (v: string) => void;
|
||||
onCustomToChange: (v: string) => void;
|
||||
onApplyCustom: () => void;
|
||||
statsQueryParams: GetAdminStatsParams;
|
||||
onSelectApp: (appId: number) => void;
|
||||
onSelectUser: (userId: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [drillIn, setDrillIn] = useState<
|
||||
| { kind: "app"; appId: number }
|
||||
| { kind: "user"; userId: number }
|
||||
| null
|
||||
>(null);
|
||||
|
||||
const totalApps = apps?.length ?? 0;
|
||||
const totalServices = services?.length ?? 0;
|
||||
@@ -1348,7 +1368,7 @@ function DashboardSection({
|
||||
<li key={app.appId}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectApp(app.appId)}
|
||||
onClick={() => setDrillIn({ kind: "app", appId: app.appId })}
|
||||
aria-label={t("admin.dashboard.viewAppDetails", { name: appName })}
|
||||
className="w-full text-start flex items-center gap-3 p-2 rounded-xl bg-white/60 border border-slate-200/60 hover:bg-white hover:border-slate-300 hover:shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-primary transition-all cursor-pointer"
|
||||
>
|
||||
@@ -1408,7 +1428,7 @@ function DashboardSection({
|
||||
<li key={u.userId}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectUser(u.userId)}
|
||||
onClick={() => setDrillIn({ kind: "user", userId: u.userId })}
|
||||
aria-label={t("admin.dashboard.viewUserDetails", { name: displayName })}
|
||||
className="w-full text-start flex items-center gap-3 p-2 rounded-xl bg-white/60 border border-slate-200/60 hover:bg-white hover:border-slate-300 hover:shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-primary transition-all cursor-pointer"
|
||||
>
|
||||
@@ -1483,6 +1503,274 @@ function DashboardSection({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{drillIn?.kind === "app" && (
|
||||
<AppOpensDrillIn
|
||||
appId={drillIn.appId}
|
||||
lang={lang}
|
||||
rangeLabel={rangeLabel}
|
||||
statsQueryParams={statsQueryParams}
|
||||
onClose={() => setDrillIn(null)}
|
||||
onJump={(id) => {
|
||||
setDrillIn(null);
|
||||
onSelectApp(id);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{drillIn?.kind === "user" && (
|
||||
<UserOpensDrillIn
|
||||
userId={drillIn.userId}
|
||||
lang={lang}
|
||||
rangeLabel={rangeLabel}
|
||||
statsQueryParams={statsQueryParams}
|
||||
onClose={() => setDrillIn(null)}
|
||||
onJump={(id) => {
|
||||
setDrillIn(null);
|
||||
onSelectUser(id);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DrillInShell({
|
||||
title,
|
||||
subtitle,
|
||||
onClose,
|
||||
children,
|
||||
footer,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [onClose]);
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-slate-900/30 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="glass-panel rounded-3xl p-5 w-full max-w-md max-h-[85vh] flex flex-col gap-3"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-base font-semibold text-foreground truncate">{title}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{subtitle}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-xs text-muted-foreground hover:text-foreground px-2 py-1 rounded-lg hover:bg-slate-100"
|
||||
aria-label={t("common.close")}
|
||||
>
|
||||
{t("common.close")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto pr-1 -mr-1">{children}</div>
|
||||
{footer && <div className="pt-2 border-t border-slate-200/70">{footer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatOpenTimestamp(iso: string, lang: string): string {
|
||||
try {
|
||||
return formatDateUtil(iso, lang, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function AppOpensDrillIn({
|
||||
appId,
|
||||
lang,
|
||||
rangeLabel,
|
||||
statsQueryParams,
|
||||
onClose,
|
||||
onJump,
|
||||
}: {
|
||||
appId: number;
|
||||
lang: string;
|
||||
rangeLabel: string;
|
||||
statsQueryParams: GetAdminStatsParams;
|
||||
onClose: () => void;
|
||||
onJump: (appId: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, error } = useGetAdminAppOpensByApp(appId, statsQueryParams, {
|
||||
query: {
|
||||
queryKey: getGetAdminAppOpensByAppQueryKey(appId, statsQueryParams),
|
||||
retry: false,
|
||||
},
|
||||
});
|
||||
const errorMessage = error ? (error as { message?: string }).message ?? null : null;
|
||||
const appName = data ? (lang === "ar" ? data.nameAr : data.nameEn) : "";
|
||||
const title = appName
|
||||
? t("admin.dashboard.drillIn.appTitle", { name: appName })
|
||||
: t("admin.dashboard.drillIn.loading");
|
||||
const subtitle = data
|
||||
? t("admin.dashboard.drillIn.subtitle", { count: data.totalCount, range: rangeLabel })
|
||||
: rangeLabel;
|
||||
|
||||
return (
|
||||
<DrillInShell
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" variant="outline" onClick={() => onJump(appId)}>
|
||||
{t("admin.dashboard.drillIn.editApp")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
</div>
|
||||
) : errorMessage ? (
|
||||
<div className="text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded-lg px-3 py-2">
|
||||
{t("admin.dashboard.drillIn.loadError")}
|
||||
<div className="mt-0.5 font-mono text-[11px] opacity-80 break-words">{errorMessage}</div>
|
||||
</div>
|
||||
) : !data || data.opens.length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground py-8 text-center">
|
||||
{t("admin.dashboard.drillIn.empty")}
|
||||
</div>
|
||||
) : (
|
||||
<ol className="space-y-1.5">
|
||||
{data.opens.map((o) => {
|
||||
const display =
|
||||
(lang === "ar" ? o.displayNameAr : o.displayNameEn) ?? o.username;
|
||||
const initial = (display || o.username || "?").trim().charAt(0).toUpperCase();
|
||||
const avatar = resolveServiceImageUrl(o.avatarUrl);
|
||||
return (
|
||||
<li
|
||||
key={o.id}
|
||||
className="flex items-center gap-3 p-2 rounded-xl bg-white/60 border border-slate-200/60"
|
||||
>
|
||||
<LeaderboardAvatar src={avatar} initial={initial} alt={display} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-foreground truncate">{display}</div>
|
||||
{display !== o.username && (
|
||||
<div className="text-[11px] text-muted-foreground truncate">@{o.username}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground shrink-0 tabular-nums">
|
||||
{formatOpenTimestamp(o.createdAt, lang)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
</DrillInShell>
|
||||
);
|
||||
}
|
||||
|
||||
function UserOpensDrillIn({
|
||||
userId,
|
||||
lang,
|
||||
rangeLabel,
|
||||
statsQueryParams,
|
||||
onClose,
|
||||
onJump,
|
||||
}: {
|
||||
userId: number;
|
||||
lang: string;
|
||||
rangeLabel: string;
|
||||
statsQueryParams: GetAdminStatsParams;
|
||||
onClose: () => void;
|
||||
onJump: (userId: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, error } = useGetAdminAppOpensByUser(userId, statsQueryParams, {
|
||||
query: {
|
||||
queryKey: getGetAdminAppOpensByUserQueryKey(userId, statsQueryParams),
|
||||
retry: false,
|
||||
},
|
||||
});
|
||||
const errorMessage = error ? (error as { message?: string }).message ?? null : null;
|
||||
const displayName = data
|
||||
? (lang === "ar" ? data.displayNameAr : data.displayNameEn) ?? data.username
|
||||
: "";
|
||||
const title = displayName
|
||||
? t("admin.dashboard.drillIn.userTitle", { name: displayName })
|
||||
: t("admin.dashboard.drillIn.loading");
|
||||
const subtitle = data
|
||||
? t("admin.dashboard.drillIn.subtitle", { count: data.totalCount, range: rangeLabel })
|
||||
: rangeLabel;
|
||||
|
||||
return (
|
||||
<DrillInShell
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" variant="outline" onClick={() => onJump(userId)}>
|
||||
{t("admin.dashboard.drillIn.viewUser")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
</div>
|
||||
) : errorMessage ? (
|
||||
<div className="text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded-lg px-3 py-2">
|
||||
{t("admin.dashboard.drillIn.loadError")}
|
||||
<div className="mt-0.5 font-mono text-[11px] opacity-80 break-words">{errorMessage}</div>
|
||||
</div>
|
||||
) : !data || data.opens.length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground py-8 text-center">
|
||||
{t("admin.dashboard.drillIn.empty")}
|
||||
</div>
|
||||
) : (
|
||||
<ol className="space-y-1.5">
|
||||
{data.opens.map((o) => {
|
||||
const name = lang === "ar" ? o.nameAr : o.nameEn;
|
||||
return (
|
||||
<li
|
||||
key={o.id}
|
||||
className="flex items-center gap-3 p-2 rounded-xl bg-white/60 border border-slate-200/60"
|
||||
>
|
||||
<div
|
||||
className="w-8 h-8 rounded-lg shrink-0"
|
||||
style={{ backgroundColor: `${o.color}40` }}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-foreground truncate">{name}</div>
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground shrink-0 tabular-nums">
|
||||
{formatOpenTimestamp(o.createdAt, lang)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
</DrillInShell>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user