"use server"; import { createAdminClient } from "@/lib/supabase/server"; import { auth } from "@clerk/nextjs/server"; async function getOrganisationId(): Promise { const { userId } = await auth(); if (!userId) throw new Error("Authentication failed: User not logged in"); const supabase = await createAdminClient(); const { data: member, error } = await supabase .from("organisation_members") .select("organisation_id") .eq("user_id", userId) .single(); if (error || !member) { throw new Error("Organisation error: No membership found for this user"); } return member.organisation_id; } export interface DashboardData { metrics: { cash_on_hand: number; outstanding_invoices: number; payroll_due: number; headcount: number; }; payrollDueDate: string | null; trends: { cash: { value: string; direction: "up" | "down" } | null; outstanding: { value: string; direction: "up" | "down" } | null; payroll: null; headcount: null; }; comparisonLabel: string; revenueData: { month: string; revenue: number; expenses: number }[]; recentInvoices: { id: string; client_name: string; total_cents: number; amount: number | null; status: string; due_date: string; }[]; peopleSnapshot: { id: string; name: string; department: string; status: string; initials: string; job_title: string | null; }[]; budgetData: { category: string; budget: number; actual: number; color: string; }[]; upcomingEvents: { title: string; date: string; dateRaw: string; icon: string; color: string; bg: string; }[]; } function cents(amount: number): number { return Math.round(amount * 100); } function invCents(inv: any): number { return inv.total_cents > 0 ? inv.total_cents : cents(inv.amount || 0); } export async function fetchDashboardData(): Promise { const orgId = await getOrganisationId(); const supabase = await createAdminClient(); const now = new Date(); const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; // ── 1. Cash on hand: paid invoices minus recorded expenses ── const { data: paidInvoices } = await supabase .from("invoices") .select("total_cents, amount, status") .eq("organisation_id", orgId) .eq("status", "paid"); const totalRevenueCents = (paidInvoices || []).reduce((s, i) => s + invCents(i), 0); const { data: allExpenses } = await supabase .from("expenses") .select("amount, status") .eq("organisation_id", orgId); const totalExpenseCents = cents( (allExpenses || []) .filter((e) => e.status !== "draft" && e.status !== "rejected") .reduce((s, e) => s + Number(e.amount), 0) ); const cashOnHandCents = Math.max(totalRevenueCents - totalExpenseCents, 0); // ── 2. Outstanding invoices: sent + overdue ── const { data: outstandingInvoices } = await supabase .from("invoices") .select("total_cents, amount, status") .eq("organisation_id", orgId) .in("status", ["sent", "overdue"]); const outstandingTotalCents = (outstandingInvoices || []).reduce((s, i) => s + invCents(i), 0); // ── 3. Payroll due: most recent payroll run ── const { data: recentPayroll } = await supabase .from("payroll_runs") .select("total_net, period_end, status") .eq("organisation_id", orgId) .order("created_at", { ascending: false }) .limit(1); const payrollDueCents = recentPayroll?.[0] ? cents(Number(recentPayroll[0].total_net)) : 0; const payrollDueDate = recentPayroll?.[0]?.period_end ? formatDateShort(recentPayroll[0].period_end) : null; // ── 4. Headcount: active employees ── const { count: headcount } = await supabase .from("employees") .select("*", { count: "exact", head: true }) .eq("organisation_id", orgId) .eq("status", "active"); // ── Recent invoices (last 5) ── const { data: invoices } = await supabase .from("invoices") .select("id, client_name, total_cents, amount, status, due_date") .eq("organisation_id", orgId) .order("created_at", { ascending: false }) .limit(5); const recentInvoices = (invoices || []).map((inv) => ({ id: inv.id, client_name: inv.client_name, total_cents: inv.total_cents, amount: inv.amount, status: inv.status, due_date: inv.due_date, })); // ── 5. Revenue chart: last 6 months real data ── const sixMonthsAgo = new Date(now.getFullYear(), now.getMonth() - 5, 1); const sixMonthsAgoStr = sixMonthsAgo.toISOString().split("T")[0]; // Paid invoices by month const { data: monthlyRevenue } = await supabase .from("invoices") .select("total_cents, amount, status, issue_date, created_at") .eq("organisation_id", orgId) .eq("status", "paid") .gte("issue_date", sixMonthsAgoStr); // Expenses by month const { data: monthlyExpenses } = await supabase .from("expenses") .select("amount, status, expense_date, created_at") .eq("organisation_id", orgId); // Initialise all 6 months const monthlyTotals: Record = {}; for (let i = 0; i < 6; i++) { const d = new Date(sixMonthsAgo); d.setMonth(d.getMonth() + i); monthlyTotals[monthNames[d.getMonth()]] = { revenue: 0, expenses: 0 }; } // Aggregate revenue (monthlyRevenue || []).forEach((inv) => { const dateStr = inv.issue_date || inv.created_at; if (!dateStr) return; const key = monthNames[new Date(dateStr).getMonth()]; if (key in monthlyTotals) { monthlyTotals[key].revenue += invCents(inv); } }); // Aggregate expenses (monthlyExpenses || []) .filter((e) => e.status !== "draft" && e.status !== "rejected") .forEach((exp) => { const dateStr = exp.expense_date || exp.created_at; if (!dateStr) return; const key = monthNames[new Date(dateStr).getMonth()]; if (key in monthlyTotals) { monthlyTotals[key].expenses += cents(Number(exp.amount)); } }); const revenueData = Object.entries(monthlyTotals).map(([month, vals]) => ({ month, revenue: vals.revenue, expenses: vals.expenses, })); // ── 6. People snapshot: 4 most recent employees ── const { data: employees } = await supabase .from("employees") .select("id, first_name, last_name, job_title, department:departments(name), status") .eq("organisation_id", orgId) .order("created_at", { ascending: false }) .limit(4); const peopleSnapshot = (employees || []).map((emp) => { const deptArr = emp.department as unknown as { name: string }[] | null; return { id: emp.id, name: `${emp.first_name} ${emp.last_name}`, department: deptArr?.[0]?.name || "Unassigned", status: emp.status, initials: `${emp.first_name[0]}${emp.last_name[0]}`, job_title: emp.job_title, }; }); // ── Budget vs Actuals (department-level salary spend) ── const { data: deptEmployees } = await supabase .from("employees") .select("salary, department:departments(name), status, pay_frequency") .eq("organisation_id", orgId); const deptCosts: Record = {}; (deptEmployees || []) .filter((e: any) => e.status === "active" && (e.department as any)?.name) .forEach((e: any) => { const dept = e.department.name; const annualSalary = Number(e.salary) || 0; const monthlyCost = e.pay_frequency === "weekly" ? (annualSalary / 52) * 4.33 : e.pay_frequency === "fortnightly" ? (annualSalary / 26) * 2 : annualSalary / 12; if (!deptCosts[dept]) deptCosts[dept] = { budget: 0, actual: 0 }; deptCosts[dept].budget += monthlyCost; deptCosts[dept].actual += monthlyCost; }); const barColors = ["bg-teal-500", "bg-blue-500", "bg-purple-500", "bg-orange-500", "bg-pink-500", "bg-emerald-500"]; const budgetData = Object.entries(deptCosts) .map(([category, vals], i) => ({ category, budget: Math.round(vals.budget), actual: Math.round(vals.actual), color: barColors[i % barColors.length], })) .slice(0, 6); if (budgetData.length === 0) { budgetData.push({ category: "No departments yet", budget: 0, actual: 0, color: "bg-gray-300", }); } // ── Upcoming Events ── const todayStr = now.toISOString().split("T")[0]; const thirtyDaysStr = new Date(now.getTime() + 30 * 86400000).toISOString().split("T")[0]; const events: DashboardData["upcomingEvents"] = []; // Approved leave next 30 days const { data: pendingLeave } = await supabase .from("leave_requests") .select("start_date, employee:employees(first_name, last_name)") .eq("organisation_id", orgId) .eq("status", "approved") .gte("start_date", todayStr) .lte("start_date", thirtyDaysStr) .order("start_date", { ascending: true }) .limit(3); (pendingLeave || []).forEach((lr: any) => { events.push({ title: `${lr.employee?.first_name || "Employee"} on leave`, date: formatDateShort(lr.start_date), dateRaw: lr.start_date, icon: "Calendar", color: "text-amber-600", bg: "bg-amber-100", }); }); // Invoices due next 30 days const { data: upcomingInvoices } = await supabase .from("invoices") .select("client_name, due_date") .eq("organisation_id", orgId) .gte("due_date", todayStr) .lte("due_date", thirtyDaysStr) .in("status", ["sent", "overdue"]) .order("due_date", { ascending: true }) .limit(3); (upcomingInvoices || []).forEach((inv) => { events.push({ title: `Invoice due: ${inv.client_name || "Client"}`, date: formatDateShort(inv.due_date), dateRaw: inv.due_date, icon: "DollarSign", color: "text-teal-600", bg: "bg-teal-100", }); }); // Draft payroll runs const { data: draftPayroll } = await supabase .from("payroll_runs") .select("period_start, period_end") .eq("organisation_id", orgId) .eq("status", "draft") .order("created_at", { ascending: false }) .limit(1); if (draftPayroll?.length) { const p = draftPayroll[0]; events.push({ title: "Payroll run (draft)", date: formatDateShort(p.period_end), dateRaw: p.period_end, icon: "FileText", color: "text-purple-600", bg: "bg-purple-100", }); } // New starters (last 7 days) const sevenDaysAgoStr = new Date(now.getTime() - 7 * 86400000).toISOString().split("T")[0]; const { data: newEmployees } = await supabase .from("employees") .select("first_name, last_name, start_date") .eq("organisation_id", orgId) .gte("start_date", sevenDaysAgoStr) .order("start_date", { ascending: false }) .limit(2); (newEmployees || []).forEach((emp) => { events.push({ title: `New starter: ${emp.first_name} ${emp.last_name}`, date: formatDateShort(emp.start_date), dateRaw: emp.start_date, icon: "Users", color: "text-blue-600", bg: "bg-blue-100", }); }); events.sort((a, b) => a.dateRaw.localeCompare(b.dateRaw)); // ── Month-over-month trends ── const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1); const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1); const currentMonthStr = currentMonthStart.toISOString().split("T")[0]; const lastMonthStr = lastMonthStart.toISOString().split("T")[0]; const { data: cmInvoices } = await supabase .from("invoices") .select("total_cents, amount, status") .eq("organisation_id", orgId) .gte("issue_date", currentMonthStr); const { data: lmInvoices } = await supabase .from("invoices") .select("total_cents, amount, status") .eq("organisation_id", orgId) .gte("issue_date", lastMonthStr) .lt("issue_date", currentMonthStr); function monthPaid(invs: any[] | null) { return (invs || []).filter((i) => i.status === "paid").reduce((s, i) => s + invCents(i), 0); } function monthOutstanding(invs: any[] | null) { return (invs || []).filter((i) => i.status === "sent" || i.status === "overdue").reduce((s, i) => s + invCents(i), 0); } const cmPaid = monthPaid(cmInvoices); const lmPaid = monthPaid(lmInvoices); const cmOut = monthOutstanding(cmInvoices); const lmOut = monthOutstanding(lmInvoices); function pctChange(current: number, previous: number): string | null { if (previous === 0) return current > 0 ? "new" : null; return ((current - previous) / previous * 100).toFixed(0); } const cashChange = pctChange(cmPaid, lmPaid); const outstandingChange = pctChange(cmOut, lmOut); const lastMonthLabel = monthNames[lastMonthStart.getMonth()]; return { metrics: { cash_on_hand: cashOnHandCents, outstanding_invoices: outstandingTotalCents, payroll_due: payrollDueCents, headcount: headcount || 0, }, payrollDueDate, trends: { cash: cashChange ? { value: cashChange, direction: cmPaid >= lmPaid ? "up" : "down" as "up" | "down" } : null, outstanding: outstandingChange ? { value: outstandingChange, direction: cmOut <= lmOut ? "down" : "up" as "up" | "down" } : null, payroll: null, headcount: null, }, comparisonLabel: `vs ${lastMonthLabel}`, revenueData, recentInvoices, peopleSnapshot, budgetData, upcomingEvents: events, }; } export async function fetchUserRole(): Promise { const { userId } = await auth(); if (!userId) return "employee"; const orgId = await getOrganisationId(); const supabase = await createAdminClient(); const { data } = await supabase .from("organisation_members") .select("role") .eq("user_id", userId) .eq("organisation_id", orgId) .single(); return data?.role || "employee"; } function formatDateShort(dateStr: string): string { const d = new Date(dateStr + "T00:00:00"); return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); }