/** * @file UserDetail.tsx * @description One account, opened IN PLACE under its row of the * Administration page's users table (`?user=`, `GET /api/users/:id/details`). * The row already shows identity, sessions, spend, home and tmux, so * this keeps only what the row cannot: one summary line (last sign-in, last * activity, active sessions and live panes), then three short columns — spend * (30-day bars, by AI tool, top models), the account pool (per tool: pin or * group, each account's quota left beside this user's own 7-day share), and * what runs now (live panes, owned workspaces) — and the five newest sessions. Read-only; Edit stays on * the row. * @author Nguyễn Ngọc Trí Vĩ */ import { useCallback, useEffect, useState, type ReactNode } from "react"; import { Link } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { AlertCircle, Play, RefreshCw } from "lucide-react"; import { api, type PoolTool, type UserDetails } from "../../lib/api"; import { fmtCost, formatDateTime } from "../../lib/format"; import { effectiveSessionStatus, sessionAwaitingReason } from "../../lib/types"; import { useSpin } from "../../hooks/useSpin"; import { useAiTools } from "../../hooks/useAiTools"; import { AiToolIcon } from "../AiToolIcon"; import { SessionStatusBadge } from "../StatusBadge"; import { MiniChart } from "../admin/MiniChart"; import { QuotaBar, windowsOf } from "./AccountPoolCard"; const POOL_LABEL: Record = { claude: "Claude Code", codex: "Codex", cursor: "Cursor CLI", }; const pctText = (n: number) => `${n < 10 ? n.toFixed(1) : Math.round(n)}%`; export function UserDetail({ userId }: { userId: number }) { const { t } = useTranslation("users"); const { label: toolLabel } = useAiTools(); const [data, setData] = useState(null); const [error, setError] = useState(null); const load = useCallback( () => api.users .details(userId) .then((d) => { setData(d); setError(null); }) .catch((e: Error) => setError(e.message)), [userId] ); const [spinning, refresh] = useSpin(load); useEffect(() => { setData(null); void load(); }, [load]); if (error) return (
{t("detail.failed")}: {error}
); if (!data) return (

{t("common:loading")}

); const u = data.user; const total30 = data.usage.last30d.cost; const pools = (Object.keys(POOL_LABEL) as PoolTool[]).filter( (tool) => (data.pool[tool]?.accounts.length ?? 0) > 0 ); return (
{/* One line of what the row does not show. */}
{u.lastLoginAt ? formatDateTime(u.lastLoginAt) : t("detail.never")} {data.sessions.lastAt ? formatDateTime(data.sessions.lastAt) : "—"} {t("detail.activeValue", { sessions: data.sessions.active, panes: data.panes.length })}
{/* Spend: the month at a glance, then where it went. */} ({ x: d.date.slice(5), y: d.cost }))} format={(v) => fmtCost(v)} /> {data.usage.byTool.length === 0 ? (

{t("detail.noUsage")}

) : (
    {data.usage.byTool.map((r) => ( {toolLabel(r.key)} } cost={r.cost} total={total30} /> ))} {data.usage.byModel.slice(0, 3).map((r) => ( {r.key}} cost={r.cost} total={total30} /> ))}
)}
{/* Pool: what they may run on, and how much of it is theirs. */} {pools.length === 0 ? (

{t("detail.poolNone")}

) : ( pools.map((tool) => { const p = data.pool[tool]!; return (
{POOL_LABEL[tool]} ·{" "} {p.pin ? t("detail.poolPinned", { account: p.pin }) : p.choice ? t("detail.poolChosen", { account: p.choice }) : p.group ? t("detail.poolGroup", { name: p.group.name }) : t("detail.poolShared")}
{p.accounts.map((a) => { const share = a.spend7d > 0 ? (a.userSpend7d / a.spend7d) * 100 : 0; return (
{a.name} {a.expired && ( {t("pool.expired")} )} {!a.allowed && ( {t("detail.poolNotAllowed")} )} {tool !== "cursor" && ( {t("detail.poolTheirs7d", { cost: fmtCost(a.userSpend7d) })} {a.spend7d > 0 && ( ({pctText(share)}) )} )}
{tool !== "cursor" && windowsOf(a).map((w) => )}
); })}
); }) )}
{/* Now: live panes and the workspaces they own. */} {data.panes.length === 0 ? (

{t("detail.noPanes")}

) : (
    {data.panes.map((p) => (
  • {p.laneTitle || p.laneCwd} {" "} · {toolLabel(p.tool)} {p.account ? ` · ${p.account}` : ""}
  • ))}
)} {data.workspaces.length > 0 && (
{data.workspaces.map((w) => ( {w.livePanes > 0 && ( )} {w.title || w.cwd.split("/").pop()} {w.sessions} ))}
)}
{data.recentSessions.length > 0 && (
    {data.recentSessions.slice(0, 5).map((s) => (
  • {s.name || s.id.slice(0, 8)} {toolLabel(s.tool || "claude")} {formatDateTime(s.last_activity || s.started_at)} {s.cost ? fmtCost(s.cost) : "—"}
  • ))}
)}
); } function Inline({ label, children }: { label: string; children: ReactNode }) { return ( {label}: {children} ); } function Panel({ title, children }: { title: string; children: ReactNode }) { return (

{title}

{children}
); } /** One slice of the 30-day spend: label, $, and a thin bar of its share. */ function ShareRow({ label, cost, total }: { label: ReactNode; cost: number; total: number }) { const share = total > 0 ? (cost / total) * 100 : 0; return (
  • {label} {fmtCost(cost)} {Math.round(share)}%
  • ); }