feat: Implement a new dashboard shell with animated background, refactor dashboard data fetching into a dedicated API route, and introduce new UI components.**

This commit is contained in:
2025-12-10 03:16:36 -05:00
parent ca6484aea5
commit 39fdf16280
24 changed files with 767 additions and 412 deletions
@@ -3,6 +3,7 @@
import {
TrendingDown,
TrendingUp,
Minus,
DollarSign,
Clock,
Users,
@@ -15,7 +16,7 @@ interface AnimatedStatsCardProps {
title: string;
value: string;
change: string;
trend: "up" | "down";
trend: "up" | "down" | "neutral";
iconName: IconName;
description: string;
delay?: number;
@@ -42,8 +43,13 @@ export function AnimatedStatsCard({
numericValue,
}: AnimatedStatsCardProps) {
const Icon = iconMap[iconName];
const TrendIcon = trend === "up" ? TrendingUp : TrendingDown;
let TrendIcon = Minus;
if (trend === "up") TrendIcon = TrendingUp;
if (trend === "down") TrendIcon = TrendingDown;
const isPositive = trend === "up";
const isNeutral = trend === "neutral";
// For now, always use the formatted value prop to ensure correct display
// Animation can be added back once the basic display is working correctly
@@ -65,9 +71,11 @@ export function AnimatedStatsCard({
<div
className="flex items-center space-x-1 text-xs"
style={{
color: isPositive
? "oklch(var(--chart-2))"
: "oklch(var(--chart-3))",
color: isNeutral
? "hsl(var(--muted-foreground))"
: isPositive
? "oklch(var(--chart-2))"
: "oklch(var(--chart-3))",
}}
>
<TrendIcon className="h-3 w-3" />
+10 -45
View File
@@ -8,8 +8,6 @@ import {
XAxis,
YAxis,
} from "recharts";
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
import type { StoredInvoiceStatus } from "~/types/invoice";
import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider";
interface Invoice {
@@ -21,49 +19,16 @@ interface Invoice {
}
interface RevenueChartProps {
invoices: Invoice[];
data: {
month: string;
revenue: number;
monthLabel: string;
}[];
}
export function RevenueChart({ invoices }: RevenueChartProps) {
// Process invoice data to create monthly revenue data
const monthlyData = invoices
.filter(
(invoice) =>
getEffectiveInvoiceStatus(
invoice.status as StoredInvoiceStatus,
invoice.dueDate,
) === "paid",
)
.reduce(
(acc, invoice) => {
const date = new Date(invoice.issueDate);
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
acc[monthKey] ??= {
month: monthKey,
revenue: 0,
count: 0,
};
acc[monthKey].revenue += invoice.totalAmount;
acc[monthKey].count += 1;
return acc;
},
{} as Record<string, { month: string; revenue: number; count: number }>,
);
// Convert to array and sort by month
const chartData = Object.values(monthlyData)
.sort((a, b) => a.month.localeCompare(b.month))
.slice(-6) // Show last 6 months
.map((item) => ({
...item,
monthLabel: new Date(item.month + "-01").toLocaleDateString("en-US", {
month: "short",
year: "2-digit",
}),
}));
export function RevenueChart({ data }: RevenueChartProps) {
// Use data directly
const chartData = data;
const formatCurrency = (value: number) => {
return new Intl.NumberFormat("en-US", {
@@ -80,7 +45,7 @@ export function RevenueChart({ invoices }: RevenueChartProps) {
label,
}: {
active?: boolean;
payload?: Array<{ payload: { revenue: number; count: number } }>;
payload?: Array<{ payload: { revenue: number } }>;
label?: string;
}) => {
if (active && payload?.length) {
@@ -92,7 +57,7 @@ export function RevenueChart({ invoices }: RevenueChartProps) {
Revenue: {formatCurrency(data.revenue)}
</p>
<p className="text-muted-foreground text-sm">
{data.count} invoice{data.count !== 1 ? "s" : ""}
{/* Count not available in aggregated view currently */}
</p>
</div>
);
@@ -90,8 +90,8 @@ export function ClientsDataTable({
const client = row.original;
return (
<div className="flex items-center gap-3">
<div className="bg-status-info-muted hidden p-2 sm:flex">
<UserPlus className="text-status-info h-4 w-4" />
<div className="bg-primary/10 hidden p-2 sm:flex">
<UserPlus className="text-primary h-4 w-4" />
</div>
<div className="min-w-0">
<p className="truncate font-medium">{client.name}</p>
+2 -1
View File
@@ -4,10 +4,11 @@ import { PageHeader } from "~/components/layout/page-header";
import { Button } from "~/components/ui/button";
import { HydrateClient } from "~/trpc/server";
import { ClientsTable } from "./_components/clients-table";
import { Card, CardContent } from "~/components/ui/card";
export default async function ClientsPage() {
return (
<div className="page-enter space-y-8">
<div className="page-enter space-y-6">
<PageHeader
title="Clients"
description="Manage your clients and their information."
@@ -16,7 +16,7 @@ import {
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import { Eye, Edit, Trash2 } from "lucide-react";
import { Eye, Edit, Trash2, FileText } from "lucide-react";
import { api } from "~/trpc/react";
import { toast } from "sonner";
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
@@ -130,13 +130,18 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
cell: ({ row }) => {
const invoice = row.original;
return (
<div className="max-w-[80px] min-w-0 sm:max-w-[200px] lg:max-w-[300px]">
<p className="truncate font-medium">
{invoice.client?.name ?? "—"}
</p>
<p className="text-muted-foreground truncate text-xs sm:text-sm">
{invoice.invoiceNumber}
</p>
<div className="flex items-center gap-3">
<div className="bg-primary/10 hidden p-2 sm:flex">
<FileText className="text-primary h-4 w-4" />
</div>
<div className="max-w-[80px] min-w-0 sm:max-w-[200px] lg:max-w-[300px]">
<p className="truncate font-medium">
{invoice.client?.name ?? "—"}
</p>
<p className="text-muted-foreground truncate text-xs sm:text-sm">
{invoice.invoiceNumber}
</p>
</div>
</div>
);
},
+2 -1
View File
@@ -6,6 +6,7 @@ import { PageHeader } from "~/components/layout/page-header";
import { Plus, Upload } from "lucide-react";
import { InvoicesDataTable } from "./_components/invoices-data-table";
import { DataTableSkeleton } from "~/components/data/data-table";
import { Card, CardContent } from "~/components/ui/card";
// Invoices Table Component
async function InvoicesTable() {
@@ -16,7 +17,7 @@ async function InvoicesTable() {
export default async function InvoicesPage() {
return (
<div className="page-enter space-y-8">
<div className="page-enter space-y-6">
<PageHeader
title="Invoices"
description="Manage your invoices and track payments"
+2 -23
View File
@@ -1,30 +1,9 @@
import { Navbar } from "~/components/layout/navbar";
import { Sidebar } from "~/components/layout/sidebar";
import { DashboardBreadcrumbs } from "~/components/navigation/dashboard-breadcrumbs";
import { DashboardShell } from "~/components/layout/dashboard-shell";
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="bg-dashboard relative min-h-screen">
<Navbar />
<Sidebar />
{/* Mobile layout - no left margin */}
<main className="relative z-10 min-h-screen pt-16 md:hidden">
<div className="bg-background px-4 pt-4 pb-6 sm:px-6">
<DashboardBreadcrumbs />
{children}
</div>
</main>
{/* Desktop layout - with sidebar margin */}
<main className="relative z-10 hidden min-h-screen pt-16 md:ml-64 md:block">
<div className="bg-background px-6 pt-6 pb-6">
<DashboardBreadcrumbs />
{children}
</div>
</main>
</div>
);
return <DashboardShell>{children}</DashboardShell>;
}
+50 -178
View File
@@ -26,131 +26,10 @@ import { MonthlyMetricsChart } from "~/app/dashboard/_components/monthly-metrics
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&apos;s what&apos;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;
function DashboardStats({ stats }: { stats: any }) { // TODO: Import RouterOutput type
const formatTrend = (value: number, isCount = false) => {
if (isCount) {
return value > 0 ? `+${value}` : value.toString();
@@ -158,66 +37,52 @@ async function DashboardStats() {
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 = [
const statCards = [
{
title: "Total Revenue",
value: `$${totalRevenue.toLocaleString("en-US", { minimumFractionDigits: 2 })}`,
numericValue: totalRevenue,
value: `$${stats.totalRevenue.toLocaleString("en-US", { minimumFractionDigits: 2 })}`,
numericValue: stats.totalRevenue,
isCurrency: true,
change: formatTrend(revenueChange),
trend: revenueChange >= 0 ? ("up" as const) : ("down" as const),
change: formatTrend(stats.revenueChange),
trend: stats.revenueChange >= 0 ? ("up" as const) : ("down" as const),
iconName: "DollarSign" as const,
description: `From ${paidInvoices.length} paid invoices`,
description: "Total collected revenue",
},
{
title: "Pending Amount",
value: `$${pendingAmount.toLocaleString("en-US", { minimumFractionDigits: 2 })}`,
numericValue: pendingAmount,
value: `$${stats.pendingAmount.toLocaleString("en-US", { minimumFractionDigits: 2 })}`,
numericValue: stats.pendingAmount,
isCurrency: true,
change: formatTrend(pendingChange),
trend: pendingChange >= 0 ? ("up" as const) : ("down" as const),
change: "0%", // TODO: Calculate pending change if needed
trend: "neutral" as const,
iconName: "Clock" as const,
description: `${pendingInvoices.length} invoices awaiting payment`,
description: "Invoices awaiting payment",
},
{
title: "Active Clients",
value: totalClients.toString(),
numericValue: totalClients,
value: stats.totalClients.toString(),
numericValue: stats.totalClients,
isCurrency: false,
change: formatTrend(clientChange, true),
trend: clientChange >= 0 ? ("up" as const) : ("down" as const),
change: "0", // TODO: Calculate client change if needed
trend: "neutral" as const,
iconName: "Users" as const,
description: "Total registered clients",
},
{
title: "Overdue Invoices",
value: overdueInvoices.length.toString(),
numericValue: overdueInvoices.length,
value: stats.overdueCount.toString(),
numericValue: stats.overdueCount,
isCurrency: false,
change: formatTrend(overdueChange, true),
trend: overdueChange <= 0 ? ("up" as const) : ("down" as const),
change: "0", // TODO: Calculate overdue change if needed
trend: "neutral" 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) => (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
{statCards.map((stat, index) => (
<AnimatedStatsCard
key={stat.title}
title={stat.title}
@@ -236,11 +101,14 @@ async function DashboardStats() {
}
// Charts section
async function ChartsSection() {
async function ChartsSection({ stats }: { stats: any }) {
// 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();
return (
<div className="mb-8 grid grid-cols-1 gap-6 lg:grid-cols-2">
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* Revenue Trend Chart */}
<Card className="lg:col-span-2">
<CardHeader>
@@ -250,7 +118,7 @@ async function ChartsSection() {
</CardTitle>
</CardHeader>
<CardContent>
<RevenueChart invoices={invoices} />
<RevenueChart data={stats.revenueChartData} />
</CardContent>
</Card>
@@ -441,14 +309,8 @@ async function CurrentWork() {
}
// 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);
async function RecentActivity({ recentInvoices }: { recentInvoices: any[] }) {
// Use passed recentInvoices instead of fetching all
const getStatusStyle = (status: string) => {
switch (status) {
@@ -558,7 +420,7 @@ async function RecentActivity() {
// Loading skeletons
function StatsSkeleton() {
return (
<div className="mb-8 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
<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">
@@ -577,7 +439,7 @@ function StatsSkeleton() {
function ChartsSkeleton() {
return (
<div className="mb-8 grid grid-cols-1 gap-6 lg:grid-cols-2">
<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" />
@@ -623,30 +485,40 @@ function CardSkeleton() {
);
}
import { DashboardPageHeader } from "~/components/layout/page-header";
// ... imports
export default async function DashboardPage() {
const session = await auth.api.getSession({
headers: await headers(),
});
const firstName = session?.user?.name?.split(" ")[0] ?? "User";
// Fetch stats centrally
const stats = await api.dashboard.getStats();
return (
<div className="page-enter space-y-8">
<DashboardHero firstName={firstName} />
<div className="page-enter space-y-6">
<DashboardPageHeader
title={`Welcome back, ${firstName}!`}
description="Here's what's happening with your business today"
/>
<HydrateClient>
<Suspense fallback={<StatsSkeleton />}>
<DashboardStats />
<DashboardStats stats={stats} />
</Suspense>
</HydrateClient>
<HydrateClient>
<Suspense fallback={<ChartsSkeleton />}>
<ChartsSection />
<ChartsSection stats={stats} />
</Suspense>
</HydrateClient>
<div className="grid grid-cols-1 gap-8 lg:grid-cols-2">
<div className="space-y-8">
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div className="space-y-6">
<HydrateClient>
<Suspense fallback={<CardSkeleton />}>
<CurrentWork />
@@ -657,7 +529,7 @@ export default async function DashboardPage() {
<HydrateClient>
<Suspense fallback={<CardSkeleton />}>
<RecentActivity />
<RecentActivity recentInvoices={stats.recentInvoices} />
</Suspense>
</HydrateClient>
</div>
+11 -6
View File
@@ -3,21 +3,26 @@ import { HydrateClient } from "~/trpc/server";
import { PageHeader } from "~/components/layout/page-header";
import { DataTableSkeleton } from "~/components/data/data-table";
import { SettingsContent } from "./_components/settings-content";
import { Card, CardContent } from "~/components/ui/card";
export default async function SettingsPage() {
return (
<div className="page-enter space-y-8">
<div className="page-enter space-y-6">
<PageHeader
title="Settings"
description="Manage your account preferences and data"
variant="gradient"
/>
<HydrateClient>
<Suspense fallback={<DataTableSkeleton columns={1} rows={4} />}>
<SettingsContent />
</Suspense>
</HydrateClient>
<Card>
<CardContent className="p-6">
<HydrateClient>
<Suspense fallback={<DataTableSkeleton columns={1} rows={4} />}>
<SettingsContent />
</Suspense>
</HydrateClient>
</CardContent>
</Card>
</div>
);
}