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
246 lines
13 KiB
TypeScript
246 lines
13 KiB
TypeScript
'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, l’activité 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 d’audit', 'Tracer chaque action admin sensible dans RentalDriveGo.', 'Voir les journaux'],
|
||
],
|
||
health: ['Comptes locataires', 'Identité Carplace', 'Piste d’audit 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>
|
||
)
|
||
}
|