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,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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -14,6 +14,7 @@ import { BillingModule } from './billing/billing.module';
|
||||
import { SnapshotsModule } from './snapshots/snapshots.module';
|
||||
import { LifecycleModule } from './lifecycle/lifecycle.module';
|
||||
import { ApplicationMigrationsModule } from './application-migrations/application-migrations.module';
|
||||
import { AdminModule } from './admin/admin.module';
|
||||
import configuration from './config/configuration';
|
||||
|
||||
@Module({
|
||||
@@ -66,6 +67,7 @@ import configuration from './config/configuration';
|
||||
SnapshotsModule,
|
||||
LifecycleModule,
|
||||
ApplicationMigrationsModule,
|
||||
AdminModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -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(
|
||||
userId: string,
|
||||
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 { 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">
|
||||
{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>
|
||||
{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