"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
import {
DollarSign,
Users,
FileText,
Calendar,
ArrowUpRight,
ArrowDownRight,
Bell,
Search,
MoreHorizontal,
ChevronRight,
} from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { fetchDashboardData, fetchUserRole, type DashboardData } from "@/lib/supabase/dashboard-actions";
import { centsToDecimal, INVOICE_STATUS_LABELS } from "@/lib/supabase/invoicing-types";
// ── Skeleton ─────────────────────────────────────────────────
function DashboardSkeleton() {
return (
{[1, 2, 3, 4].map((i) => (
))}
);
}
// ── Components ───────────────────────────────────────────────
function MetricCard({
title,
value,
change,
changeSuffix,
trend,
comparisonLabel,
Icon,
gradient,
bgSoft,
iconColor,
}: {
title: string;
value: string;
change: string | null;
changeSuffix?: string;
trend: "up" | "down" | "idle";
comparisonLabel?: string | null;
Icon: typeof DollarSign;
gradient: string;
bgSoft: string;
iconColor: string;
}) {
const TrendIcon = trend === "up" ? ArrowUpRight : trend === "down" ? ArrowDownRight : null;
const trendColor = trend === "up" ? "text-emerald-600" : trend === "down" ? "text-red-500" : "text-gray-400";
const trendBg = trend === "up" ? "bg-emerald-50" : trend === "down" ? "bg-red-50" : "bg-gray-100";
return (
{trend !== "idle" && TrendIcon && change && (
{change === "new" ? "New" : `${change}${changeSuffix || "%"}`}
)}
{title}
{value}
{comparisonLabel && {comparisonLabel}
}
);
}
function StatusBadge({ status }: { status: string }) {
const variants: Record = {
paid: "bg-emerald-100 text-emerald-700 border-emerald-200",
pending: "bg-amber-100 text-amber-700 border-amber-200",
overdue: "bg-red-100 text-red-700 border-red-200",
draft: "bg-gray-100 text-gray-600 border-gray-200",
sent: "bg-blue-100 text-blue-700 border-blue-200",
cancelled: "bg-gray-100 text-gray-500 border-gray-200",
active: "bg-emerald-100 text-emerald-700 border-emerald-200",
"on-leave": "bg-gray-100 text-gray-600 border-gray-200",
};
const cls = variants[status] || "bg-gray-100 text-gray-600 border-gray-200";
const label = INVOICE_STATUS_LABELS[status as keyof typeof INVOICE_STATUS_LABELS] || status;
return (
{label}
);
}
function Avatar({ initials, color }: { initials: string; color: string }) {
const colors: Record = {
teal: "bg-teal-500", blue: "bg-blue-500", purple: "bg-purple-500",
orange: "bg-orange-500", pink: "bg-pink-500",
};
return (
{initials}
);
}
const ICON_MAP: Record = {
DollarSign, Users, Calendar, FileText,
};
const avatarColors = ["teal", "blue", "purple", "orange", "pink"];
function formatDate(dateStr: string): string {
const d = new Date(dateStr + "T00:00:00");
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
// ── Main Dashboard ───────────────────────────────────────────
export default function DashboardPage() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [userRole, setUserRole] = useState("employee");
const router = useRouter();
useEffect(() => {
let cancelled = false;
Promise.all([
fetchDashboardData(),
fetchUserRole(),
])
.then(([d, role]) => {
if (!cancelled) {
setData(d);
setUserRole(role);
}
})
.catch((err) => {
if (!cancelled) {
console.error("Dashboard error:", err);
const errorMessage = err instanceof Error ? err.message : String(err);
// If no organisation found, redirect to onboarding
if (errorMessage.includes("Organisation error") || errorMessage.includes("No organisation found")) {
router.push("/onboarding");
return;
}
setError(errorMessage);
}
})
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [router]);
const [greeting, setGreeting] = useState("");
useEffect(() => {
const today = new Date();
setGreeting(
today.toLocaleDateString("en-US", {
weekday: "long", month: "long", day: "numeric",
})
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (loading) return ;
if (error) {
return (
);
}
if (!data) return null;
const metrics: {
title: string;
value: string;
change: string | null;
trend: "up" | "down" | "idle";
comparisonLabel?: string | null;
Icon: typeof DollarSign;
gradient: string;
bgSoft: string;
iconColor: string;
}[] = [
{
title: "Cash on hand",
value: `$${(data.metrics.cash_on_hand / 100).toLocaleString()}`,
change: data.trends.cash?.value ?? null,
trend: data.trends.cash ? (data.trends.cash.direction as "up" | "down") : "idle",
comparisonLabel: data.metrics.cash_on_hand > 0 ? data.comparisonLabel : null,
Icon: DollarSign,
gradient: "from-teal-500 to-emerald-600",
bgSoft: "bg-teal-50",
iconColor: "text-teal-600",
},
{
title: "Outstanding invoices",
value: `$${(data.metrics.outstanding_invoices / 100).toLocaleString()}`,
change: data.trends.outstanding?.value ?? null,
trend: data.trends.outstanding ? (data.trends.outstanding.direction as "up" | "down") : "idle",
comparisonLabel: data.metrics.outstanding_invoices > 0 ? data.comparisonLabel : null,
Icon: FileText,
gradient: "from-blue-500 to-indigo-600",
bgSoft: "bg-blue-50",
iconColor: "text-blue-600",
},
{
title: "Payroll due",
value: `$${(data.metrics.payroll_due / 100).toLocaleString()}`,
change: null,
trend: "idle",
comparisonLabel: data.payrollDueDate ? `due ${data.payrollDueDate}` : null,
Icon: Users,
gradient: "from-purple-500 to-violet-600",
bgSoft: "bg-purple-50",
iconColor: "text-purple-600",
},
{
title: "Headcount",
value: String(data.metrics.headcount),
change: null,
trend: "idle",
comparisonLabel: null,
Icon: Users,
gradient: "from-orange-500 to-amber-600",
bgSoft: "bg-orange-50",
iconColor: "text-orange-600",
},
];
return (
{/* Header */}
{/* Metrics */}
{metrics.map((m, i) => )}
{/* Revenue Chart — visible to owner, admin, accountant */}
{(userRole === "owner" || userRole === "admin" || userRole === "accountant") && (
Revenue Overview
Revenue vs expenses over the last 7 months
{data.revenueData.some((d) => d.revenue > 0) && (
+22.4% vs last period
)}
{data.revenueData.every((d) => d.revenue === 0) ? (
No revenue data yet
Create and mark invoices as paid to see your revenue trend
) : (
v > 0 ? `$${v / 1000}k` : ""} />
[`$${Number(value).toLocaleString()}`, ""]}
/>
)}
)}
{/* Two Columns */}
{/* Recent Invoices — visible to owner, admin, accountant */}
{(userRole === "owner" || userRole === "admin" || userRole === "accountant") && (
Recent Invoices
View all
| Company |
Due |
Amount |
Status |
{data.recentInvoices.map((invoice) => {
const displayTotal = invoice.total_cents > 0
? `$${centsToDecimal(invoice.total_cents)}`
: invoice.amount != null ? `$${invoice.amount.toFixed(2)}` : "$0.00";
return (
| {invoice.client_name} |
{formatDate(invoice.due_date)} |
{displayTotal} |
|
);
})}
)}
{/* Upcoming Events — visible to all roles */}
Upcoming
{data.upcomingEvents.map((event, index) => {
const Icon = ICON_MAP[event.icon] || Calendar;
return (
{event.title}
{event.date}
);
})}
{/* Bottom Row — People Snapshot (owner/admin/hr) */}
{(userRole === "owner" || userRole === "admin" || userRole === "hr_manager") && (
{/* People Snapshot */}
People Snapshot
Manage
{data.peopleSnapshot.length === 0 ? (
) : (
{data.peopleSnapshot.map((person) => (
{person.name}
{person.department}{person.job_title ? ` · ${person.job_title}` : ""}
))}
)}
)}
{/* Budget vs Actuals — visible to owner, admin, accountant */}
{(userRole === "owner" || userRole === "admin" || userRole === "accountant") && data.budgetData.length > 0 && data.budgetData.some((b) => b.budget > 0) && (
Budget vs Actuals
Department-level monthly salary spend
{data.budgetData.map((item, index) => {
const percentage = item.budget > 0 ? (item.actual / item.budget) * 100 : 0;
const isOverBudget = percentage > 100;
return (
{item.category}
${(item.actual / 1000).toFixed(1)}k / ${(item.budget / 1000).toFixed(1)}k
{percentage.toFixed(0)}%
);
})}
)}
);
}