Unify the dashboard experience and retire the multi-theme engine so onboarding and day-to-day invoicing feel consistent and easier to maintain.
Shared layout, tabs, and sidebar timer; user onboarding and registration polish; settings danger zone and data export; chart and tRPC perf fixes; migrations for onboarding and dropped appearance columns. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+190
-308
@@ -10,24 +10,34 @@ import {
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
import { AnimatedStatsCard } from "~/app/dashboard/_components/animated-stats-card";
|
||||
import {
|
||||
InvoiceStatusChart,
|
||||
MonthlyMetricsChart,
|
||||
RevenueChart,
|
||||
} from "~/app/dashboard/_components/charts-client";
|
||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||
import {
|
||||
DashboardCardTitle,
|
||||
DashboardGrid,
|
||||
DashboardPage as DashboardPageLayout,
|
||||
dashboardGridClass,
|
||||
} from "~/components/layout/dashboard-page";
|
||||
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 {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { getOptionalServerSessionFromHeaders } from "~/lib/auth-server";
|
||||
import { HydrateClient, api } from "~/trpc/server";
|
||||
import type { StoredInvoiceStatus } from "~/types/invoice";
|
||||
import { RevenueChart, InvoiceStatusChart, MonthlyMetricsChart } from "~/app/dashboard/_components/charts-client";
|
||||
import { AnimatedStatsCard } from "~/app/dashboard/_components/animated-stats-card";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { api } from "~/trpc/server";
|
||||
import type { DashboardStats, RecentInvoice } from "./types";
|
||||
|
||||
// Hero section with clean mono design
|
||||
|
||||
// Enhanced stats cards with better visuals
|
||||
function DashboardStats({ stats }: { stats: DashboardStats }) {
|
||||
// TODO: Import RouterOutput type
|
||||
const formatTrend = (value: number, isCount = false) => {
|
||||
if (isCount) {
|
||||
return value > 0 ? `+${value}` : value.toString();
|
||||
@@ -44,42 +54,42 @@ function DashboardStats({ stats }: { stats: DashboardStats }) {
|
||||
change: formatTrend(stats.revenueChange),
|
||||
trend: stats.revenueChange >= 0 ? ("up" as const) : ("down" as const),
|
||||
iconName: "DollarSign" as const,
|
||||
description: "Total collected revenue",
|
||||
description: "Collected to date",
|
||||
},
|
||||
{
|
||||
title: "Pending Amount",
|
||||
title: "Pending",
|
||||
value: `$${stats.pendingAmount.toLocaleString("en-US", { minimumFractionDigits: 2 })}`,
|
||||
numericValue: stats.pendingAmount,
|
||||
isCurrency: true,
|
||||
change: "0%", // TODO: Calculate pending change if needed
|
||||
change: "0%",
|
||||
trend: "neutral" as const,
|
||||
iconName: "Clock" as const,
|
||||
description: "Invoices awaiting payment",
|
||||
description: "Awaiting payment",
|
||||
},
|
||||
{
|
||||
title: "Active Clients",
|
||||
title: "Clients",
|
||||
value: stats.totalClients.toString(),
|
||||
numericValue: stats.totalClients,
|
||||
isCurrency: false,
|
||||
change: "0", // TODO: Calculate client change if needed
|
||||
change: "0",
|
||||
trend: "neutral" as const,
|
||||
iconName: "Users" as const,
|
||||
description: "Total registered clients",
|
||||
description: "Active clients",
|
||||
},
|
||||
{
|
||||
title: "Overdue Invoices",
|
||||
title: "Overdue",
|
||||
value: stats.overdueCount.toString(),
|
||||
numericValue: stats.overdueCount,
|
||||
isCurrency: false,
|
||||
change: "0", // TODO: Calculate overdue change if needed
|
||||
change: "0",
|
||||
trend: "neutral" as const,
|
||||
iconName: "TrendingDown" as const,
|
||||
description: "Invoices past due date",
|
||||
description: "Past due date",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className={cn(dashboardGridClass, "sm:grid-cols-2 xl:grid-cols-4")}>
|
||||
{statCards.map((stat, index) => (
|
||||
<AnimatedStatsCard
|
||||
key={stat.title}
|
||||
@@ -98,21 +108,15 @@ function DashboardStats({ stats }: { stats: DashboardStats }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Charts section
|
||||
async function ChartsSection({ stats }: { stats: DashboardStats }) {
|
||||
// We still fetch all invoices for the status chart for now, or we could aggregate that too.
|
||||
// For now, let's keep status chart as is (fetching all) but use aggregated for revenue.
|
||||
// Actually, let's fetch invoices here for the status chart to keep it working.
|
||||
const invoices = await api.invoices.getAll();
|
||||
|
||||
function ChartsSection({ stats }: { stats: DashboardStats }) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
{/* Revenue Trend Chart */}
|
||||
<DashboardGrid className="lg:grid-cols-2">
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<BarChart3 className="h-5 w-5" />
|
||||
Revenue Over Time
|
||||
<CardTitle>
|
||||
<DashboardCardTitle icon={BarChart3}>
|
||||
Revenue over time
|
||||
</DashboardCardTitle>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -120,55 +124,54 @@ async function ChartsSection({ stats }: { stats: DashboardStats }) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Invoice Status Breakdown */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Activity className="h-5 w-5" />
|
||||
Invoice Status
|
||||
<CardTitle>
|
||||
<DashboardCardTitle icon={Activity}>
|
||||
Invoice status
|
||||
</DashboardCardTitle>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<InvoiceStatusChart invoices={invoices} />
|
||||
<InvoiceStatusChart data={stats.statusChartData} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Monthly Metrics */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5" />
|
||||
Monthly Metrics
|
||||
<CardTitle>
|
||||
<DashboardCardTitle icon={Calendar}>
|
||||
Monthly metrics
|
||||
</DashboardCardTitle>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<MonthlyMetricsChart invoices={invoices} />
|
||||
<MonthlyMetricsChart data={stats.monthlyMetricsChartData} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</DashboardGrid>
|
||||
);
|
||||
}
|
||||
|
||||
// Enhanced Quick Actions
|
||||
function QuickActions() {
|
||||
const actions = [
|
||||
{
|
||||
title: "Create Invoice",
|
||||
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",
|
||||
title: "Add client",
|
||||
description: "Register someone you bill",
|
||||
href: "/dashboard/clients/new",
|
||||
icon: Users,
|
||||
featured: false,
|
||||
},
|
||||
{
|
||||
title: "View All Invoices",
|
||||
description: "Manage your invoice pipeline",
|
||||
title: "View invoices",
|
||||
description: "Browse your full pipeline",
|
||||
href: "/dashboard/invoices",
|
||||
icon: BarChart3,
|
||||
featured: false,
|
||||
@@ -178,27 +181,36 @@ function QuickActions() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Plus className="h-5 w-5" />
|
||||
Quick Actions
|
||||
<CardTitle>
|
||||
<DashboardCardTitle icon={Plus}>Quick actions</DashboardCardTitle>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<CardContent className="space-y-2">
|
||||
{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 ${
|
||||
className={cn(
|
||||
"flex items-start gap-3 rounded-2xl border p-4 transition-colors",
|
||||
action.featured
|
||||
? "border-foreground/20 bg-muted/50 hover:bg-muted"
|
||||
: "border-border bg-background hover:bg-muted/50"
|
||||
}`}
|
||||
? "border-primary/20 bg-primary/5 hover:bg-primary/10"
|
||||
: "border-border/60 bg-background/50 hover:bg-muted/50",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5 flex-shrink-0" />
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl p-2",
|
||||
action.featured
|
||||
? "bg-primary/10 text-primary"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-semibold">{action.title}</p>
|
||||
<p className="text-sm font-medium">{action.title}</p>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{action.description}
|
||||
</p>
|
||||
@@ -211,204 +223,168 @@ function QuickActions() {
|
||||
);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
function CurrentWork({
|
||||
currentDraft,
|
||||
}: {
|
||||
currentDraft: DashboardStats["currentDraft"];
|
||||
}) {
|
||||
if (!currentDraft) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Activity className="h-5 w-5" />
|
||||
Current Work
|
||||
<CardTitle>
|
||||
<DashboardCardTitle icon={Activity}>
|
||||
Current work
|
||||
</DashboardCardTitle>
|
||||
</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>
|
||||
<CardContent className="flex flex-col items-center py-8 text-center">
|
||||
<div className="bg-muted mb-4 rounded-2xl p-3">
|
||||
<FileText className="text-muted-foreground h-6 w-6" />
|
||||
</div>
|
||||
<p className="font-medium">No draft in progress</p>
|
||||
<CardDescription className="mt-1 max-w-xs">
|
||||
Start an invoice when you're ready to bill your next piece of
|
||||
work.
|
||||
</CardDescription>
|
||||
<Button asChild variant="outline" className="mt-5">
|
||||
<Link href="/dashboard/invoices/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create invoice
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const totalHours =
|
||||
currentInvoice.items?.reduce((sum, item) => sum + item.hours, 0) ?? 0;
|
||||
const totalHours = currentDraft.totalHours;
|
||||
|
||||
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
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle>
|
||||
<DashboardCardTitle icon={Activity}>Current work</DashboardCardTitle>
|
||||
</CardTitle>
|
||||
<Badge variant="secondary">In Progress</Badge>
|
||||
<Badge variant="secondary">Draft</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>
|
||||
<CardContent className="space-y-5">
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<p className="font-medium">#{currentDraft.invoiceNumber}</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{currentDraft.client?.name}
|
||||
</p>
|
||||
</div>
|
||||
<p className="font-mono text-xl font-semibold tabular-nums">
|
||||
${currentDraft.totalAmount.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-muted-foreground font-mono text-xs tabular-nums">
|
||||
{totalHours.toFixed(1)} hours logged
|
||||
</p>
|
||||
</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 className="flex gap-2">
|
||||
<Button asChild variant="outline" size="sm" className="flex-1">
|
||||
<Link href={`/dashboard/invoices/${currentDraft.id}`}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
View
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm" className="flex-1">
|
||||
<Link href={`/dashboard/invoices/${currentDraft.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Continue
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Enhanced recent activity
|
||||
async function RecentActivity({
|
||||
function RecentActivity({
|
||||
recentInvoices,
|
||||
}: {
|
||||
recentInvoices: RecentInvoice[];
|
||||
}) {
|
||||
// Use passed recentInvoices instead of fetching all
|
||||
|
||||
const getStatusStyle = (status: string) => {
|
||||
const getStatusVariant = (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))",
|
||||
};
|
||||
return "default" as const;
|
||||
case "sent":
|
||||
return {
|
||||
backgroundColor: "oklch(var(--chart-1) / 0.1)",
|
||||
borderColor: "oklch(var(--chart-1) / 0.3)",
|
||||
color: "oklch(var(--chart-1))",
|
||||
};
|
||||
return "secondary" as const;
|
||||
case "overdue":
|
||||
return {
|
||||
backgroundColor: "oklch(var(--chart-3) / 0.1)",
|
||||
borderColor: "oklch(var(--chart-3) / 0.3)",
|
||||
color: "oklch(var(--chart-3))",
|
||||
};
|
||||
return "destructive" as const;
|
||||
default:
|
||||
return {
|
||||
backgroundColor: "hsl(var(--muted))",
|
||||
borderColor: "hsl(var(--border))",
|
||||
color: "hsl(var(--muted-foreground))",
|
||||
};
|
||||
return "outline" as const;
|
||||
}
|
||||
};
|
||||
|
||||
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
|
||||
<Card className="h-full">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle>
|
||||
<DashboardCardTitle icon={Calendar}>
|
||||
Recent activity
|
||||
</DashboardCardTitle>
|
||||
</CardTitle>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/dashboard/invoices">
|
||||
<span className="hidden sm:inline">View All</span>
|
||||
<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">
|
||||
<div className="flex flex-col items-center py-8 text-center">
|
||||
<div className="bg-muted mb-4 rounded-2xl p-3">
|
||||
<FileText className="text-muted-foreground h-6 w-6" />
|
||||
</div>
|
||||
<p className="font-medium">No invoices yet</p>
|
||||
<CardDescription className="mt-1 max-w-xs">
|
||||
Your latest invoices will show up here.
|
||||
</CardDescription>
|
||||
<Button asChild variant="outline" className="mt-5">
|
||||
<Link href="/dashboard/invoices/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Your First Invoice
|
||||
Create invoice
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{recentInvoices.map((invoice, _index) => (
|
||||
<div className="space-y-2">
|
||||
{recentInvoices.map((invoice) => (
|
||||
<Link
|
||||
key={invoice.id}
|
||||
href={`/dashboard/invoices/${invoice.id}`}
|
||||
className="block"
|
||||
className="hover:bg-muted/50 border-border/60 flex items-center gap-3 rounded-2xl border p-3 transition-colors"
|
||||
>
|
||||
<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 className="bg-muted rounded-xl p-2">
|
||||
<FileText className="text-muted-foreground h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="truncate text-sm font-medium">
|
||||
#{invoice.invoiceNumber}
|
||||
</p>
|
||||
<span className="shrink-0 font-mono text-sm font-medium tabular-nums">
|
||||
${invoice.totalAmount.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center justify-between gap-2">
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{invoice.client?.name}
|
||||
</p>
|
||||
<Badge
|
||||
variant={getStatusVariant(invoice.status)}
|
||||
className="shrink-0 text-[10px]"
|
||||
>
|
||||
{invoice.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
@@ -420,121 +396,27 @@ async function RecentActivity({
|
||||
);
|
||||
}
|
||||
|
||||
// Loading skeletons
|
||||
function StatsSkeleton() {
|
||||
return (
|
||||
<div className="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="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>
|
||||
);
|
||||
}
|
||||
|
||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||
|
||||
// ... imports
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const session = await getOptionalServerSessionFromHeaders();
|
||||
const firstName = session?.user?.name?.split(" ")[0] ?? "User";
|
||||
|
||||
// Fetch stats centrally
|
||||
const stats = await api.dashboard.getStats();
|
||||
void api.timeEntries.getRunning.prefetch();
|
||||
|
||||
return (
|
||||
<div className="page-enter space-y-6">
|
||||
<DashboardPageLayout>
|
||||
<DashboardPageHeader
|
||||
title={`Welcome back, ${firstName}!`}
|
||||
description="Here's what's happening with your business today"
|
||||
title={`Welcome back, ${firstName}`}
|
||||
description="A snapshot of your invoices, revenue, and work in progress."
|
||||
/>
|
||||
|
||||
<HydrateClient>
|
||||
<Suspense fallback={<StatsSkeleton />}>
|
||||
<DashboardStats stats={stats} />
|
||||
</Suspense>
|
||||
</HydrateClient>
|
||||
|
||||
<HydrateClient>
|
||||
<Suspense fallback={<ChartsSkeleton />}>
|
||||
<ChartsSection stats={stats} />
|
||||
</Suspense>
|
||||
</HydrateClient>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<div className="space-y-6">
|
||||
<HydrateClient>
|
||||
<Suspense fallback={<CardSkeleton />}>
|
||||
<CurrentWork />
|
||||
</Suspense>
|
||||
</HydrateClient>
|
||||
<DashboardStats stats={stats} />
|
||||
<ChartsSection stats={stats} />
|
||||
<DashboardGrid className="lg:grid-cols-2">
|
||||
<div className={cn(dashboardGridClass)}>
|
||||
<CurrentWork currentDraft={stats.currentDraft} />
|
||||
<QuickActions />
|
||||
</div>
|
||||
|
||||
<HydrateClient>
|
||||
<Suspense fallback={<CardSkeleton />}>
|
||||
<RecentActivity recentInvoices={stats.recentInvoices} />
|
||||
</Suspense>
|
||||
</HydrateClient>
|
||||
</div>
|
||||
</div>
|
||||
<RecentActivity recentInvoices={stats.recentInvoices} />
|
||||
</DashboardGrid>
|
||||
</DashboardPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user