mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2025-12-13 01:24:44 -05:00
667 lines
20 KiB
TypeScript
667 lines
20 KiB
TypeScript
import {
|
|
Activity,
|
|
ArrowUpRight,
|
|
BarChart3,
|
|
Calendar,
|
|
Edit,
|
|
Eye,
|
|
FileText,
|
|
Plus,
|
|
Users,
|
|
} from "lucide-react";
|
|
import Link from "next/link";
|
|
import { Suspense } from "react";
|
|
import { Badge } from "~/components/ui/badge";
|
|
import { Button } from "~/components/ui/button";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
|
import { Skeleton } from "~/components/ui/skeleton";
|
|
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
|
import { auth } from "~/lib/auth";
|
|
import { headers } from "next/headers";
|
|
import { HydrateClient, api } from "~/trpc/server";
|
|
import type { StoredInvoiceStatus } from "~/types/invoice";
|
|
import { RevenueChart } from "~/app/dashboard/_components/revenue-chart";
|
|
import { InvoiceStatusChart } from "~/app/dashboard/_components/invoice-status-chart";
|
|
import { MonthlyMetricsChart } from "~/app/dashboard/_components/monthly-metrics-chart";
|
|
import { AnimatedStatsCard } from "~/app/dashboard/_components/animated-stats-card";
|
|
|
|
// Hero section with clean mono design
|
|
function DashboardHero({ firstName }: { firstName: string }) {
|
|
return (
|
|
<div className="mb-8">
|
|
<h1 className="mb-2 text-3xl font-bold">Welcome back, {firstName}!</h1>
|
|
<p className="text-muted-foreground text-lg">
|
|
Here's what's happening with your business today
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Enhanced stats cards with better visuals
|
|
async function DashboardStats() {
|
|
const [clients, invoices] = await Promise.all([
|
|
api.clients.getAll(),
|
|
api.invoices.getAll(),
|
|
]);
|
|
|
|
const totalClients = clients.length;
|
|
const paidInvoices = invoices.filter(
|
|
(invoice) =>
|
|
getEffectiveInvoiceStatus(
|
|
invoice.status as StoredInvoiceStatus,
|
|
invoice.dueDate,
|
|
) === "paid",
|
|
);
|
|
const totalRevenue = paidInvoices.reduce(
|
|
(sum, invoice) => sum + invoice.totalAmount,
|
|
0,
|
|
);
|
|
|
|
const pendingInvoices = invoices.filter((invoice) => {
|
|
const effectiveStatus = getEffectiveInvoiceStatus(
|
|
invoice.status as StoredInvoiceStatus,
|
|
invoice.dueDate,
|
|
);
|
|
return effectiveStatus === "sent" || effectiveStatus === "overdue";
|
|
});
|
|
const pendingAmount = pendingInvoices.reduce(
|
|
(sum, invoice) => sum + invoice.totalAmount,
|
|
0,
|
|
);
|
|
|
|
const overdueInvoices = invoices.filter(
|
|
(invoice) =>
|
|
getEffectiveInvoiceStatus(
|
|
invoice.status as StoredInvoiceStatus,
|
|
invoice.dueDate,
|
|
) === "overdue",
|
|
);
|
|
|
|
// Calculate month-over-month trends
|
|
const now = new Date();
|
|
const currentMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
|
const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
|
|
|
// Current month data
|
|
const currentMonthInvoices = invoices.filter(
|
|
(invoice) => new Date(invoice.issueDate) >= currentMonth,
|
|
);
|
|
const currentMonthRevenue = currentMonthInvoices
|
|
.filter(
|
|
(invoice) =>
|
|
getEffectiveInvoiceStatus(
|
|
invoice.status as StoredInvoiceStatus,
|
|
invoice.dueDate,
|
|
) === "paid",
|
|
)
|
|
.reduce((sum, invoice) => sum + invoice.totalAmount, 0);
|
|
|
|
// Last month data
|
|
const lastMonthInvoices = invoices.filter((invoice) => {
|
|
const date = new Date(invoice.issueDate);
|
|
return date >= lastMonth && date < currentMonth;
|
|
});
|
|
const lastMonthRevenue = lastMonthInvoices
|
|
.filter(
|
|
(invoice) =>
|
|
getEffectiveInvoiceStatus(
|
|
invoice.status as StoredInvoiceStatus,
|
|
invoice.dueDate,
|
|
) === "paid",
|
|
)
|
|
.reduce((sum, invoice) => sum + invoice.totalAmount, 0);
|
|
|
|
// Previous month data for clients
|
|
const prevMonthClients = clients.filter(
|
|
(client) => new Date(client.createdAt) < currentMonth,
|
|
).length;
|
|
|
|
// Calculate trends
|
|
const revenueChange =
|
|
lastMonthRevenue > 0
|
|
? ((currentMonthRevenue - lastMonthRevenue) / lastMonthRevenue) * 100
|
|
: currentMonthRevenue > 0
|
|
? 100
|
|
: 0;
|
|
|
|
const pendingChange =
|
|
lastMonthInvoices.length > 0
|
|
? ((pendingInvoices.length -
|
|
lastMonthInvoices.filter((invoice) => {
|
|
const status = getEffectiveInvoiceStatus(
|
|
invoice.status as StoredInvoiceStatus,
|
|
invoice.dueDate,
|
|
);
|
|
return status === "sent" || status === "overdue";
|
|
}).length) /
|
|
lastMonthInvoices.length) *
|
|
100
|
|
: pendingInvoices.length > 0
|
|
? 100
|
|
: 0;
|
|
|
|
const clientChange = totalClients - prevMonthClients;
|
|
|
|
const lastMonthOverdue = lastMonthInvoices.filter(
|
|
(invoice) =>
|
|
getEffectiveInvoiceStatus(
|
|
invoice.status as StoredInvoiceStatus,
|
|
invoice.dueDate,
|
|
) === "overdue",
|
|
).length;
|
|
const overdueChange = overdueInvoices.length - lastMonthOverdue;
|
|
|
|
const formatTrend = (value: number, isCount = false) => {
|
|
if (isCount) {
|
|
return value > 0 ? `+${value}` : value.toString();
|
|
}
|
|
return value > 0 ? `+${value.toFixed(1)}%` : `${value.toFixed(1)}%`;
|
|
};
|
|
|
|
// Debug logging to see actual values
|
|
console.log("Dashboard Stats Debug:", {
|
|
totalRevenue,
|
|
pendingAmount,
|
|
totalClients,
|
|
overdueInvoices: overdueInvoices.length,
|
|
revenueChange,
|
|
pendingChange,
|
|
clientChange,
|
|
overdueChange,
|
|
paidInvoicesCount: paidInvoices.length,
|
|
pendingInvoicesCount: pendingInvoices.length,
|
|
});
|
|
|
|
const stats = [
|
|
{
|
|
title: "Total Revenue",
|
|
value: `$${totalRevenue.toLocaleString("en-US", { minimumFractionDigits: 2 })}`,
|
|
numericValue: totalRevenue,
|
|
isCurrency: true,
|
|
change: formatTrend(revenueChange),
|
|
trend: revenueChange >= 0 ? ("up" as const) : ("down" as const),
|
|
iconName: "DollarSign" as const,
|
|
description: `From ${paidInvoices.length} paid invoices`,
|
|
},
|
|
{
|
|
title: "Pending Amount",
|
|
value: `$${pendingAmount.toLocaleString("en-US", { minimumFractionDigits: 2 })}`,
|
|
numericValue: pendingAmount,
|
|
isCurrency: true,
|
|
change: formatTrend(pendingChange),
|
|
trend: pendingChange >= 0 ? ("up" as const) : ("down" as const),
|
|
iconName: "Clock" as const,
|
|
description: `${pendingInvoices.length} invoices awaiting payment`,
|
|
},
|
|
{
|
|
title: "Active Clients",
|
|
value: totalClients.toString(),
|
|
numericValue: totalClients,
|
|
isCurrency: false,
|
|
change: formatTrend(clientChange, true),
|
|
trend: clientChange >= 0 ? ("up" as const) : ("down" as const),
|
|
iconName: "Users" as const,
|
|
description: "Total registered clients",
|
|
},
|
|
{
|
|
title: "Overdue Invoices",
|
|
value: overdueInvoices.length.toString(),
|
|
numericValue: overdueInvoices.length,
|
|
isCurrency: false,
|
|
change: formatTrend(overdueChange, true),
|
|
trend: overdueChange <= 0 ? ("up" as const) : ("down" as const),
|
|
iconName: "TrendingDown" as const,
|
|
description: "Invoices past due date",
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div className="mb-8 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
|
{stats.map((stat, index) => (
|
|
<AnimatedStatsCard
|
|
key={stat.title}
|
|
title={stat.title}
|
|
value={stat.value}
|
|
numericValue={stat.numericValue}
|
|
isCurrency={stat.isCurrency}
|
|
iconName={stat.iconName}
|
|
change={stat.change}
|
|
trend={stat.trend}
|
|
description={stat.description}
|
|
delay={index * 100}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Charts section
|
|
async function ChartsSection() {
|
|
const invoices = await api.invoices.getAll();
|
|
|
|
return (
|
|
<div className="mb-8 grid grid-cols-1 gap-6 lg:grid-cols-2">
|
|
{/* Revenue Trend Chart */}
|
|
<Card className="lg:col-span-2">
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<BarChart3 className="h-5 w-5" />
|
|
Revenue Over Time
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<RevenueChart invoices={invoices} />
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Invoice Status Breakdown */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Activity className="h-5 w-5" />
|
|
Invoice Status
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<InvoiceStatusChart invoices={invoices} />
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Monthly Metrics */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Calendar className="h-5 w-5" />
|
|
Monthly Metrics
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<MonthlyMetricsChart invoices={invoices} />
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Enhanced Quick Actions
|
|
function QuickActions() {
|
|
const actions = [
|
|
{
|
|
title: "Create Invoice",
|
|
description: "Start a new invoice for a client",
|
|
href: "/dashboard/invoices/new",
|
|
icon: FileText,
|
|
featured: true,
|
|
},
|
|
{
|
|
title: "Add Client",
|
|
description: "Register a new client",
|
|
href: "/dashboard/clients/new",
|
|
icon: Users,
|
|
featured: false,
|
|
},
|
|
{
|
|
title: "View All Invoices",
|
|
description: "Manage your invoice pipeline",
|
|
href: "/dashboard/invoices",
|
|
icon: BarChart3,
|
|
featured: false,
|
|
},
|
|
];
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Plus className="h-5 w-5" />
|
|
Quick Actions
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
{actions.map((action) => {
|
|
const Icon = action.icon;
|
|
return (
|
|
<Link
|
|
key={action.title}
|
|
href={action.href}
|
|
className={`hover-lift flex w-full items-start space-x-3 rounded-lg border p-4 transition-colors ${action.featured
|
|
? "border-foreground/20 bg-muted/50 hover:bg-muted"
|
|
: "border-border bg-background hover:bg-muted/50"
|
|
}`}
|
|
>
|
|
<Icon className="h-5 w-5 flex-shrink-0" />
|
|
<div className="min-w-0 flex-1">
|
|
<p className="font-semibold">{action.title}</p>
|
|
<p className="text-muted-foreground text-sm leading-relaxed">
|
|
{action.description}
|
|
</p>
|
|
</div>
|
|
</Link>
|
|
);
|
|
})}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// Current work section with enhanced design
|
|
async function CurrentWork() {
|
|
const invoices = await api.invoices.getAll();
|
|
const draftInvoices = invoices.filter(
|
|
(invoice) =>
|
|
getEffectiveInvoiceStatus(
|
|
invoice.status as StoredInvoiceStatus,
|
|
invoice.dueDate,
|
|
) === "draft",
|
|
);
|
|
const currentInvoice = draftInvoices[0];
|
|
|
|
if (!currentInvoice) {
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Activity className="h-5 w-5" />
|
|
Current Work
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="py-8 text-center">
|
|
<FileText className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
|
|
<h3 className="mb-2 text-lg font-semibold">No active drafts</h3>
|
|
<p className="text-muted-foreground mb-4">
|
|
Create a new invoice to get started
|
|
</p>
|
|
<Button asChild variant="outline" className="border-foreground/20">
|
|
<Link href="/dashboard/invoices/new">
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
Create Invoice
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
const totalHours =
|
|
currentInvoice.items?.reduce((sum, item) => sum + item.hours, 0) ?? 0;
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Activity className="h-5 w-5" />
|
|
Current Work
|
|
</CardTitle>
|
|
<Badge variant="secondary">In Progress</Badge>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
|
<h3 className="text-lg font-semibold break-words">
|
|
#{currentInvoice.invoiceNumber}
|
|
</h3>
|
|
<span className="text-primary text-2xl font-bold">
|
|
${currentInvoice.totalAmount.toFixed(2)}
|
|
</span>
|
|
</div>
|
|
<div className="text-muted-foreground flex flex-col gap-1 text-sm sm:flex-row sm:items-center sm:justify-between">
|
|
<span className="break-words">{currentInvoice.client?.name}</span>
|
|
<span className="text-xs sm:text-sm">
|
|
{totalHours.toFixed(1)} hours logged
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
<Button
|
|
asChild
|
|
variant="outline"
|
|
size="sm"
|
|
className="hover-lift flex-1"
|
|
>
|
|
<Link href={`/dashboard/invoices/${currentInvoice.id}`}>
|
|
<Eye className="mr-2 h-4 w-4" />
|
|
View
|
|
</Link>
|
|
</Button>
|
|
<Button asChild size="sm" className="hover-lift flex-1">
|
|
<Link href={`/dashboard/invoices/${currentInvoice.id}/edit`}>
|
|
<Edit className="mr-2 h-4 w-4" />
|
|
Continue
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// Enhanced recent activity
|
|
async function RecentActivity() {
|
|
const invoices = await api.invoices.getAll();
|
|
const recentInvoices = invoices
|
|
.sort(
|
|
(a, b) =>
|
|
new Date(b.issueDate).getTime() - new Date(a.issueDate).getTime(),
|
|
)
|
|
.slice(0, 5);
|
|
|
|
const getStatusStyle = (status: string) => {
|
|
switch (status) {
|
|
case "paid":
|
|
return {
|
|
backgroundColor: "oklch(var(--chart-2) / 0.1)",
|
|
borderColor: "oklch(var(--chart-2) / 0.3)",
|
|
color: "oklch(var(--chart-2))",
|
|
};
|
|
case "sent":
|
|
return {
|
|
backgroundColor: "oklch(var(--chart-1) / 0.1)",
|
|
borderColor: "oklch(var(--chart-1) / 0.3)",
|
|
color: "oklch(var(--chart-1))",
|
|
};
|
|
case "overdue":
|
|
return {
|
|
backgroundColor: "oklch(var(--chart-3) / 0.1)",
|
|
borderColor: "oklch(var(--chart-3) / 0.3)",
|
|
color: "oklch(var(--chart-3))",
|
|
};
|
|
default:
|
|
return {
|
|
backgroundColor: "hsl(var(--muted))",
|
|
borderColor: "hsl(var(--border))",
|
|
color: "hsl(var(--muted-foreground))",
|
|
};
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Calendar className="h-5 w-5" />
|
|
Recent Activity
|
|
</CardTitle>
|
|
<Button variant="ghost" size="sm" asChild>
|
|
<Link href="/dashboard/invoices">
|
|
<span className="hidden sm:inline">View All</span>
|
|
<ArrowUpRight className="h-4 w-4 sm:ml-1" />
|
|
</Link>
|
|
</Button>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{recentInvoices.length === 0 ? (
|
|
<div className="py-8 text-center">
|
|
<FileText className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
|
|
<h3 className="mb-2 text-lg font-semibold">No invoices yet</h3>
|
|
<p className="text-muted-foreground mb-4">
|
|
Create your first invoice to get started
|
|
</p>
|
|
<Button asChild variant="outline" className="border-foreground/20">
|
|
<Link href="/dashboard/invoices/new">
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
Create Your First Invoice
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{recentInvoices.map((invoice, _index) => (
|
|
<Link
|
|
key={invoice.id}
|
|
href={`/dashboard/invoices/${invoice.id}`}
|
|
className="block"
|
|
>
|
|
<div className="recent-activity-item bg-muted/50 hover:bg-muted border-foreground/20 rounded-lg border p-3 transition-colors">
|
|
<div className="flex items-start gap-3">
|
|
<div className="bg-muted flex-shrink-0 rounded-lg p-2">
|
|
<FileText className="text-muted-foreground h-4 w-4" />
|
|
</div>
|
|
<div className="min-w-0 flex-1 space-y-2">
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
|
<div className="min-w-0">
|
|
<p className="truncate font-medium">
|
|
#{invoice.invoiceNumber}
|
|
</p>
|
|
<p className="text-muted-foreground truncate text-sm">
|
|
{invoice.client?.name}
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-shrink-0 items-center gap-2">
|
|
<Badge style={getStatusStyle(invoice.status)}>
|
|
{invoice.status}
|
|
</Badge>
|
|
<span className="text-primary font-semibold">
|
|
${invoice.totalAmount.toFixed(2)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<p className="text-muted-foreground text-xs">
|
|
{new Date(invoice.issueDate).toLocaleDateString()}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// Loading skeletons
|
|
function StatsSkeleton() {
|
|
return (
|
|
<div className="mb-8 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
|
{Array.from({ length: 4 }).map((_, i) => (
|
|
<Card key={i}>
|
|
<CardContent className="p-6">
|
|
<div className="flex items-center justify-between space-y-0 pb-2">
|
|
<Skeleton className="h-4 w-24" />
|
|
<Skeleton className="h-4 w-12" />
|
|
</div>
|
|
<Skeleton className="mb-2 h-8 w-20" />
|
|
<Skeleton className="h-3 w-32" />
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ChartsSkeleton() {
|
|
return (
|
|
<div className="mb-8 grid grid-cols-1 gap-6 lg:grid-cols-2">
|
|
<Card className="lg:col-span-2">
|
|
<CardHeader>
|
|
<Skeleton className="h-6 w-40" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Skeleton className="h-64 w-full" />
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader>
|
|
<Skeleton className="h-6 w-32" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Skeleton className="h-64 w-full" />
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader>
|
|
<Skeleton className="h-6 w-36" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Skeleton className="h-64 w-full" />
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CardSkeleton() {
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<Skeleton className="h-6 w-32" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-3">
|
|
<Skeleton className="h-4 w-full" />
|
|
<Skeleton className="h-4 w-3/4" />
|
|
<Skeleton className="h-4 w-1/2" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
export default async function DashboardPage() {
|
|
const session = await auth.api.getSession({
|
|
headers: await headers(),
|
|
});
|
|
const firstName = session?.user?.name?.split(" ")[0] ?? "User";
|
|
|
|
return (
|
|
<div className="page-enter space-y-8">
|
|
<DashboardHero firstName={firstName} />
|
|
|
|
<HydrateClient>
|
|
<Suspense fallback={<StatsSkeleton />}>
|
|
<DashboardStats />
|
|
</Suspense>
|
|
</HydrateClient>
|
|
|
|
<HydrateClient>
|
|
<Suspense fallback={<ChartsSkeleton />}>
|
|
<ChartsSection />
|
|
</Suspense>
|
|
</HydrateClient>
|
|
|
|
<div className="grid grid-cols-1 gap-8 lg:grid-cols-2">
|
|
<div className="space-y-8">
|
|
<HydrateClient>
|
|
<Suspense fallback={<CardSkeleton />}>
|
|
<CurrentWork />
|
|
</Suspense>
|
|
</HydrateClient>
|
|
<QuickActions />
|
|
</div>
|
|
|
|
<HydrateClient>
|
|
<Suspense fallback={<CardSkeleton />}>
|
|
<RecentActivity />
|
|
</Suspense>
|
|
</HydrateClient>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|