feat(admin): super-admin user detail dashboard

Add a read-only User Detail dashboard for super admins, reachable by
clicking a user name in the admin users list.

Backend: new `admin` module aggregating existing domain services
(no new entities). ADMIN-only endpoints under /api/v1/admin:
overview (profile, account status, wallet balance, revenue, summary
counts), wallet transactions, applications (incl. deleted/docked with
restore eligibility), build/deploy errors, tickets with conversation,
and a composite activity timeline. Adds BillingService.getRevenueSummary
and guards against a wallet get-or-create race in the overview reads.

Frontend: tabbed detail page (overview/applications/activity/errors/
tickets) with lazy per-tab queries; user names in the admin list link to
it (admin only); fa/en i18n keys and response types.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
keyhan
2026-06-17 23:52:21 +03:30
parent 87e2224d38
commit 7958d2fa72
10 changed files with 1409 additions and 3 deletions
@@ -0,0 +1,638 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useParams } from 'next/navigation';
import { useLocalizedRouter } from '@/i18n/navigation';
import { useT, useLocale } from '@/i18n/I18nProvider';
import api from '@/lib/api';
import type {
AdminUserOverview,
AdminUserApplication,
AdminUserError,
AdminUserTicket,
AdminActivityEvent,
AdminDeploymentLogs,
} from '@/types';
import {
ArrowLeft,
Wallet,
TrendingUp,
Boxes,
AlertTriangle,
Ticket as TicketIcon,
Activity,
Server,
ChevronDown,
ChevronUp,
Layers,
CreditCard,
} from 'lucide-react';
type TabKey = 'overview' | 'applications' | 'activity' | 'errors' | 'tickets';
type Det = ReturnType<typeof useT>['dashboard']['users']['detail'];
const lifecycleBadge: Record<string, string> = {
active: 'badge-green',
suspended: 'bg-yellow-100 text-yellow-700 px-2 py-0.5 rounded-full text-xs font-medium',
pending_deletion: 'bg-orange-100 text-orange-700 px-2 py-0.5 rounded-full text-xs font-medium',
docked: 'bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full text-xs font-medium',
deleted: 'badge-red',
};
const deployBadge: Record<string, string> = {
running: 'badge-green',
build_failed: 'badge-red',
failed: 'badge-red',
building: 'badge-blue',
deploying: 'badge-blue',
pending: 'badge-gray',
stopped: 'badge-gray',
cancelled: 'badge-gray',
deleting: 'badge-gray',
};
const restoreBadge: Record<string, string> = {
restorable: 'badge-green',
recoverable: 'badge-blue',
none: 'badge-gray',
};
const ticketStatusBadge: Record<string, string> = {
open: 'bg-yellow-100 text-yellow-700',
waiting: 'bg-orange-100 text-orange-700',
answered: 'bg-green-100 text-green-700',
closed: 'bg-gray-100 text-gray-500',
};
function lookup(map: Record<string, string>, key?: string | null): string {
if (!key) return '—';
return map[key] ?? key;
}
export default function AdminUserDetailPage() {
const t = useT();
const det = t.dashboard.users.detail;
const roleLabels = t.dashboard.users.roles as Record<string, string>;
const locale = useLocale();
const { id } = useParams<{ id: string }>();
const router = useLocalizedRouter();
const [tab, setTab] = useState<TabKey>('overview');
const money = (n: number | string) =>
`${Number(n || 0).toLocaleString('en-US')} ${t.common.currencyShort}`;
const { data: overview, isLoading } = useQuery<AdminUserOverview>({
queryKey: ['admin-user', id],
queryFn: () => api.get(`/admin/users/${id}`).then((r) => r.data),
});
const apps = useQuery<AdminUserApplication[]>({
queryKey: ['admin-user', id, 'applications'],
queryFn: () => api.get(`/admin/users/${id}/applications`).then((r) => r.data),
enabled: tab === 'applications',
});
const activity = useQuery<AdminActivityEvent[]>({
queryKey: ['admin-user', id, 'activity'],
queryFn: () => api.get(`/admin/users/${id}/activity`).then((r) => r.data),
enabled: tab === 'activity',
});
const errors = useQuery<AdminUserError[]>({
queryKey: ['admin-user', id, 'errors'],
queryFn: () => api.get(`/admin/users/${id}/errors`).then((r) => r.data),
enabled: tab === 'errors',
});
const tickets = useQuery<AdminUserTicket[]>({
queryKey: ['admin-user', id, 'tickets'],
queryFn: () => api.get(`/admin/users/${id}/tickets`).then((r) => r.data),
enabled: tab === 'tickets',
});
if (isLoading) {
return (
<div className="text-center py-16">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary-600 border-t-transparent mx-auto" />
</div>
);
}
if (!overview) {
return <div className="card p-12 text-center text-gray-500">{det.notFound}</div>;
}
const p = overview.profile;
const tabs: { key: TabKey; label: string; icon: React.ReactNode; badge?: number }[] = [
{ key: 'overview', label: det.tabOverview, icon: <Activity className="w-4 h-4" /> },
{ key: 'applications', label: det.tabApplications, icon: <Boxes className="w-4 h-4" />, badge: overview.counts.appsTotal },
{ key: 'activity', label: det.tabActivity, icon: <Layers className="w-4 h-4" /> },
{ key: 'errors', label: det.tabErrors, icon: <AlertTriangle className="w-4 h-4" /> },
{ key: 'tickets', label: det.tabTickets, icon: <TicketIcon className="w-4 h-4" />, badge: overview.counts.ticketsOpen },
];
return (
<div className="space-y-6 animate-fade-in">
{/* Header */}
<div>
<button
onClick={() => router.back()}
className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-3"
>
<ArrowLeft className="w-4 h-4 rtl:rotate-180" /> {det.back}
</button>
<div className="flex items-center gap-3 flex-wrap">
<h1 className="text-2xl font-bold text-gray-900">
{p.firstName} {p.lastName}
</h1>
<span
className={`badge ${
p.role === 'admin' ? 'badge-purple' : p.role === 'user' ? 'badge-gray' : 'badge-blue'
}`}
>
{lookup(roleLabels, p.role)}
</span>
<span className={`badge ${p.isActive ? 'badge-green' : 'badge-red'}`}>
{p.isActive ? t.dashboard.users.active : t.dashboard.users.inactive}
</span>
</div>
<p className="text-sm text-gray-500 mt-1" dir="ltr">
{p.phone || p.email || '—'}
</p>
</div>
{/* Tabs */}
<div className="flex gap-1 border-b border-gray-200 overflow-x-auto">
{tabs.map((tb) => (
<button
key={tb.key}
onClick={() => setTab(tb.key)}
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium whitespace-nowrap border-b-2 -mb-px transition-colors ${
tab === tb.key
? 'border-primary-600 text-primary-700'
: 'border-transparent text-gray-500 hover:text-gray-800'
}`}
>
{tb.icon}
{tb.label}
{tb.badge !== undefined && tb.badge > 0 && (
<span className="min-w-[18px] h-[18px] flex items-center justify-center px-1 text-[10px] font-bold rounded-full bg-gray-200 text-gray-700">
{tb.badge}
</span>
)}
</button>
))}
</div>
{/* ── Overview ── */}
{tab === 'overview' && (
<div className="space-y-6">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{/* Basic info */}
<div className="card space-y-3">
<h2 className="text-sm font-semibold text-gray-700">{det.basicInfo}</h2>
<dl className="space-y-2 text-sm">
<Row label={t.dashboard.users.colPhone} value={p.phone || '—'} ltr />
<Row label={t.dashboard.users.colEmail} value={p.email || '—'} ltr />
<Row label={det.namespace} value={p.namespace || '—'} ltr />
<Row
label={det.joinedAt}
value={new Date(p.createdAt).toLocaleDateString(locale)}
/>
</dl>
</div>
{/* Wallet */}
<div className="card space-y-3">
<h2 className="text-sm font-semibold text-gray-700 flex items-center gap-2">
<Wallet className="w-4 h-4 text-primary-600" /> {det.walletBalance}
</h2>
<p className="text-2xl font-bold text-gray-900">{money(overview.wallet.balance)}</p>
<dl className="space-y-1.5 text-sm pt-2 border-t border-gray-100">
<Row label={det.totalCharged} value={money(overview.revenue.charged)} />
<Row label={det.totalRefunded} value={money(overview.revenue.refunded)} />
</dl>
</div>
{/* Revenue */}
<div className="card space-y-3">
<h2 className="text-sm font-semibold text-gray-700 flex items-center gap-2">
<TrendingUp className="w-4 h-4 text-green-600" /> {det.revenue}
</h2>
<p className="text-2xl font-bold text-green-700">{money(overview.revenue.revenue)}</p>
</div>
</div>
{/* Counts */}
<div>
<h2 className="text-sm font-semibold text-gray-700 mb-3">{det.countsTitle}</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
<StatBox label={det.appsTotal} value={overview.counts.appsTotal} />
<StatBox label={det.appsActive} value={overview.counts.appsActive} />
<StatBox label={det.appsDeleted} value={overview.counts.appsDeleted} />
<StatBox label={det.managedServices} value={overview.counts.managedServices} />
<StatBox label={det.deploymentsCount} value={overview.counts.deployments} />
<StatBox label={det.ticketsOpenCount} value={overview.counts.ticketsOpen} />
</div>
</div>
{/* Recent transactions */}
<div>
<h2 className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-2">
<CreditCard className="w-4 h-4 text-gray-500" /> {det.recentTransactions}
</h2>
{overview.recentTransactions.length === 0 ? (
<div className="card text-center py-8 text-sm text-gray-500">{det.noTransactions}</div>
) : (
<div className="table-wrapper">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50/80">
<tr>
<Th>{det.txType}</Th>
<Th>{det.txAmount}</Th>
<Th>{det.txBalance}</Th>
<Th>{det.txDesc}</Th>
<Th>{det.txDate}</Th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{overview.recentTransactions.map((tx) => (
<tr key={tx.id}>
<td className="px-4 py-3 text-sm">
{lookup(det.txTypes as Record<string, string>, tx.type)}
</td>
<td className="px-4 py-3 text-sm font-medium" dir="ltr">{money(tx.amount)}</td>
<td className="px-4 py-3 text-sm text-gray-500" dir="ltr">{money(tx.balanceAfter)}</td>
<td className="px-4 py-3 text-sm text-gray-600">{tx.description || '—'}</td>
<td className="px-4 py-3 text-sm text-gray-500">
{new Date(tx.createdAt).toLocaleDateString(locale)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
)}
{/* ── Applications ── */}
{tab === 'applications' && (
<TabState query={apps}>
{(list) => {
const active = list.filter(
(a) => a.lifecycleStatus !== 'docked' && a.lifecycleStatus !== 'deleted',
);
const removed = list.filter(
(a) => a.lifecycleStatus === 'docked' || a.lifecycleStatus === 'deleted',
);
return (
<div className="space-y-6">
<AppTable title={det.activeApps} apps={active} det={det} locale={locale} />
<AppTable title={det.deletedApps} apps={removed} det={det} locale={locale} showRestore />
</div>
);
}}
</TabState>
)}
{/* ── Activity ── */}
{tab === 'activity' && (
<TabState query={activity}>
{(events) =>
events.length === 0 ? (
<div className="card text-center py-10 text-sm text-gray-500">{det.noActivity}</div>
) : (
<ol className="relative border-s-2 border-gray-100 ms-3 space-y-5">
{events.map((e, i) => (
<li key={i} className="ms-5">
<span className="absolute -start-[7px] mt-1.5 w-3 h-3 rounded-full bg-primary-400 ring-4 ring-white" />
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-gray-900">
{lookup(det.activityTypes as Record<string, string>, e.type)}
</span>
<span className="text-sm text-gray-600">{e.title}</span>
{e.meta?.status && (
<span className={`badge ${deployBadge[String(e.meta.status)] ?? 'badge-gray'}`}>
{String(e.meta.status)}
</span>
)}
{typeof e.meta?.amount === 'number' && (
<span className="text-xs text-gray-500" dir="ltr">{money(e.meta.amount)}</span>
)}
</div>
<time className="text-xs text-gray-400">
{new Date(e.at).toLocaleString(locale)}
</time>
</li>
))}
</ol>
)
}
</TabState>
)}
{/* ── Errors ── */}
{tab === 'errors' && (
<TabState query={errors}>
{(list) =>
list.length === 0 ? (
<div className="card text-center py-10 text-sm text-gray-500">{det.noErrors}</div>
) : (
<div className="space-y-3">
{list.map((err) => (
<ErrorRow key={err.deploymentId} err={err} det={det} locale={locale} />
))}
</div>
)
}
</TabState>
)}
{/* ── Tickets ── */}
{tab === 'tickets' && (
<TabState query={tickets}>
{(list) =>
list.length === 0 ? (
<div className="card text-center py-10 text-sm text-gray-500">{det.noTickets}</div>
) : (
<div className="space-y-3">
{list.map((tk) => (
<TicketRow key={tk.id} ticket={tk} det={det} locale={locale} />
))}
</div>
)
}
</TabState>
)}
</div>
);
}
/* ── small presentational helpers ── */
function Row({ label, value, ltr }: { label: string; value: string; ltr?: boolean }) {
return (
<div className="flex items-center justify-between gap-3">
<dt className="text-gray-500">{label}</dt>
<dd className="text-gray-900 font-medium truncate" dir={ltr ? 'ltr' : undefined}>
{value}
</dd>
</div>
);
}
function StatBox({ label, value }: { label: string; value: number }) {
return (
<div className="card py-4 text-center">
<p className="text-2xl font-bold text-gray-900">{value}</p>
<p className="text-xs text-gray-500 mt-1">{label}</p>
</div>
);
}
function Th({ children }: { children: React.ReactNode }) {
return (
<th className="px-4 py-3 text-left rtl:text-right text-xs font-semibold text-gray-500 uppercase tracking-wider">
{children}
</th>
);
}
/** Renders loading / content states for a lazily-fetched tab query. */
function TabState<T>({
query,
children,
}: {
query: { isLoading: boolean; data: T | undefined };
children: (data: T) => React.ReactNode;
}) {
if (query.isLoading || !query.data) {
return (
<div className="text-center py-12">
<div className="animate-spin rounded-full h-7 w-7 border-2 border-primary-600 border-t-transparent mx-auto" />
</div>
);
}
return <>{children(query.data)}</>;
}
function remainingLabel(det: Det, iso?: string | null): { label: string; expired: boolean } | null {
if (!iso) return null;
const ms = new Date(iso).getTime() - Date.now();
if (ms <= 0) return { label: det.expired, expired: true };
const days = Math.floor(ms / 86_400_000);
if (days >= 1) return { label: det.remainingDays.replace('{n}', String(days)), expired: false };
const hours = Math.max(1, Math.ceil(ms / 3_600_000));
return { label: det.remainingHours.replace('{n}', String(hours)), expired: false };
}
function AppTable({
title,
apps,
det,
locale,
showRestore,
}: {
title: string;
apps: AdminUserApplication[];
det: Det;
locale: string;
showRestore?: boolean;
}) {
return (
<div>
<h2 className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-2">
<Server className="w-4 h-4 text-gray-500" /> {title}
<span className="text-gray-400 font-normal">({apps.length})</span>
</h2>
{apps.length === 0 ? (
<div className="card text-center py-8 text-sm text-gray-500">{det.noApps}</div>
) : (
<div className="table-wrapper">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50/80">
<tr>
<Th>{det.appName}</Th>
<Th>{det.appType}</Th>
<Th>{det.appStatus}</Th>
<Th>{det.appExpires}</Th>
<Th>{det.appCreated}</Th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{apps.map((a) => {
const rem = remainingLabel(det, a.planExpiresAt);
return (
<tr key={a.id}>
<td className="px-4 py-3 text-sm">
<div className="font-medium text-gray-900">{a.name}</div>
{a.subdomain && (
<div className="text-xs text-gray-400" dir="ltr">{a.subdomain}</div>
)}
</td>
<td className="px-4 py-3 text-sm text-gray-600">
<div>{a.productType || 'application'}</div>
<div className="text-xs text-gray-400">{a.runtime}</div>
</td>
<td className="px-4 py-3 space-y-1">
<span className={lifecycleBadge[a.lifecycleStatus ?? 'active'] ?? 'badge-gray'}>
{lookup(det.lifecycle as Record<string, string>, a.lifecycleStatus)}
</span>
{showRestore && (
<div>
<span className={`badge ${restoreBadge[a.restorable]}`}>
{lookup(det.restore as Record<string, string>, a.restorable)}
</span>
</div>
)}
</td>
<td className="px-4 py-3 text-sm">
{rem ? (
<span className={rem.expired ? 'text-red-600' : 'text-gray-700'}>{rem.label}</span>
) : (
<span className="text-gray-400"></span>
)}
</td>
<td className="px-4 py-3 text-sm text-gray-500">
{new Date(a.createdAt).toLocaleDateString(locale)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}
function ErrorRow({ err, det, locale }: { err: AdminUserError; det: Det; locale: string }) {
const [open, setOpen] = useState(false);
const logs = useQuery<AdminDeploymentLogs>({
queryKey: ['admin-deploy-logs', err.deploymentId],
queryFn: () => api.get(`/admin/deployments/${err.deploymentId}/logs`).then((r) => r.data),
enabled: open,
});
return (
<div className="card">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<AlertTriangle className="w-4 h-4 text-red-500 shrink-0" />
<span className="font-medium text-gray-900">{err.applicationName}</span>
<span className={`badge ${deployBadge[err.status] ?? 'badge-red'}`}>{err.status}</span>
<span className={`badge ${err.resolved ? 'badge-green' : 'badge-gray'}`}>
{err.resolved ? det.errorResolved : det.errorOpen}
</span>
</div>
<p className="text-sm text-gray-600 mt-1.5 break-words">
{err.errorMessage || det.noErrorMessage}
</p>
<p className="text-xs text-gray-400 mt-1">{new Date(err.createdAt).toLocaleString(locale)}</p>
</div>
<button
onClick={() => setOpen((v) => !v)}
className="btn-secondary text-xs inline-flex items-center gap-1 shrink-0"
>
{open ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
{open ? det.hideLogs : det.viewLogs}
</button>
</div>
{open && (
<div className="mt-3 pt-3 border-t border-gray-100 space-y-3">
{logs.isLoading ? (
<div className="text-center py-4">
<div className="animate-spin rounded-full h-5 w-5 border-2 border-primary-600 border-t-transparent mx-auto" />
</div>
) : (
<>
<LogBlock title={det.buildLog} content={logs.data?.buildLog} empty={det.noLog} />
<LogBlock title={det.deployLog} content={logs.data?.deployLog} empty={det.noLog} />
</>
)}
</div>
)}
</div>
);
}
function LogBlock({ title, content, empty }: { title: string; content?: string | null; empty: string }) {
return (
<div>
<p className="text-xs font-semibold text-gray-500 mb-1">{title}</p>
<pre
className="text-[11px] leading-relaxed bg-gray-900 text-gray-100 rounded-lg p-3 overflow-x-auto max-h-72 whitespace-pre-wrap"
dir="ltr"
>
{content?.trim() || empty}
</pre>
</div>
);
}
function TicketRow({ ticket, det, locale }: { ticket: AdminUserTicket; det: Det; locale: string }) {
const [open, setOpen] = useState(false);
return (
<div className="card">
<button
onClick={() => setOpen((v) => !v)}
className="w-full flex items-start justify-between gap-3 text-left rtl:text-right"
>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-gray-900 truncate">{ticket.subject}</span>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${ticketStatusBadge[ticket.status]}`}>
{lookup(det.ticketStatuses as Record<string, string>, ticket.status)}
</span>
<span className="badge badge-gray">
{lookup(det.ticketDepts as Record<string, string>, ticket.department)}
</span>
</div>
<p className="text-xs text-gray-400 mt-1">
{det.ticketMessages.replace('{n}', String(ticket.messageCount))} ·{' '}
{new Date(ticket.updatedAt).toLocaleDateString(locale)}
</p>
</div>
{open ? (
<ChevronUp className="w-4 h-4 text-gray-400 shrink-0" />
) : (
<ChevronDown className="w-4 h-4 text-gray-400 shrink-0" />
)}
</button>
{open && (
<div className="mt-3 pt-3 border-t border-gray-100 space-y-3 max-h-96 overflow-y-auto">
{ticket.messages.map((m) => {
const isStaff = m.senderRole !== 'user';
return (
<div key={m.id} className={`flex ${isStaff ? 'justify-start' : 'justify-end'}`}>
<div
className={`max-w-[85%] rounded-2xl px-3 py-2 ${
isStaff ? 'bg-blue-50 border border-blue-200' : 'bg-gray-100'
}`}
>
<p className="text-[11px] font-semibold mb-0.5 text-gray-500">
{m.senderName || '—'}
<span className="ms-1 px-1.5 py-0.5 bg-gray-200 text-gray-700 rounded text-[10px]">
{lookup(det.senderRoles as Record<string, string>, m.senderRole)}
</span>
</p>
<p className="text-sm text-gray-900 whitespace-pre-wrap">{m.message}</p>
<p className="text-[10px] text-gray-400 mt-0.5">
{new Date(m.createdAt).toLocaleString(locale)}
</p>
</div>
</div>
);
})}
</div>
)}
</div>
);
}
@@ -9,6 +9,7 @@ import { notify } from '@/lib/notify';
import type { AdminUser } from '@/types';
import { Users, Search, X, Clock, KeyRound } from 'lucide-react';
import { Select } from '@/components/ui/select';
import { Link } from '@/i18n/Link';
export default function AdminUsersPage() {
const t = useT();
@@ -250,7 +251,16 @@ export default function AdminUsersPage() {
{users.map((user) => (
<tr key={user.id} className="hover:bg-gray-50/50 transition-colors">
<td className="px-6 py-4 text-sm font-medium text-gray-900">
{user.firstName} {user.lastName}
{isAdmin ? (
<Link
href={`/dashboard/admin/users/${user.id}`}
className="text-primary-600 hover:text-primary-800 hover:underline"
>
{user.firstName} {user.lastName}
</Link>
) : (
<>{user.firstName} {user.lastName}</>
)}
</td>
<td className="px-6 py-4 text-sm text-gray-600" dir="ltr">{user.phone || '—'}</td>
<td className="px-6 py-4 text-sm text-gray-600" dir="ltr">{user.email || '—'}</td>
@@ -320,7 +330,16 @@ export default function AdminUsersPage() {
<div key={user.id} className="card space-y-3">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-gray-900">{user.firstName} {user.lastName}</h3>
{isAdmin ? (
<Link
href={`/dashboard/admin/users/${user.id}`}
className="font-semibold text-primary-600 hover:underline"
>
{user.firstName} {user.lastName}
</Link>
) : (
<h3 className="font-semibold text-gray-900">{user.firstName} {user.lastName}</h3>
)}
<p className="text-sm text-gray-500" dir="ltr">{user.phone || user.email || '—'}</p>
</div>
<span className={`badge ${user.isActive ? 'badge-green' : 'badge-red'}`}>