Changes
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
co-authored by
renee-png
parent
2c63008bc7
commit
7b7db7cd73
+232
-65
@@ -1,14 +1,18 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, 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 { Badge } from "@/components/ui/badge";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { Briefcase, Users, Receipt, Clock, ArrowRight } from "lucide-react";
|
||||
import {
|
||||
Briefcase, Receipt, Clock, ArrowRight, CalendarDays, Plus, FileText,
|
||||
CheckSquare, UserPlus, DollarSign, Wallet, AlertCircle, Activity,
|
||||
} from "lucide-react";
|
||||
import { formatCurrency, formatDate, statusBadgeClass } from "@/lib/format";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { differenceInCalendarDays, format, startOfDay, startOfMonth, endOfMonth, subDays } from "date-fns";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
component: () => (
|
||||
@@ -18,78 +22,253 @@ export const Route = createFileRoute("/")({
|
||||
),
|
||||
});
|
||||
|
||||
interface Stats {
|
||||
activeCases: number;
|
||||
clients: number;
|
||||
unpaidInvoices: number;
|
||||
unbilledHours: number;
|
||||
interface Financial {
|
||||
trustBalance: number;
|
||||
paidThisMonth: number;
|
||||
overdueTotal: number;
|
||||
unsentTotal: number;
|
||||
}
|
||||
interface MyTaskCounts {
|
||||
dueToday: number;
|
||||
overdue: number;
|
||||
incomplete: number;
|
||||
}
|
||||
interface OpenCases {
|
||||
open: number;
|
||||
newLast30: number;
|
||||
closedLast30: number;
|
||||
}
|
||||
|
||||
function Dashboard() {
|
||||
const { user } = useAuth();
|
||||
const [stats, setStats] = useState<Stats>({ activeCases: 0, clients: 0, unpaidInvoices: 0, unbilledHours: 0 });
|
||||
const [financial, setFinancial] = useState<Financial>({ trustBalance: 0, paidThisMonth: 0, overdueTotal: 0, unsentTotal: 0 });
|
||||
const [myTasks, setMyTasks] = useState<MyTaskCounts>({ dueToday: 0, overdue: 0, incomplete: 0 });
|
||||
const [openCases, setOpenCases] = useState<OpenCases>({ open: 0, newLast30: 0, closedLast30: 0 });
|
||||
const [todaysEvents, setTodaysEvents] = useState<any[]>([]);
|
||||
const [recentCases, setRecentCases] = useState<any[]>([]);
|
||||
const [recentInvoices, setRecentInvoices] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.id) return;
|
||||
(async () => {
|
||||
const [casesRes, clientsRes, invoicesRes, timeRes, recentCasesRes, recentInvRes] = await Promise.all([
|
||||
setLoading(true);
|
||||
const today = startOfDay(new Date());
|
||||
const todayStr = format(today, "yyyy-MM-dd");
|
||||
const monthStart = format(startOfMonth(today), "yyyy-MM-dd");
|
||||
const monthEnd = format(endOfMonth(today), "yyyy-MM-dd");
|
||||
const thirtyAgo = format(subDays(today, 30), "yyyy-MM-dd");
|
||||
|
||||
const [
|
||||
trustRes, paymentsRes, invoicesRes,
|
||||
myAssigneeRes,
|
||||
openCasesRes, newCasesRes, closedCasesRes,
|
||||
tasksTodayRes, hearingsTodayRes,
|
||||
recentCasesRes, recentInvRes,
|
||||
] = await Promise.all([
|
||||
supabase.from("trust_ledger_entries").select("entry_type, amount"),
|
||||
supabase.from("invoice_payments").select("amount, paid_on").gte("paid_on", monthStart).lte("paid_on", monthEnd),
|
||||
supabase.from("invoices").select("id, total, amount_paid, status, due_date"),
|
||||
supabase.from("task_assignees").select("task_id, task:tasks(id, status, due_date)").eq("user_id", user.id),
|
||||
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", { count: "exact", head: true }).gte("opened_at", thirtyAgo),
|
||||
supabase.from("cases").select("id", { count: "exact", head: true }).gte("closed_at", thirtyAgo).not("closed_at", "is", null),
|
||||
supabase.from("tasks").select("id, title, due_date, case_id, case:cases(case_number, title)").eq("status", "incomplete").eq("due_date", todayStr),
|
||||
supabase.from("cases").select("id, title, case_number, next_hearing_date, next_hearing_notes").eq("next_hearing_date", todayStr),
|
||||
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);
|
||||
// Trust balance
|
||||
const trustBalance = (trustRes.data ?? []).reduce((s, e: any) => {
|
||||
return s + (e.entry_type === "deposit" ? Number(e.amount) : -Number(e.amount));
|
||||
}, 0);
|
||||
|
||||
setStats({
|
||||
activeCases: casesRes.count ?? 0,
|
||||
clients: clientsRes.count ?? 0,
|
||||
unpaidInvoices: unpaid,
|
||||
unbilledHours: hours,
|
||||
// Paid this month
|
||||
const paidThisMonth = (paymentsRes.data ?? []).reduce((s, p: any) => s + Number(p.amount), 0);
|
||||
|
||||
// Overdue + unsent totals (remaining)
|
||||
let overdueTotal = 0;
|
||||
let unsentTotal = 0;
|
||||
for (const inv of (invoicesRes.data ?? []) as any[]) {
|
||||
const remaining = Number(inv.total) - Number(inv.amount_paid);
|
||||
if (remaining <= 0) continue;
|
||||
if (inv.status === "draft") unsentTotal += remaining;
|
||||
if (inv.due_date && inv.due_date < todayStr && inv.status !== "paid") overdueTotal += remaining;
|
||||
}
|
||||
|
||||
// My tasks
|
||||
const myTaskRows = (myAssigneeRes.data ?? []).map((r: any) => r.task).filter(Boolean);
|
||||
let dueToday = 0, overdue = 0, incomplete = 0;
|
||||
for (const t of myTaskRows) {
|
||||
if (t.status !== "incomplete") continue;
|
||||
incomplete++;
|
||||
if (t.due_date) {
|
||||
if (t.due_date === todayStr) dueToday++;
|
||||
else if (t.due_date < todayStr) overdue++;
|
||||
}
|
||||
}
|
||||
|
||||
// Today's events: combine tasks + hearings
|
||||
const evts: any[] = [
|
||||
...((tasksTodayRes.data ?? []) as any[]).map((t) => ({
|
||||
id: `task-${t.id}`, kind: "task", title: t.title,
|
||||
subtitle: t.case ? `${t.case.case_number} · ${t.case.title}` : null,
|
||||
to: t.case_id ? `/cases/${t.case_id}` : "/tasks",
|
||||
})),
|
||||
...((hearingsTodayRes.data ?? []) as any[]).map((c) => ({
|
||||
id: `hearing-${c.id}`, kind: "hearing", title: `Hearing: ${c.title}`,
|
||||
subtitle: c.case_number, to: `/cases/${c.id}`,
|
||||
})),
|
||||
];
|
||||
|
||||
setFinancial({ trustBalance, paidThisMonth, overdueTotal, unsentTotal });
|
||||
setMyTasks({ dueToday, overdue, incomplete });
|
||||
setOpenCases({
|
||||
open: openCasesRes.count ?? 0,
|
||||
newLast30: newCasesRes.count ?? 0,
|
||||
closedLast30: closedCasesRes.count ?? 0,
|
||||
});
|
||||
setTodaysEvents(evts);
|
||||
setRecentCases(recentCasesRes.data ?? []);
|
||||
setRecentInvoices(recentInvRes.data ?? []);
|
||||
setLoading(false);
|
||||
})();
|
||||
}, [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" },
|
||||
const quickActions = useMemo(() => [
|
||||
{ label: "Add task", icon: CheckSquare, to: "/tasks" },
|
||||
{ label: "Add case", icon: Briefcase, to: "/cases/new" },
|
||||
{ label: "Add contact", icon: UserPlus, to: "/contacts" },
|
||||
{ label: "Create invoice", icon: Receipt, to: "/invoices" },
|
||||
{ label: "Add time", icon: Clock, to: "/tasks" },
|
||||
{ label: "Add expense", icon: DollarSign, to: "/cases" },
|
||||
], []);
|
||||
|
||||
const financialCards = [
|
||||
{ label: "Trust account balance", value: formatCurrency(financial.trustBalance), tone: "bg-muted/40", icon: Wallet, to: "/clients" },
|
||||
{ label: "Invoices paid this mo.", value: formatCurrency(financial.paidThisMonth), tone: "bg-emerald-500/10", icon: Receipt, to: "/invoices" },
|
||||
{ label: "Overdue invoice total", value: formatCurrency(financial.overdueTotal), tone: "bg-destructive/10", icon: AlertCircle, to: "/invoices" },
|
||||
{ label: "Unsent invoice total", value: formatCurrency(financial.unsentTotal), tone: "bg-muted/40", icon: FileText, 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>
|
||||
))}
|
||||
{/* Quick actions */}
|
||||
<Card className="mb-6">
|
||||
<CardContent className="p-4">
|
||||
<div className="text-xs uppercase tracking-wider text-muted-foreground mb-3">Quick actions</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{quickActions.map((a) => (
|
||||
<Button key={a.label} asChild variant="outline" size="sm">
|
||||
<Link to={a.to}><a.icon className="h-3.5 w-3.5 mr-1.5" />{a.label}</Link>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Financial + My tasks */}
|
||||
<div className="grid lg:grid-cols-[2fr_1fr] gap-6 mb-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Financial overview</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{financialCards.map((c) => (
|
||||
<Link key={c.label} to={c.to}>
|
||||
<div className={`rounded-lg p-4 ${c.tone} hover:opacity-80 transition-opacity`}>
|
||||
<div className="text-xs text-muted-foreground mb-1">{c.label}</div>
|
||||
<div className="font-serif text-2xl">{loading ? "—" : c.value}</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">My tasks</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Link to="/tasks" className="rounded-lg p-4 bg-amber-500/10 hover:opacity-80 transition-opacity">
|
||||
<div className="text-xs text-muted-foreground">Due today</div>
|
||||
<div className="font-serif text-2xl">{myTasks.dueToday}</div>
|
||||
</Link>
|
||||
<Link to="/tasks" className="rounded-lg p-4 bg-destructive/10 hover:opacity-80 transition-opacity">
|
||||
<div className="text-xs text-muted-foreground">Overdue</div>
|
||||
<div className="font-serif text-2xl">{myTasks.overdue}</div>
|
||||
</Link>
|
||||
<Link to="/tasks" className="rounded-lg p-4 bg-muted/40 hover:opacity-80 transition-opacity">
|
||||
<div className="text-xs text-muted-foreground">Incomplete</div>
|
||||
<div className="font-serif text-2xl">{myTasks.incomplete}</div>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-2 gap-6">
|
||||
<Card className="border-border/60">
|
||||
{/* Open cases + Today's events */}
|
||||
<div className="grid lg:grid-cols-[2fr_1fr] gap-6 mb-6">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="font-serif text-lg">Recent cases</CardTitle>
|
||||
<CardTitle className="text-base">Open 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>
|
||||
<div className="font-serif text-5xl mb-4">{openCases.open}</div>
|
||||
<div className="space-y-2 text-sm border-t pt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">New cases in last 30 days</span>
|
||||
<span className="font-medium">{openCases.newLast30}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">Cases closed in last 30 days</span>
|
||||
<span className="font-medium">{openCases.closedLast30}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base">Today's events</CardTitle>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link to="/calendar">View all <ArrowRight className="h-3 w-3 ml-1" /></Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{todaysEvents.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Nothing scheduled today.</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{todaysEvents.map((e) => (
|
||||
<Link key={e.id} to={e.to} className="flex items-start gap-2 py-2 px-2 -mx-2 rounded-md hover:bg-muted/60">
|
||||
<CalendarDays className="h-4 w-4 mt-0.5 text-muted-foreground shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium truncate">{e.title}</div>
|
||||
{e.subtitle && <div className="text-xs text-muted-foreground truncate">{e.subtitle}</div>}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Recent cases & invoices */}
|
||||
<div className="grid lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base">Recent cases</CardTitle>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link to="/cases">View all <ArrowRight className="h-3 w-3 ml-1" /></Link>
|
||||
</Button>
|
||||
@@ -97,17 +276,11 @@ function Dashboard() {
|
||||
<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"
|
||||
>
|
||||
<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 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>
|
||||
@@ -115,9 +288,9 @@ function Dashboard() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/60">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="font-serif text-lg">Recent invoices</CardTitle>
|
||||
<CardTitle className="text-base">Recent invoices</CardTitle>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link to="/invoices">View all <ArrowRight className="h-3 w-3 ml-1" /></Link>
|
||||
</Button>
|
||||
@@ -125,17 +298,11 @@ function Dashboard() {
|
||||
<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"
|
||||
>
|
||||
<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 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>
|
||||
|
||||
Reference in New Issue
Block a user