Make admin recent-opens drill-in rows clickable

Task: #47 — Let admins jump from a recent open straight to that user.

Changes (artifacts/teaboy-os/src/pages/admin.tsx):
- AppOpensDrillIn: each user row in the per-app recent-opens popup is
  now a focusable button. Clicking jumps to that user's row in the
  Users list (closes the popup, then calls the existing
  handleSelectUserFromDashboard via a new onJumpUser prop), reusing
  the highlight behavior that already powers the leaderboard.
- UserOpensDrillIn: each app row in the per-user recent-opens popup is
  now a focusable button. Clicking opens that app's edit modal
  (closes the popup, then calls the existing
  handleSelectAppFromDashboard via a new onJumpApp prop), mirroring
  the leaderboard click behavior.
- Added type="button", hover/focus styles, and aria-labels reusing
  the existing admin.dashboard.viewUserDetails /
  admin.dashboard.viewAppDetails translation keys (already localized
  for both en and ar).

The footer "Edit app" / "View user" buttons remain unchanged and
keep working alongside the per-row clicks.

No new translation keys, schema changes, or API changes were needed —
AppOpenByAppEntry already exposes userId and AppOpenByUserEntry
already exposes appId.

Verification: tsc --noEmit on teaboy-os shows no new errors from
these edits (only the same pre-existing errors that were present
before this task). E2E was not run because the change is a thin
wiring of existing handlers and admin login requires custom
session setup beyond this task's scope.

Replit-Task-Id: 5db0964e-95fa-4199-9337-631cd1a790ec
This commit is contained in:
riyadhafraa
2026-04-21 11:39:34 +00:00
parent 0b7593bbc2
commit ce0819cfaa
+48 -28
View File
@@ -1515,6 +1515,10 @@ function DashboardSection({
setDrillIn(null);
onSelectApp(id);
}}
onJumpUser={(id) => {
setDrillIn(null);
onSelectUser(id);
}}
/>
)}
{drillIn?.kind === "user" && (
@@ -1528,6 +1532,10 @@ function DashboardSection({
setDrillIn(null);
onSelectUser(id);
}}
onJumpApp={(id) => {
setDrillIn(null);
onSelectApp(id);
}}
/>
)}
</div>
@@ -1607,6 +1615,7 @@ function AppOpensDrillIn({
statsQueryParams,
onClose,
onJump,
onJumpUser,
}: {
appId: number;
lang: string;
@@ -1614,6 +1623,7 @@ function AppOpensDrillIn({
statsQueryParams: GetAdminStatsParams;
onClose: () => void;
onJump: (appId: number) => void;
onJumpUser: (userId: number) => void;
}) {
const { t } = useTranslation();
const { data, isLoading, error } = useGetAdminAppOpensByApp(appId, statsQueryParams, {
@@ -1665,20 +1675,24 @@ function AppOpensDrillIn({
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 key={o.id}>
<button
type="button"
onClick={() => onJumpUser(o.userId)}
aria-label={t("admin.dashboard.viewUserDetails", { name: display })}
className="flex items-center gap-3 p-2 rounded-xl bg-white/60 border border-slate-200/60 w-full text-start hover:bg-white hover:border-slate-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-colors"
>
<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>
</button>
</li>
);
})}
@@ -1695,6 +1709,7 @@ function UserOpensDrillIn({
statsQueryParams,
onClose,
onJump,
onJumpApp,
}: {
userId: number;
lang: string;
@@ -1702,6 +1717,7 @@ function UserOpensDrillIn({
statsQueryParams: GetAdminStatsParams;
onClose: () => void;
onJump: (userId: number) => void;
onJumpApp: (appId: number) => void;
}) {
const { t } = useTranslation();
const { data, isLoading, error } = useGetAdminAppOpensByUser(userId, statsQueryParams, {
@@ -1752,20 +1768,24 @@ function UserOpensDrillIn({
{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 key={o.id}>
<button
type="button"
onClick={() => onJumpApp(o.appId)}
aria-label={t("admin.dashboard.viewAppDetails", { name })}
className="flex items-center gap-3 p-2 rounded-xl bg-white/60 border border-slate-200/60 w-full text-start hover:bg-white hover:border-slate-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-colors"
>
<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>
</button>
</li>
);
})}