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,71 @@
import {
Controller,
Get,
Param,
Query,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { AdminUsersService } from './admin-users.service';
import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
import { UserRole } from '../common/enums';
/**
* Super-admin "User Detail" dashboard API. Read-only aggregation of everything
* about a single user. ADMIN only — wallet/revenue data is too sensitive for
* technical/sales staff.
*/
@ApiTags('Admin')
@ApiBearerAuth()
@Controller('admin')
@UseGuards(AuthGuard('jwt'), RolesGuard)
@Roles(UserRole.ADMIN)
export class AdminUsersController {
constructor(private readonly adminUsers: AdminUsersService) {}
@Get('users/:id')
@ApiOperation({ summary: 'User overview: profile, status, wallet, revenue, counts' })
getOverview(@Param('id') id: string) {
return this.adminUsers.getOverview(id);
}
@Get('users/:id/wallet/transactions')
@ApiOperation({ summary: 'Wallet transaction history' })
@ApiQuery({ name: 'limit', required: false })
getTransactions(@Param('id') id: string, @Query('limit') limit?: string) {
const n = limit ? Math.min(Math.max(parseInt(limit, 10) || 50, 1), 200) : 50;
return this.adminUsers.getTransactions(id, n);
}
@Get('users/:id/applications')
@ApiOperation({ summary: 'All applications (incl. deleted/docked) with restore status' })
getApplications(@Param('id') id: string) {
return this.adminUsers.getApplications(id);
}
@Get('users/:id/errors')
@ApiOperation({ summary: 'Build/deploy failures across the user applications' })
getErrors(@Param('id') id: string) {
return this.adminUsers.getErrors(id);
}
@Get('users/:id/tickets')
@ApiOperation({ summary: 'Support tickets with conversation' })
getTickets(@Param('id') id: string) {
return this.adminUsers.getTickets(id);
}
@Get('users/:id/activity')
@ApiOperation({ summary: 'Composite activity timeline' })
getActivity(@Param('id') id: string) {
return this.adminUsers.getActivity(id);
}
@Get('deployments/:deploymentId/logs')
@ApiOperation({ summary: 'Build/deploy logs for a single deployment' })
getDeploymentLogs(@Param('deploymentId') deploymentId: string) {
return this.adminUsers.getDeploymentLogs(deploymentId);
}
}
+333
View File
@@ -0,0 +1,333 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { UsersService } from '../users/users.service';
import { BillingService } from '../billing/billing.service';
import { ApplicationsService } from '../applications/applications.service';
import { TicketsService } from '../tickets/tickets.service';
import { DeploymentsService } from '../deployments/deployments.service';
import {
AppLifecycleStatus,
DeploymentStatus,
ProductType,
TicketStatus,
} from '../common/enums';
import { Application } from '../applications/entities/application.entity';
import { Ticket } from '../tickets/entities/ticket.entity';
/** How a non-active application can be brought back, for the admin UI. */
type RestoreEligibility = 'restorable' | 'recoverable' | 'none';
/**
* Read-only aggregation for the super-admin "User Detail" dashboard. Pulls
* everything about a single user from the existing domain services so the panel
* can show profile, wallet, apps, errors, tickets and an activity timeline —
* without duplicating per-module endpoints.
*/
@Injectable()
export class AdminUsersService {
constructor(
private readonly usersService: UsersService,
private readonly billingService: BillingService,
private readonly applicationsService: ApplicationsService,
private readonly ticketsService: TicketsService,
private readonly deploymentsService: DeploymentsService,
) {}
/** Overview tab: profile + account status + wallet + revenue + summary counts. */
async getOverview(userId: string) {
const user = await this.usersService.findById(userId);
if (!user) {
throw new NotFoundException('User not found');
}
const { password, ...profile } = user;
// Ensure the wallet row exists before the parallel reads below — otherwise
// getBalance and getRevenueSummary both lazily create it at once and race on
// the unique (userId) constraint.
await this.billingService.getOrCreateWallet(userId);
const [balance, revenue, apps, tickets, recentTx] = await Promise.all([
this.billingService.getBalance(userId),
this.billingService.getRevenueSummary(userId),
this.applicationsService.findAllByUser(userId),
this.ticketsService.findMyTickets(userId),
this.billingService.getTransactions(userId, 10),
]);
return {
profile,
wallet: { balance: balance.balance },
revenue,
counts: this.computeCounts(apps, tickets),
recentTransactions: recentTx.map((t) => this.txPublic(t)),
};
}
/** Wallet transaction history. */
async getTransactions(userId: string, limit = 50) {
await this.assertUserExists(userId);
const txs = await this.billingService.getTransactions(userId, limit);
return txs.map((t) => this.txPublic(t));
}
/** All applications including deleted/docked ones, with restore eligibility. */
async getApplications(userId: string) {
await this.assertUserExists(userId);
const apps = await this.applicationsService.findAllByUser(userId);
return apps.map((a) => this.appPublic(a));
}
/** Build/deploy failures across all of the user's applications. */
async getErrors(userId: string) {
await this.assertUserExists(userId);
const apps = await this.applicationsService.findAllByUser(userId);
const failures: Array<Record<string, unknown>> = [];
for (const app of apps) {
const deps = [...(app.deployments ?? [])].sort(
(a, b) => this.time(b.createdAt) - this.time(a.createdAt),
);
for (const d of deps) {
if (
d.status !== DeploymentStatus.BUILD_FAILED &&
d.status !== DeploymentStatus.FAILED
) {
continue;
}
// Resolved once a later successful (running) deployment exists for the app.
const resolved = deps.some(
(o) =>
o.status === DeploymentStatus.RUNNING &&
this.time(o.createdAt) > this.time(d.createdAt),
);
failures.push({
deploymentId: d.id,
applicationId: app.id,
applicationName: app.name,
status: d.status,
errorMessage: d.errorMessage ?? null,
createdAt: d.createdAt,
finishedAt: d.finishedAt ?? null,
resolved,
});
}
}
return failures.sort(
(a, b) => this.time(b.createdAt as Date) - this.time(a.createdAt as Date),
);
}
/** Build/deploy logs for a single deployment (admin can read any user's). */
async getDeploymentLogs(deploymentId: string) {
const d = await this.deploymentsService.findOne(deploymentId);
return {
id: d.id,
status: d.status,
errorMessage: d.errorMessage ?? null,
buildLog: d.buildLog ?? null,
deployLog: d.deployLog ?? null,
createdAt: d.createdAt,
finishedAt: d.finishedAt ?? null,
};
}
/** Support tickets with their full conversation (sensitive sender data stripped). */
async getTickets(userId: string) {
await this.assertUserExists(userId);
const tickets = await this.ticketsService.findMyTickets(userId);
return tickets.map((t) => ({
id: t.id,
subject: t.subject,
department: t.department,
status: t.status,
priority: t.priority,
createdAt: t.createdAt,
updatedAt: t.updatedAt,
closedAt: t.closedAt ?? null,
messageCount: t.messages?.length ?? 0,
messages: [...(t.messages ?? [])]
.sort((a, b) => this.time(a.createdAt) - this.time(b.createdAt))
.map((m) => ({
id: m.id,
message: m.message,
senderRole: m.senderRole,
senderName: m.sender
? `${m.sender.firstName ?? ''} ${m.sender.lastName ?? ''}`.trim()
: null,
createdAt: m.createdAt,
})),
}));
}
/** Composite activity timeline built from existing data (works retroactively). */
async getActivity(userId: string) {
await this.assertUserExists(userId);
const [apps, txs, tickets] = await Promise.all([
this.applicationsService.findAllByUser(userId),
this.billingService.getTransactions(userId, 100),
this.ticketsService.findMyTickets(userId),
]);
const events: Array<{
type: string;
title: string;
at: Date;
meta: Record<string, unknown>;
}> = [];
for (const a of apps) {
events.push({
type: 'app_created',
title: a.name,
at: a.createdAt,
meta: { productType: a.productType },
});
if (a.dockedAt) {
events.push({ type: 'app_docked', title: a.name, at: a.dockedAt, meta: {} });
}
if (a.lifecycleStatus === AppLifecycleStatus.DELETED && a.scheduledDeletionAt) {
events.push({
type: 'app_deleted',
title: a.name,
at: a.scheduledDeletionAt,
meta: {},
});
}
for (const d of a.deployments ?? []) {
events.push({
type: 'deployment',
title: a.name,
at: d.createdAt,
meta: { status: d.status },
});
}
}
for (const t of txs) {
events.push({
type: 'transaction',
title: t.description || t.type,
at: t.createdAt,
meta: { txType: t.type, amount: Number(t.amount) },
});
}
for (const t of tickets) {
events.push({
type: 'ticket',
title: t.subject,
at: t.createdAt,
meta: { status: t.status, department: t.department },
});
}
return events
.sort((a, b) => this.time(b.at) - this.time(a.at))
.slice(0, 100);
}
// ── helpers ──────────────────────────────────────────
private async assertUserExists(userId: string): Promise<void> {
const user = await this.usersService.findById(userId);
if (!user) {
throw new NotFoundException('User not found');
}
}
private computeCounts(apps: Application[], tickets: Ticket[]) {
const isManaged = (a: Application) =>
!!a.productType && a.productType !== ProductType.APPLICATION;
return {
appsTotal: apps.length,
appsActive: apps.filter((a) => a.lifecycleStatus === AppLifecycleStatus.ACTIVE)
.length,
appsDeleted: apps.filter(
(a) =>
a.lifecycleStatus === AppLifecycleStatus.DELETED ||
a.lifecycleStatus === AppLifecycleStatus.DOCKED,
).length,
managedServices: apps.filter(isManaged).length,
optionalServices: apps.reduce(
(n, a) =>
n +
(a.enableRedis ? 1 : 0) +
(a.enableRabbitmq ? 1 : 0) +
(a.enableElasticsearch ? 1 : 0),
0,
),
deployments: apps.reduce((n, a) => n + (a.deployments?.length ?? 0), 0),
ticketsTotal: tickets.length,
ticketsOpen: tickets.filter((t) => t.status !== TicketStatus.CLOSED).length,
};
}
private appPublic(a: Application) {
// findAllByUser orders deployments newest-first, so [0] is the latest.
const latest = a.deployments?.[0];
return {
id: a.id,
name: a.name,
productType: a.productType,
runtime: a.runtime,
databaseType: a.databaseType,
lifecycleStatus: a.lifecycleStatus,
billingCycle: a.billingCycle ?? null,
subdomain: a.subdomain ?? null,
customDomain: a.customDomain ?? null,
enableRedis: a.enableRedis,
enableRabbitmq: a.enableRabbitmq,
enableElasticsearch: a.enableElasticsearch,
cpuLimit: a.cpuLimit,
memoryLimit: a.memoryLimit,
replicas: a.replicas,
createdAt: a.createdAt,
planExpiresAt: a.planExpiresAt ?? null,
scheduledDeletionAt: a.scheduledDeletionAt ?? null,
dockedAt: a.dockedAt ?? null,
restorable: this.restoreEligibility(a),
latestDeployment: latest
? { id: latest.id, status: latest.status, createdAt: latest.createdAt }
: null,
};
}
private restoreEligibility(a: Application): RestoreEligibility {
if (a.lifecycleStatus === AppLifecycleStatus.DOCKED && a.dockSnapshotId) {
return 'restorable';
}
if (
a.lifecycleStatus === AppLifecycleStatus.SUSPENDED ||
a.lifecycleStatus === AppLifecycleStatus.PENDING_DELETION
) {
return 'recoverable';
}
return 'none';
}
private txPublic(t: {
id: string;
type: unknown;
amount: number;
balanceAfter: number;
description?: string | null;
applicationId?: string | null;
invoiceId?: string | null;
createdAt: Date;
}) {
return {
id: t.id,
type: t.type,
amount: Number(t.amount),
balanceAfter: Number(t.balanceAfter),
description: t.description ?? null,
applicationId: t.applicationId ?? null,
invoiceId: t.invoiceId ?? null,
createdAt: t.createdAt,
};
}
private time(d: Date | string): number {
return new Date(d).getTime();
}
}
+26
View File
@@ -0,0 +1,26 @@
import { Module } from '@nestjs/common';
import { AdminUsersController } from './admin-users.controller';
import { AdminUsersService } from './admin-users.service';
import { UsersModule } from '../users/users.module';
import { BillingModule } from '../billing/billing.module';
import { ApplicationsModule } from '../applications/applications.module';
import { DeploymentsModule } from '../deployments/deployments.module';
import { TicketsModule } from '../tickets/tickets.module';
/**
* Super-admin aggregation module. Imports the domain modules (which export
* their services) so the admin controller can read everything about a user
* without re-implementing per-module logic.
*/
@Module({
imports: [
UsersModule,
BillingModule,
ApplicationsModule,
DeploymentsModule,
TicketsModule,
],
controllers: [AdminUsersController],
providers: [AdminUsersService],
})
export class AdminModule {}
+2
View File
@@ -14,6 +14,7 @@ import { BillingModule } from './billing/billing.module';
import { SnapshotsModule } from './snapshots/snapshots.module'; import { SnapshotsModule } from './snapshots/snapshots.module';
import { LifecycleModule } from './lifecycle/lifecycle.module'; import { LifecycleModule } from './lifecycle/lifecycle.module';
import { ApplicationMigrationsModule } from './application-migrations/application-migrations.module'; import { ApplicationMigrationsModule } from './application-migrations/application-migrations.module';
import { AdminModule } from './admin/admin.module';
import configuration from './config/configuration'; import configuration from './config/configuration';
@Module({ @Module({
@@ -66,6 +67,7 @@ import configuration from './config/configuration';
SnapshotsModule, SnapshotsModule,
LifecycleModule, LifecycleModule,
ApplicationMigrationsModule, ApplicationMigrationsModule,
AdminModule,
], ],
}) })
export class AppModule {} export class AppModule {}
+29
View File
@@ -167,6 +167,35 @@ export class BillingService {
}); });
} }
/**
* Aggregate wallet movement for a user, grouped by transaction type. Used by
* the admin user-detail dashboard. "revenue" is what the user actually spent
* on services (deductions + direct gateway payments) — i.e. platform income —
* as opposed to "charged" which is just money topped up into the wallet.
*/
async getRevenueSummary(
userId: string,
): Promise<{ revenue: number; charged: number; refunded: number }> {
const wallet = await this.getOrCreateWallet(userId);
const rows = await this.txRepo
.createQueryBuilder('tx')
.select('tx.type', 'type')
.addSelect('COALESCE(SUM(tx.amount), 0)', 'total')
.where('tx.walletId = :walletId', { walletId: wallet.id })
.groupBy('tx.type')
.getRawMany<{ type: TransactionType; total: string }>();
const totals = new Map(rows.map((r) => [r.type, Number(r.total)]));
const revenue =
(totals.get(TransactionType.DEDUCTION) ?? 0) +
(totals.get(TransactionType.GATEWAY_PAYMENT) ?? 0);
return {
revenue,
charged: totals.get(TransactionType.CHARGE) ?? 0,
refunded: totals.get(TransactionType.REFUND) ?? 0,
};
}
async recordGatewayPayment( async recordGatewayPayment(
userId: string, userId: string,
amount: number, amount: number,
@@ -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 type { AdminUser } from '@/types';
import { Users, Search, X, Clock, KeyRound } from 'lucide-react'; import { Users, Search, X, Clock, KeyRound } from 'lucide-react';
import { Select } from '@/components/ui/select'; import { Select } from '@/components/ui/select';
import { Link } from '@/i18n/Link';
export default function AdminUsersPage() { export default function AdminUsersPage() {
const t = useT(); const t = useT();
@@ -250,7 +251,16 @@ export default function AdminUsersPage() {
{users.map((user) => ( {users.map((user) => (
<tr key={user.id} className="hover:bg-gray-50/50 transition-colors"> <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"> <td className="px-6 py-4 text-sm font-medium text-gray-900">
{isAdmin ? (
<Link
href={`/dashboard/admin/users/${user.id}`}
className="text-primary-600 hover:text-primary-800 hover:underline"
>
{user.firstName} {user.lastName} {user.firstName} {user.lastName}
</Link>
) : (
<>{user.firstName} {user.lastName}</>
)}
</td> </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.phone || '—'}</td>
<td className="px-6 py-4 text-sm text-gray-600" dir="ltr">{user.email || '—'}</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 key={user.id} className="card space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
{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> <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> <p className="text-sm text-gray-500" dir="ltr">{user.phone || user.email || '—'}</p>
</div> </div>
<span className={`badge ${user.isActive ? 'badge-green' : 'badge-red'}`}> <span className={`badge ${user.isActive ? 'badge-green' : 'badge-red'}`}>
+76
View File
@@ -794,6 +794,82 @@ const en: Dictionary = {
roleUpdated: 'Role updated', roleUpdated: 'Role updated',
passwordUpdated: 'Password updated', passwordUpdated: 'Password updated',
passwordFailed: 'Failed to update password', 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: { pools: {
title: 'Cluster Pools', title: 'Cluster Pools',
+76
View File
@@ -793,6 +793,82 @@ const fa = {
roleUpdated: 'نقش به‌روزرسانی شد', roleUpdated: 'نقش به‌روزرسانی شد',
passwordUpdated: 'رمز عبور به‌روزرسانی شد', passwordUpdated: 'رمز عبور به‌روزرسانی شد',
passwordFailed: 'به‌روزرسانی رمز ناموفق بود', 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: { pools: {
title: 'پول‌های کلاستر', title: 'پول‌های کلاستر',
+137 -1
View File
@@ -172,7 +172,7 @@ export type DeploymentStatus =
| 'stopped' | 'stopped'
| 'deleting'; | 'deleting';
export type AppLifecycleStatus = 'active' | 'suspended' | 'pending_deletion' | 'deleted'; export type AppLifecycleStatus = 'active' | 'suspended' | 'pending_deletion' | 'docked' | 'deleted';
export interface ResourceCredit { export interface ResourceCredit {
id: string; id: string;
@@ -432,6 +432,142 @@ export interface AdminUser extends User {
appCount?: number; 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 { export interface ClusterNode {
name: string; name: string;
status: string; status: string;