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:
2026-06-26 03:08:22 -04:00
co-authored by Cursor
parent 6ec26a4a0d
commit c53f2e6c4d
79 changed files with 2871 additions and 3576 deletions
+190 -308
View File
@@ -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&apos;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>
);
}