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:
@@ -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'}`}>
|
||||
|
||||
@@ -794,6 +794,82 @@ const en: Dictionary = {
|
||||
roleUpdated: 'Role updated',
|
||||
passwordUpdated: 'Password updated',
|
||||
passwordFailed: 'Failed to update password',
|
||||
detail: {
|
||||
back: 'Back',
|
||||
notFound: 'User not found',
|
||||
loading: 'Loading…',
|
||||
viewDetails: 'View details',
|
||||
tabOverview: 'Overview',
|
||||
tabApplications: 'Applications',
|
||||
tabActivity: 'Activity',
|
||||
tabErrors: 'Errors',
|
||||
tabTickets: 'Tickets',
|
||||
accountStatus: 'Account status',
|
||||
basicInfo: 'Basic info',
|
||||
walletBalance: 'Wallet balance',
|
||||
revenue: 'Revenue generated',
|
||||
totalCharged: 'Total charged',
|
||||
totalRefunded: 'Total refunded',
|
||||
joinedAt: 'Joined',
|
||||
namespace: 'Namespace',
|
||||
recentTransactions: 'Recent transactions',
|
||||
noTransactions: 'No transactions yet.',
|
||||
countsTitle: 'Summary',
|
||||
appsTotal: 'Total applications',
|
||||
appsActive: 'Active apps',
|
||||
appsDeleted: 'Deleted / archived apps',
|
||||
managedServices: 'Managed services',
|
||||
optionalServices: 'Add-on services',
|
||||
deploymentsCount: 'Deployments',
|
||||
ticketsOpenCount: 'Open tickets',
|
||||
txType: 'Type',
|
||||
txAmount: 'Amount',
|
||||
txBalance: 'Balance after',
|
||||
txDesc: 'Description',
|
||||
txDate: 'Date',
|
||||
txTypes: { charge: 'Charge', deduction: 'Deduction', refund: 'Refund', gateway_payment: 'Gateway payment' },
|
||||
appName: 'Name',
|
||||
appType: 'Type',
|
||||
appStatus: 'Status',
|
||||
appExpires: 'Service expiry',
|
||||
appCreated: 'Created',
|
||||
appResources: 'Resources',
|
||||
activeApps: 'Active apps',
|
||||
deletedApps: 'Deleted / archived apps',
|
||||
noApps: 'No applications.',
|
||||
expired: 'Expired',
|
||||
remainingDays: '{n} days left',
|
||||
remainingHours: '{n} hours left',
|
||||
lifecycle: { active: 'Active', suspended: 'Suspended', pending_deletion: 'Pending deletion', docked: 'Archived', deleted: 'Deleted' },
|
||||
restore: { restorable: 'Restorable', recoverable: 'Recoverable with payment', none: 'Not restorable' },
|
||||
errorApp: 'Application',
|
||||
errorReason: 'Error reason',
|
||||
errorTime: 'Time',
|
||||
errorState: 'State',
|
||||
errorResolved: 'Resolved',
|
||||
errorOpen: 'Open',
|
||||
noErrors: 'No build/deploy errors.',
|
||||
noErrorMessage: 'No error message recorded.',
|
||||
viewLogs: 'View logs',
|
||||
hideLogs: 'Hide logs',
|
||||
buildLog: 'Build log',
|
||||
deployLog: 'Deploy log',
|
||||
noLog: 'No log available.',
|
||||
noActivity: 'No activity recorded.',
|
||||
activityTypes: { app_created: 'Application created', app_docked: 'Application archived', app_deleted: 'Application deleted', deployment: 'Deployment', transaction: 'Wallet transaction', ticket: 'Support ticket' },
|
||||
ticketSubject: 'Subject',
|
||||
ticketStatus: 'Status',
|
||||
ticketDept: 'Department',
|
||||
ticketPriority: 'Priority',
|
||||
ticketMessages: '{n} messages',
|
||||
noTickets: 'No tickets.',
|
||||
viewConversation: 'View conversation',
|
||||
hideConversation: 'Hide conversation',
|
||||
ticketStatuses: { open: 'Open', answered: 'Answered', waiting: 'Waiting', closed: 'Closed' },
|
||||
ticketDepts: { technical: 'Technical', sales: 'Sales' },
|
||||
ticketPriorities: { low: 'Low', medium: 'Medium', high: 'High' },
|
||||
senderRoles: { user: 'User', admin: 'Admin', technical: 'Technical support', sales: 'Sales' },
|
||||
},
|
||||
},
|
||||
pools: {
|
||||
title: 'Cluster Pools',
|
||||
|
||||
@@ -793,6 +793,82 @@ const fa = {
|
||||
roleUpdated: 'نقش بهروزرسانی شد',
|
||||
passwordUpdated: 'رمز عبور بهروزرسانی شد',
|
||||
passwordFailed: 'بهروزرسانی رمز ناموفق بود',
|
||||
detail: {
|
||||
back: 'بازگشت',
|
||||
notFound: 'کاربر پیدا نشد',
|
||||
loading: 'در حال بارگذاری…',
|
||||
viewDetails: 'مشاهدهٔ جزئیات',
|
||||
tabOverview: 'نمای کلی',
|
||||
tabApplications: 'اپلیکیشنها',
|
||||
tabActivity: 'تاریخچه فعالیت',
|
||||
tabErrors: 'خطاها',
|
||||
tabTickets: 'تیکتها',
|
||||
accountStatus: 'وضعیت حساب',
|
||||
basicInfo: 'اطلاعات پایه',
|
||||
walletBalance: 'موجودی کیف پول',
|
||||
revenue: 'درآمد ایجادشده',
|
||||
totalCharged: 'مجموع شارژ',
|
||||
totalRefunded: 'مجموع بازگشت',
|
||||
joinedAt: 'تاریخ عضویت',
|
||||
namespace: 'فضاینام',
|
||||
recentTransactions: 'آخرین تراکنشها',
|
||||
noTransactions: 'تراکنشی ثبت نشده.',
|
||||
countsTitle: 'آمار خلاصه',
|
||||
appsTotal: 'کل اپلیکیشنها',
|
||||
appsActive: 'اپهای فعال',
|
||||
appsDeleted: 'اپهای حذف/بایگانیشده',
|
||||
managedServices: 'سرویسهای مدیریتشده',
|
||||
optionalServices: 'سرویسهای جانبی',
|
||||
deploymentsCount: 'دیپلویها',
|
||||
ticketsOpenCount: 'تیکتهای باز',
|
||||
txType: 'نوع',
|
||||
txAmount: 'مبلغ',
|
||||
txBalance: 'موجودی پس از',
|
||||
txDesc: 'توضیح',
|
||||
txDate: 'تاریخ',
|
||||
txTypes: { charge: 'شارژ', deduction: 'کسر', refund: 'بازگشت', gateway_payment: 'پرداخت درگاه' },
|
||||
appName: 'نام',
|
||||
appType: 'نوع',
|
||||
appStatus: 'وضعیت',
|
||||
appExpires: 'انقضای سرویس',
|
||||
appCreated: 'تاریخ ایجاد',
|
||||
appResources: 'منابع',
|
||||
activeApps: 'اپهای فعال',
|
||||
deletedApps: 'اپهای حذف/بایگانیشده',
|
||||
noApps: 'اپلیکیشنی نیست.',
|
||||
expired: 'منقضیشده',
|
||||
remainingDays: '{n} روز باقیمانده',
|
||||
remainingHours: '{n} ساعت باقیمانده',
|
||||
lifecycle: { active: 'فعال', suspended: 'معلق', pending_deletion: 'در انتظار حذف', docked: 'بایگانیشده', deleted: 'حذفشده' },
|
||||
restore: { restorable: 'قابل بازگردانی', recoverable: 'با پرداخت قابل احیا', none: 'غیرقابل بازگردانی' },
|
||||
errorApp: 'اپلیکیشن',
|
||||
errorReason: 'علت خطا',
|
||||
errorTime: 'زمان',
|
||||
errorState: 'وضعیت',
|
||||
errorResolved: 'حلشده',
|
||||
errorOpen: 'باز',
|
||||
noErrors: 'خطای Build/Deploy ثبت نشده.',
|
||||
noErrorMessage: 'پیام خطایی ثبت نشده.',
|
||||
viewLogs: 'مشاهدهٔ لاگ',
|
||||
hideLogs: 'بستن لاگ',
|
||||
buildLog: 'لاگ Build',
|
||||
deployLog: 'لاگ Deploy',
|
||||
noLog: 'لاگی موجود نیست.',
|
||||
noActivity: 'فعالیتی ثبت نشده.',
|
||||
activityTypes: { app_created: 'ساخت اپلیکیشن', app_docked: 'بایگانی اپلیکیشن', app_deleted: 'حذف اپلیکیشن', deployment: 'دیپلوی', transaction: 'تراکنش کیف پول', ticket: 'تیکت پشتیبانی' },
|
||||
ticketSubject: 'موضوع',
|
||||
ticketStatus: 'وضعیت',
|
||||
ticketDept: 'دپارتمان',
|
||||
ticketPriority: 'اولویت',
|
||||
ticketMessages: '{n} پیام',
|
||||
noTickets: 'تیکتی ثبت نشده.',
|
||||
viewConversation: 'مشاهدهٔ گفتگو',
|
||||
hideConversation: 'بستن گفتگو',
|
||||
ticketStatuses: { open: 'باز', answered: 'پاسخ دادهشده', waiting: 'در انتظار پاسخ', closed: 'بسته' },
|
||||
ticketDepts: { technical: 'فنی', sales: 'فروش' },
|
||||
ticketPriorities: { low: 'کم', medium: 'متوسط', high: 'زیاد' },
|
||||
senderRoles: { user: 'کاربر', admin: 'مدیر', technical: 'پشتیبان فنی', sales: 'کارشناس فروش' },
|
||||
},
|
||||
},
|
||||
pools: {
|
||||
title: 'پولهای کلاستر',
|
||||
|
||||
+137
-1
@@ -172,7 +172,7 @@ export type DeploymentStatus =
|
||||
| 'stopped'
|
||||
| 'deleting';
|
||||
|
||||
export type AppLifecycleStatus = 'active' | 'suspended' | 'pending_deletion' | 'deleted';
|
||||
export type AppLifecycleStatus = 'active' | 'suspended' | 'pending_deletion' | 'docked' | 'deleted';
|
||||
|
||||
export interface ResourceCredit {
|
||||
id: string;
|
||||
@@ -432,6 +432,142 @@ export interface AdminUser extends User {
|
||||
appCount?: number;
|
||||
}
|
||||
|
||||
// ─── Admin User Detail dashboard ────────────────────
|
||||
|
||||
export interface AdminUserProfile {
|
||||
id: string;
|
||||
phone?: string | null;
|
||||
email: string | null;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
role: 'user' | 'admin' | 'technical' | 'sales';
|
||||
isActive: boolean;
|
||||
phoneVerified?: boolean;
|
||||
namespace?: string;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface AdminUserCounts {
|
||||
appsTotal: number;
|
||||
appsActive: number;
|
||||
appsDeleted: number;
|
||||
managedServices: number;
|
||||
optionalServices: number;
|
||||
deployments: number;
|
||||
ticketsTotal: number;
|
||||
ticketsOpen: number;
|
||||
}
|
||||
|
||||
export interface AdminRevenueSummary {
|
||||
/** What the user actually spent on services (platform income). */
|
||||
revenue: number;
|
||||
/** Total topped up into the wallet. */
|
||||
charged: number;
|
||||
refunded: number;
|
||||
}
|
||||
|
||||
export interface AdminUserTransaction {
|
||||
id: string;
|
||||
type: TransactionType;
|
||||
amount: number;
|
||||
balanceAfter: number;
|
||||
description?: string | null;
|
||||
applicationId?: string | null;
|
||||
invoiceId?: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AdminUserOverview {
|
||||
profile: AdminUserProfile;
|
||||
wallet: { balance: number };
|
||||
revenue: AdminRevenueSummary;
|
||||
counts: AdminUserCounts;
|
||||
recentTransactions: AdminUserTransaction[];
|
||||
}
|
||||
|
||||
export type RestoreEligibility = 'restorable' | 'recoverable' | 'none';
|
||||
|
||||
export interface AdminUserApplication {
|
||||
id: string;
|
||||
name: string;
|
||||
productType?: ProductType;
|
||||
runtime: string;
|
||||
databaseType: string;
|
||||
lifecycleStatus?: AppLifecycleStatus;
|
||||
billingCycle?: BillingCycle | null;
|
||||
subdomain?: string | null;
|
||||
customDomain?: string | null;
|
||||
enableRedis: boolean;
|
||||
enableRabbitmq: boolean;
|
||||
enableElasticsearch: boolean;
|
||||
cpuLimit: string;
|
||||
memoryLimit: string;
|
||||
replicas: number;
|
||||
createdAt: string;
|
||||
planExpiresAt?: string | null;
|
||||
scheduledDeletionAt?: string | null;
|
||||
dockedAt?: string | null;
|
||||
restorable: RestoreEligibility;
|
||||
latestDeployment?: { id: string; status: DeploymentStatus; createdAt: string } | null;
|
||||
}
|
||||
|
||||
export interface AdminUserError {
|
||||
deploymentId: string;
|
||||
applicationId: string;
|
||||
applicationName: string;
|
||||
status: DeploymentStatus;
|
||||
errorMessage?: string | null;
|
||||
createdAt: string;
|
||||
finishedAt?: string | null;
|
||||
resolved: boolean;
|
||||
}
|
||||
|
||||
export interface AdminDeploymentLogs {
|
||||
id: string;
|
||||
status: DeploymentStatus;
|
||||
errorMessage?: string | null;
|
||||
buildLog?: string | null;
|
||||
deployLog?: string | null;
|
||||
createdAt: string;
|
||||
finishedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface AdminUserTicketMessage {
|
||||
id: string;
|
||||
message: string;
|
||||
senderRole: 'user' | 'admin' | 'technical' | 'sales';
|
||||
senderName?: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AdminUserTicket {
|
||||
id: string;
|
||||
subject: string;
|
||||
department: TicketDepartment;
|
||||
status: TicketStatus;
|
||||
priority: TicketPriority;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
closedAt?: string | null;
|
||||
messageCount: number;
|
||||
messages: AdminUserTicketMessage[];
|
||||
}
|
||||
|
||||
export interface AdminActivityEvent {
|
||||
type:
|
||||
| 'app_created'
|
||||
| 'app_docked'
|
||||
| 'app_deleted'
|
||||
| 'deployment'
|
||||
| 'transaction'
|
||||
| 'ticket'
|
||||
| string;
|
||||
title: string;
|
||||
at: string;
|
||||
meta: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ClusterNode {
|
||||
name: string;
|
||||
status: string;
|
||||
|
||||
Reference in New Issue
Block a user