188 lines
6.4 KiB
TypeScript
188 lines
6.4 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import Link from 'next/link';
|
|
import api from '@/lib/api';
|
|
|
|
interface DashboardStats {
|
|
totalTenants: number;
|
|
activeTenants: number;
|
|
inactiveTenants: number;
|
|
totalUsers: number;
|
|
totalClients: number;
|
|
totalSubscriptions: number;
|
|
activeSubscriptions: number;
|
|
totalRevenue: number;
|
|
openSupportTickets: number;
|
|
}
|
|
|
|
interface RecentTenant {
|
|
id: string;
|
|
name: string;
|
|
slug: string;
|
|
isActive: boolean;
|
|
createdAt: string;
|
|
}
|
|
|
|
interface RecentTicket {
|
|
id: string;
|
|
subject: string;
|
|
tenantName: string;
|
|
category: string;
|
|
priority: string;
|
|
status: string;
|
|
createdAt: string;
|
|
}
|
|
|
|
export default function AdminDashboard() {
|
|
const [stats, setStats] = useState<DashboardStats | null>(null);
|
|
const [recentTenants, setRecentTenants] = useState<RecentTenant[]>([]);
|
|
const [recentTickets, setRecentTickets] = useState<RecentTicket[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
async function load() {
|
|
try {
|
|
const [statsRes, activityRes] = await Promise.all([
|
|
api.get('/dashboard/stats'),
|
|
api.get('/dashboard/recent-activity'),
|
|
]);
|
|
setStats(statsRes.data.data);
|
|
setRecentTenants(activityRes.data.data.recentTenants || []);
|
|
setRecentTickets(activityRes.data.data.recentTickets || []);
|
|
} catch (err) {
|
|
console.error('Failed to load dashboard:', err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
load();
|
|
}, []);
|
|
|
|
if (loading) {
|
|
return <DashboardSkeleton />;
|
|
}
|
|
|
|
const statCards = [
|
|
{ label: 'Total Tenants', value: stats?.totalTenants ?? 0, sub: `${stats?.activeTenants ?? 0} active` },
|
|
{ label: 'Total Users', value: stats?.totalUsers ?? 0 },
|
|
{ label: 'Active Subscriptions', value: stats?.activeSubscriptions ?? 0, sub: `${stats?.totalSubscriptions ?? 0} total` },
|
|
{ label: 'Platform Revenue', value: `₱${Number(stats?.totalRevenue ?? 0).toLocaleString()}` },
|
|
{ label: 'Open Tickets', value: stats?.openSupportTickets ?? 0 },
|
|
{ label: 'Total Clients', value: stats?.totalClients ?? 0 },
|
|
];
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{statCards.map((card) => (
|
|
<div
|
|
key={card.label}
|
|
className="bg-white rounded-xl border border-surface-200 p-5"
|
|
>
|
|
<p className="text-sm text-surface-500">{card.label}</p>
|
|
<p className="text-2xl font-bold text-surface-900 mt-1">{card.value}</p>
|
|
{card.sub && <p className="text-xs text-surface-400 mt-1">{card.sub}</p>}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
{/* Recent Tenants */}
|
|
<div className="bg-white rounded-xl border border-surface-200 p-5">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h2 className="text-base font-semibold text-surface-900">Recent Tenants</h2>
|
|
<Link href="/tenants" className="text-sm text-primary-600 hover:text-primary-700">
|
|
View all
|
|
</Link>
|
|
</div>
|
|
<div className="space-y-3">
|
|
{recentTenants.map((t) => (
|
|
<Link
|
|
key={t.id}
|
|
href={`/tenants/${t.id}`}
|
|
className="flex items-center justify-between p-3 rounded-lg hover:bg-surface-50 transition-colors"
|
|
>
|
|
<div>
|
|
<p className="text-sm font-medium text-surface-900">{t.name}</p>
|
|
<p className="text-xs text-surface-400">{t.slug}</p>
|
|
</div>
|
|
<span
|
|
className={`text-xs px-2 py-1 rounded-full ${
|
|
t.isActive
|
|
? 'bg-green-50 text-green-700'
|
|
: 'bg-red-50 text-red-700'
|
|
}`}
|
|
>
|
|
{t.isActive ? 'Active' : 'Inactive'}
|
|
</span>
|
|
</Link>
|
|
))}
|
|
{recentTenants.length === 0 && (
|
|
<p className="text-sm text-surface-400 text-center py-4">No tenants yet</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Recent Support Tickets */}
|
|
<div className="bg-white rounded-xl border border-surface-200 p-5">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h2 className="text-base font-semibold text-surface-900">Open Support Tickets</h2>
|
|
<Link href="/support" className="text-sm text-primary-600 hover:text-primary-700">
|
|
View all
|
|
</Link>
|
|
</div>
|
|
<div className="space-y-3">
|
|
{recentTickets.map((t) => (
|
|
<Link
|
|
key={t.id}
|
|
href={`/support/${t.id}`}
|
|
className="flex items-center justify-between p-3 rounded-lg hover:bg-surface-50 transition-colors"
|
|
>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-sm font-medium text-surface-900 truncate">{t.subject}</p>
|
|
<p className="text-xs text-surface-400">{t.tenantName}</p>
|
|
</div>
|
|
<PriorityBadge priority={t.priority} />
|
|
</Link>
|
|
))}
|
|
{recentTickets.length === 0 && (
|
|
<p className="text-sm text-surface-400 text-center py-4">No open tickets</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PriorityBadge({ priority }: { priority: string }) {
|
|
const styles: Record<string, string> = {
|
|
urgent: 'bg-red-100 text-red-700',
|
|
high: 'bg-orange-100 text-orange-700',
|
|
normal: 'bg-blue-100 text-blue-700',
|
|
low: 'bg-surface-100 text-surface-600',
|
|
};
|
|
return (
|
|
<span className={`text-xs px-2 py-1 rounded-full capitalize ${styles[priority] || styles.normal}`}>
|
|
{priority}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function DashboardSkeleton() {
|
|
return (
|
|
<div className="space-y-6 animate-pulse">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{Array.from({ length: 6 }).map((_, i) => (
|
|
<div key={i} className="bg-white rounded-xl border border-surface-200 p-5 h-24" />
|
|
))}
|
|
</div>
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
<div className="bg-white rounded-xl border border-surface-200 p-5 h-64" />
|
|
<div className="bg-white rounded-xl border border-surface-200 p-5 h-64" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|