Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
152 lines
6.7 KiB
TypeScript
152 lines
6.7 KiB
TypeScript
import { createFileRoute, Link } from "@tanstack/react-router";
|
|
import { useEffect, useState } from "react";
|
|
import { ProtectedLayout } from "@/components/protected-layout";
|
|
import { PageContainer, PageHeader } from "@/components/app-shell";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { supabase } from "@/integrations/supabase/client";
|
|
import { useAuth } from "@/lib/auth";
|
|
import { Briefcase, Users, Receipt, Clock, ArrowRight } from "lucide-react";
|
|
import { formatCurrency, formatDate, statusBadgeClass } from "@/lib/format";
|
|
import { Badge } from "@/components/ui/badge";
|
|
|
|
export const Route = createFileRoute("/")({
|
|
component: () => (
|
|
<ProtectedLayout>
|
|
<Dashboard />
|
|
</ProtectedLayout>
|
|
),
|
|
});
|
|
|
|
interface Stats {
|
|
activeCases: number;
|
|
clients: number;
|
|
unpaidInvoices: number;
|
|
unbilledHours: number;
|
|
}
|
|
|
|
function Dashboard() {
|
|
const { user } = useAuth();
|
|
const [stats, setStats] = useState<Stats>({ activeCases: 0, clients: 0, unpaidInvoices: 0, unbilledHours: 0 });
|
|
const [recentCases, setRecentCases] = useState<any[]>([]);
|
|
const [recentInvoices, setRecentInvoices] = useState<any[]>([]);
|
|
|
|
useEffect(() => {
|
|
(async () => {
|
|
const [casesRes, clientsRes, invoicesRes, timeRes, recentCasesRes, recentInvRes] = await Promise.all([
|
|
supabase.from("cases").select("id", { count: "exact", head: true }).in("status", ["intake", "active", "on_hold"]),
|
|
supabase.from("clients").select("id", { count: "exact", head: true }),
|
|
supabase.from("invoices").select("total, amount_paid").in("status", ["sent", "overdue"]),
|
|
supabase.from("time_entries").select("hours").is("invoice_id", null).eq("billable", true),
|
|
supabase.from("cases").select("id, case_number, title, status, updated_at, client:clients(name)").order("updated_at", { ascending: false }).limit(5),
|
|
supabase.from("invoices").select("id, invoice_number, total, status, issue_date, client:clients(name)").order("created_at", { ascending: false }).limit(5),
|
|
]);
|
|
|
|
const unpaid = (invoicesRes.data ?? []).reduce(
|
|
(sum, i) => sum + (Number(i.total) - Number(i.amount_paid)),
|
|
0,
|
|
);
|
|
const hours = (timeRes.data ?? []).reduce((s, t) => s + Number(t.hours), 0);
|
|
|
|
setStats({
|
|
activeCases: casesRes.count ?? 0,
|
|
clients: clientsRes.count ?? 0,
|
|
unpaidInvoices: unpaid,
|
|
unbilledHours: hours,
|
|
});
|
|
setRecentCases(recentCasesRes.data ?? []);
|
|
setRecentInvoices(recentInvRes.data ?? []);
|
|
})();
|
|
}, [user?.id]);
|
|
|
|
const cards = [
|
|
{ label: "Active cases", value: stats.activeCases, icon: Briefcase, to: "/cases" },
|
|
{ label: "Clients", value: stats.clients, icon: Users, to: "/clients" },
|
|
{ label: "Unbilled hours", value: stats.unbilledHours.toFixed(1), icon: Clock, to: "/cases" },
|
|
{ label: "Outstanding A/R", value: formatCurrency(stats.unpaidInvoices), icon: Receipt, to: "/invoices" },
|
|
];
|
|
|
|
return (
|
|
<PageContainer>
|
|
<PageHeader title="Dashboard" description="Practice snapshot and recent activity." />
|
|
|
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
|
{cards.map((c) => (
|
|
<Link key={c.label} to={c.to} className="group">
|
|
<Card className="border-border/60 hover:border-primary/40 transition-colors">
|
|
<CardContent className="p-5">
|
|
<div className="flex items-start justify-between mb-3">
|
|
<div className="text-xs uppercase tracking-wider text-muted-foreground">{c.label}</div>
|
|
<c.icon className="h-4 w-4 text-muted-foreground group-hover:text-primary" />
|
|
</div>
|
|
<div className="font-serif text-3xl text-foreground">{c.value}</div>
|
|
</CardContent>
|
|
</Card>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
|
|
<div className="grid lg:grid-cols-2 gap-6">
|
|
<Card className="border-border/60">
|
|
<CardHeader className="flex flex-row items-center justify-between">
|
|
<CardTitle className="font-serif text-lg">Recent cases</CardTitle>
|
|
<Button asChild variant="ghost" size="sm">
|
|
<Link to="/cases">View all <ArrowRight className="h-3 w-3 ml-1" /></Link>
|
|
</Button>
|
|
</CardHeader>
|
|
<CardContent className="space-y-1">
|
|
{recentCases.length === 0 && <p className="text-sm text-muted-foreground">No cases yet.</p>}
|
|
{recentCases.map((c) => (
|
|
<Link
|
|
key={c.id}
|
|
to="/cases/$caseId"
|
|
params={{ caseId: c.id }}
|
|
className="flex items-center justify-between py-2.5 px-2 rounded-md hover:bg-muted/60 -mx-2"
|
|
>
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-medium truncate">{c.title}</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
{c.case_number} · {c.client?.name}
|
|
</div>
|
|
</div>
|
|
<Badge variant="outline" className={statusBadgeClass(c.status)}>{c.status.replace("_", " ")}</Badge>
|
|
</Link>
|
|
))}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="border-border/60">
|
|
<CardHeader className="flex flex-row items-center justify-between">
|
|
<CardTitle className="font-serif text-lg">Recent invoices</CardTitle>
|
|
<Button asChild variant="ghost" size="sm">
|
|
<Link to="/invoices">View all <ArrowRight className="h-3 w-3 ml-1" /></Link>
|
|
</Button>
|
|
</CardHeader>
|
|
<CardContent className="space-y-1">
|
|
{recentInvoices.length === 0 && <p className="text-sm text-muted-foreground">No invoices yet.</p>}
|
|
{recentInvoices.map((i) => (
|
|
<Link
|
|
key={i.id}
|
|
to="/invoices/$invoiceId"
|
|
params={{ invoiceId: i.id }}
|
|
className="flex items-center justify-between py-2.5 px-2 rounded-md hover:bg-muted/60 -mx-2"
|
|
>
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-medium">{i.invoice_number}</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
{i.client?.name} · {formatDate(i.issue_date)}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-medium">{formatCurrency(i.total)}</span>
|
|
<Badge variant="outline" className={statusBadgeClass(i.status)}>{i.status}</Badge>
|
|
</div>
|
|
</Link>
|
|
))}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</PageContainer>
|
|
);
|
|
}
|