Files
carmanagement/apps/admin/src/app/dashboard/page.tsx
T
root 7ff2dbb139
Build & Deploy / Build & Push Docker Image (push) Successful in 2m55s
Test / Type Check (all packages) (push) Successful in 54s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Failing after 48s
Test / Homepage Unit Tests (push) Successful in 43s
Test / Storefront Unit Tests (push) Failing after 40s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Failing after 42s
Test / API Integration Tests (push) Successful in 1m0s
chore: wire Carplace into dev and production stacks
2026-07-02 18:15:42 -04:00

246 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { Activity, ArrowRight, Building2, ClipboardList, ShieldCheck, Users, WalletCards } from 'lucide-react'
import { useAdminI18n } from '@/components/I18nProvider'
import { ADMIN_API_BASE } from '@/lib/api'
import { canAccessAdminMetrics, useAdminSession } from './AdminSessionContext'
interface Metrics {
totalCompanies: number
activeCompanies: number
totalRenters: number
totalReservations: number
mrr?: number
}
export default function AdminDashboardPage() {
const { language } = useAdminI18n()
const admin = useAdminSession()
const copy = {
en: {
kpis: ['Total companies', 'Active companies', 'Total renters', 'Total reservations'],
brand: 'RentalDriveGo',
eyebrow: 'Operations command',
platformOverview: 'RentalDriveGo admin dashboard',
subtitle: 'Monitor Carplace health, subscription coverage, renter activity, and operator actions from one control surface.',
liveSignal: 'Live platform signal',
readiness: 'Operational readiness',
readinessBody: 'Core admin workflows are connected and ready for platform support.',
quickActions: 'Quick actions',
mrr: 'Monthly recurring revenue',
noMetrics: 'Metrics are limited for this admin role.',
cards: [
['Companies', 'Search operators, review subscription state, and manage account status.', 'View all'],
['Renters', 'Support renter trust workflows, blocking, and account recovery.', 'View all'],
['Audit logs', 'Trace every sensitive admin action across RentalDriveGo.', 'View logs'],
],
health: ['Tenant accounts', 'Carplace identity', 'Admin audit trail'],
},
fr: {
kpis: ['Entreprises totales', 'Entreprises actives', 'Locataires totaux', 'Réservations totales'],
brand: 'RentalDriveGo',
eyebrow: 'Centre des opérations',
platformOverview: 'Tableau de bord admin RentalDriveGo',
subtitle: 'Suivez la santé de la Carplace, les abonnements, lactivité des locataires et les actions opérateur depuis une même interface.',
liveSignal: 'Signal plateforme en direct',
readiness: 'Disponibilité opérationnelle',
readinessBody: 'Les principaux workflows admin sont connectés et prêts pour le support plateforme.',
quickActions: 'Actions rapides',
mrr: 'Revenu mensuel récurrent',
noMetrics: 'Les métriques sont limitées pour ce rôle admin.',
cards: [
['Entreprises', 'Rechercher les opérateurs, vérifier les abonnements et gérer les statuts.', 'Voir tout'],
['Locataires', 'Gérer les workflows de confiance, de blocage et de récupération.', 'Voir tout'],
['Journaux daudit', 'Tracer chaque action admin sensible dans RentalDriveGo.', 'Voir les journaux'],
],
health: ['Comptes locataires', 'Identité Carplace', 'Piste daudit admin'],
},
ar: {
kpis: ['إجمالي الشركات', 'الشركات النشطة', 'إجمالي المستأجرين', 'إجمالي الحجوزات'],
brand: 'RentalDriveGo',
eyebrow: 'مركز العمليات',
platformOverview: 'لوحة إدارة RentalDriveGo',
subtitle: 'راقب صحة السوق وحالة الاشتراكات ونشاط المستأجرين وإجراءات الإدارة من مساحة واحدة.',
liveSignal: 'مؤشر المنصة المباشر',
readiness: 'جاهزية التشغيل',
readinessBody: 'مسارات الإدارة الأساسية متصلة وجاهزة لدعم المنصة.',
quickActions: 'إجراءات سريعة',
mrr: 'الإيراد الشهري المتكرر',
noMetrics: 'المؤشرات محدودة لهذا الدور الإداري.',
cards: [
['الشركات', 'ابحث عن المشغلين وراجع الاشتراكات وأدر حالة الحساب.', 'عرض الكل'],
['المستأجرون', 'إدارة الثقة والحظر واستعادة الحسابات للمستأجرين.', 'عرض الكل'],
['سجلات التدقيق', 'تتبع كل إجراء إداري حساس داخل RentalDriveGo.', 'عرض السجلات'],
],
health: ['حسابات الشركات', 'هوية السوق', 'سجل تدقيق الإدارة'],
},
}[language]
const [metrics, setMetrics] = useState<Metrics | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
if (!canAccessAdminMetrics(admin)) {
setMetrics(null)
setLoading(false)
return
}
fetch(`${ADMIN_API_BASE}/admin/metrics`, {
credentials: 'include',
cache: 'no-store',
})
.then(async (response) => {
const json = await response.json().catch(() => null)
if (!response.ok) {
setMetrics(null)
return
}
setMetrics((json?.data ?? json) as Metrics)
})
.catch(() => null)
.finally(() => setLoading(false))
}, [admin])
const numberFormatter = new Intl.NumberFormat(language === 'ar' ? 'ar-MA' : language === 'fr' ? 'fr-FR' : 'en-US')
const currencyFormatter = new Intl.NumberFormat(language === 'ar' ? 'ar-MA' : language === 'fr' ? 'fr-FR' : 'en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0,
})
const kpis = metrics
? [
{ label: copy.kpis[0], value: metrics.totalCompanies, icon: Building2, tone: 'text-blue-600 dark:text-blue-300', bg: 'bg-blue-50 dark:bg-blue-500/10' },
{ label: copy.kpis[1], value: metrics.activeCompanies, icon: Activity, tone: 'text-emerald-600 dark:text-emerald-300', bg: 'bg-emerald-50 dark:bg-emerald-500/10' },
{ label: copy.kpis[2], value: metrics.totalRenters, icon: Users, tone: 'text-violet-600 dark:text-violet-300', bg: 'bg-violet-50 dark:bg-violet-500/10' },
{ label: copy.kpis[3], value: metrics.totalReservations, icon: ClipboardList, tone: 'text-orange-600 dark:text-orange-300', bg: 'bg-orange-50 dark:bg-orange-500/10' },
]
: []
const activeRate = metrics?.totalCompanies
? Math.round((metrics.activeCompanies / metrics.totalCompanies) * 100)
: 0
const renterRatio = metrics?.totalCompanies
? Math.round(metrics.totalRenters / Math.max(metrics.totalCompanies, 1))
: 0
const actionCards = [
{ href: '/dashboard/companies', icon: Building2, title: copy.cards[0][0], body: copy.cards[0][1], cta: copy.cards[0][2] },
{ href: '/dashboard/renters', icon: Users, title: copy.cards[1][0], body: copy.cards[1][1], cta: copy.cards[1][2] },
{ href: '/dashboard/audit-logs', icon: ShieldCheck, title: copy.cards[2][0], body: copy.cards[2][1], cta: copy.cards[2][2] },
]
return (
<div className="shell py-8 space-y-8">
<div className="grid gap-5 lg:grid-cols-[minmax(0,1.45fr)_minmax(320px,0.55fr)]">
<section className="relative overflow-hidden rounded-[2rem] border border-stone-200/80 bg-white/85 p-6 shadow-[0_30px_90px_rgba(15,23,42,0.10)] transition-colors dark:border-blue-900/60 dark:bg-[#081427]/86 dark:shadow-[0_34px_100px_rgba(0,0,0,0.34)] sm:p-8">
<div className="absolute inset-x-0 top-0 h-1 bg-[linear-gradient(90deg,#2563eb,#f97316,#10b981)]" />
<div className="flex flex-col gap-5 md:flex-row md:items-start md:justify-between">
<div className="max-w-2xl">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-orange-700 dark:text-orange-300">{copy.eyebrow}</p>
<h1 className="mt-3 text-4xl font-black tracking-normal text-blue-950 dark:text-white sm:text-5xl">{copy.platformOverview}</h1>
<p className="mt-4 max-w-2xl text-sm leading-6 text-stone-600 dark:text-slate-300">{copy.subtitle}</p>
</div>
<div className="flex w-full max-w-[15rem] items-center gap-3 rounded-2xl border border-stone-200/80 bg-stone-50/90 p-3 dark:border-blue-900/60 dark:bg-[#0d1b38]/85">
<div className="grid h-10 w-10 place-items-center rounded-xl bg-blue-950 text-white dark:bg-orange-500">
<WalletCards className="h-5 w-5" aria-hidden="true" />
</div>
<div>
<p className="text-xs text-stone-500 dark:text-slate-400">{copy.mrr}</p>
<p className="text-lg font-black text-blue-950 dark:text-white">{metrics?.mrr != null ? currencyFormatter.format(metrics.mrr) : '—'}</p>
</div>
</div>
</div>
<div className="mt-8 grid gap-3 sm:grid-cols-3">
{copy.health.map((item) => (
<div key={item} className="rounded-2xl border border-stone-200/70 bg-white/70 px-4 py-3 text-sm font-semibold text-stone-700 dark:border-blue-900/50 dark:bg-[#0d1b38]/70 dark:text-slate-200">
{item}
</div>
))}
</div>
</section>
<section className="panel p-6">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-emerald-400">{copy.liveSignal}</p>
<div className="mt-5 space-y-5">
<div>
<div className="flex items-center justify-between text-sm">
<span className="font-semibold text-zinc-200">{copy.readiness}</span>
<span className="font-black text-orange-600 dark:text-orange-300">{activeRate}%</span>
</div>
<div className="mt-2 h-2 overflow-hidden rounded-full bg-stone-200 dark:bg-blue-950">
<div className="h-full rounded-full bg-[linear-gradient(90deg,#2563eb,#f97316)]" style={{ width: `${Math.min(activeRate, 100)}%` }} />
</div>
<p className="mt-3 text-sm leading-6 text-zinc-500">{copy.readinessBody}</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="rounded-2xl border border-stone-200/80 bg-stone-50/80 p-4 dark:border-blue-900/50 dark:bg-[#07101e]/70">
<p className="text-xs text-zinc-500">{copy.kpis[1]}</p>
<p className="mt-1 text-2xl font-black text-zinc-200">{metrics ? numberFormatter.format(metrics.activeCompanies) : '—'}</p>
</div>
<div className="rounded-2xl border border-stone-200/80 bg-stone-50/80 p-4 dark:border-blue-900/50 dark:bg-[#07101e]/70">
<p className="text-xs text-zinc-500">{copy.kpis[2]}</p>
<p className="mt-1 text-2xl font-black text-zinc-200">{metrics ? numberFormatter.format(renterRatio) : '—'}</p>
</div>
</div>
</div>
</section>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{loading
? Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="panel p-6 animate-pulse">
<div className="h-3 w-24 rounded bg-zinc-800" />
<div className="mt-3 h-8 w-16 rounded bg-zinc-800" />
</div>
))
: kpis.length > 0 ? kpis.map((kpi) => {
const Icon = kpi.icon
return (
<div key={kpi.label} className="panel p-5">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-medium text-zinc-500">{kpi.label}</p>
<p className="mt-2 text-3xl font-black text-zinc-200">{numberFormatter.format(kpi.value ?? 0)}</p>
</div>
<div className={`grid h-10 w-10 place-items-center rounded-2xl ${kpi.bg}`}>
<Icon className={`h-5 w-5 ${kpi.tone}`} aria-hidden="true" />
</div>
</div>
</div>
)
}) : (
<div className="panel p-6 md:col-span-2 lg:col-span-4">
<p className="text-sm text-zinc-500">{copy.noMetrics}</p>
</div>
)}
</div>
<section>
<div className="mb-4 flex items-center justify-between">
<p className="text-sm font-bold text-blue-950 dark:text-white">{copy.quickActions}</p>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-500">{copy.brand}</p>
</div>
<div className="grid gap-5 md:grid-cols-3">
{actionCards.map(({ href, icon: Icon, title, body, cta }) => (
<Link key={href} href={href} className="panel group block p-6 hover:border-orange-300 dark:hover:border-orange-500/70">
<div className="flex items-start justify-between gap-4">
<div className="grid h-11 w-11 place-items-center rounded-2xl bg-blue-950 text-white dark:bg-orange-500">
<Icon className="h-5 w-5" aria-hidden="true" />
</div>
<ArrowRight className="h-4 w-4 text-zinc-500 transition-transform group-hover:translate-x-1 group-hover:text-orange-500" aria-hidden="true" />
</div>
<p className="mt-5 text-base font-bold text-zinc-200">{title}</p>
<p className="mt-2 min-h-[3rem] text-sm leading-6 text-zinc-500">{body}</p>
<p className="mt-5 text-xs font-bold uppercase tracking-[0.16em] text-emerald-400">{cta}</p>
</Link>
))}
</div>
</section>
</div>
)
}