initial: standalone repo from monorepo split

This commit is contained in:
kevin-asprec
2026-04-13 09:36:43 +08:00
commit be0b7d50d2
39 changed files with 2952 additions and 0 deletions

187
src/app/(admin)/page.tsx Normal file
View File

@@ -0,0 +1,187 @@
'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>
);
}