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'}`}>
|
||||
|
||||
Reference in New Issue
Block a user