diff --git a/src/app/dashboard/workforce/page.tsx b/src/app/dashboard/workforce/page.tsx index f2d47df..a8817fb 100644 --- a/src/app/dashboard/workforce/page.tsx +++ b/src/app/dashboard/workforce/page.tsx @@ -1,34 +1,28 @@ import Link from "next/link"; import { + AlertTriangle, ArrowRight, - Briefcase, + Banknote, Clock, - HardHat, ScrollText, Users, } from "lucide-react"; import { pbAdmin } from "@/lib/pb"; import { Topbar } from "@/components/layout/sidebar"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { ImportTimesheetsButton } from "@/components/import-timesheets-button"; -import { formatNumber } from "@/lib/utils"; +import { formatCurrency, formatNumber } from "@/lib/utils"; +import { buildWeeklyTrend, startOfIsoWeekUtc, sumHoursInWindow, MS_PER_DAY } from "@/lib/workforce-hours"; +import { WorkforceHoursChart, TopWorkersCostChart } from "./workforce-charts"; export const dynamic = "force-dynamic"; -const MS_PER_DAY = 86_400_000; - -function startOfIsoWeekUtc(now: Date): number { - const todayUtc = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); - const dayOfWeek = new Date(todayUtc).getUTCDay(); // 0=Sun..6=Sat - const mondayOffset = (dayOfWeek + 6) % 7; // days since Monday - return todayUtc - mondayOffset * MS_PER_DAY; -} - async function loadWorkforceSummary() { const pb = await pbAdmin(); const now = new Date(); + const todayUtc = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); + const last30Start = todayUtc - 30 * MS_PER_DAY; const weekStartUtc = startOfIsoWeekUtc(now); const [employees, timesheets, sites] = await Promise.all([ @@ -37,14 +31,41 @@ async function loadWorkforceSummary() { pb.collection("sites").getFullList({ sort: "name" }), ]); + const timesheetsByEmployee = new Map(); + const flatTimesheets: { date: string; hours: number }[] = []; let weekHours = 0; for (const t of timesheets) { - const d = new Date(t.date_logged); - if (Number.isNaN(d.getTime())) continue; - const dayUtc = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); - if (dayUtc >= weekStartUtc) weekHours += t.hours_worked || 0; + const hours = t.hours_worked || 0; + const date = t.date_logged as string; + flatTimesheets.push({ date, hours }); + const list = timesheetsByEmployee.get(t.employee) ?? []; + list.push({ date, hours }); + timesheetsByEmployee.set(t.employee, list); + + const d = new Date(date); + if (!Number.isNaN(d.getTime())) { + const dayUtc = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); + if (dayUtc >= weekStartUtc) weekHours += hours; + } } + const weeklyTrend = buildWeeklyTrend(flatTimesheets, now, 12); + + let totalHours = 0; + let totalCost = 0; + let idle = 0; + const topByCost: { id: string; name: string; type: string; cost: number; hours: number }[] = []; + for (const e of employees) { + const list = timesheetsByEmployee.get(e.id) ?? []; + const hours = list.reduce((s, t) => s + t.hours, 0); + const cost = (e.rate_per_hour || 0) * hours; + totalHours += hours; + totalCost += cost; + if (sumHoursInWindow(list, last30Start, todayUtc + MS_PER_DAY) === 0) idle += 1; + if (cost > 0) topByCost.push({ id: e.id, name: e.name, type: e.worker_type, cost, hours }); + } + topByCost.sort((a, b) => b.cost - a.cost); + const permanent = employees.filter((e) => e.worker_type === "permanent").length; const casual = employees.filter((e) => e.worker_type === "casual").length; @@ -53,7 +74,11 @@ async function loadWorkforceSummary() { permanent, casual, weekHours, - totalEntries: timesheets.length, + totalHours, + totalCost, + idle, + weeklyTrend, + topByCost: topByCost.slice(0, 5), sites: sites.map((s) => ({ id: s.id, name: s.name, project_code: s.project_code })), }; } @@ -87,6 +112,12 @@ export default async function WorkforceLanding() { sub={`${summary.permanent} permanent · ${summary.casual} casual`} icon={Users} /> + - 0 ? "warning" : undefined} /> +
+ + +
+
; + tone?: "warning"; }) { + const strip = tone === "warning" ? "from-red-500 to-amber-500" : "from-[#F2B165] to-[#E08A3C]"; + const iconBg = tone === "warning" ? "bg-red-500/15" : "bg-[#F2B165]/15"; + const iconFg = tone === "warning" ? "text-red-600 dark:text-red-400" : "text-[#F2B165]"; return ( - + - - {label} - - - + {label} + + @@ -183,10 +211,7 @@ function SectionCard({ {title}

{description}

- + diff --git a/src/app/dashboard/workforce/workforce-charts.tsx b/src/app/dashboard/workforce/workforce-charts.tsx new file mode 100644 index 0000000..3522bd1 --- /dev/null +++ b/src/app/dashboard/workforce/workforce-charts.tsx @@ -0,0 +1,139 @@ +"use client"; + +import { format, parseISO } from "date-fns"; +import { + Bar, + BarChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { cn, formatCurrency, formatNumber } from "@/lib/utils"; +import type { WeeklyTrendPoint } from "@/lib/workforce-hours"; + +export function WorkforceHoursChart({ weeklyTrend }: { weeklyTrend: WeeklyTrendPoint[] }) { + const totalLast12 = weeklyTrend.reduce((s, p) => s + p.hours, 0); + const avgPerWeek = weeklyTrend.length > 0 ? totalLast12 / weeklyTrend.length : 0; + + return ( + + +
+ Workforce hours +

+ Total hours across all workers, last 12 weeks. +

+
+
+
+ {formatNumber(Math.round(avgPerWeek))} hrs/wk +
+
+ 12-week average +
+
+
+ + {totalLast12 === 0 ? ( +
+ No timesheet data yet. Import a CSV/XLSX to populate this chart. +
+ ) : ( + + + + format(parseISO(v), "MMM d")} + stroke="rgba(127,127,127,0.6)" + fontSize={11} + tickLine={false} + /> + `${v}`} + /> + `Week of ${format(parseISO(v), "yyyy-MM-dd")}`} + formatter={(value: number) => [`${formatNumber(Math.round(value))} hrs`, "Hours"]} + /> + + + + )} +
+
+ ); +} + +export function TopWorkersCostChart({ + workers, +}: { + workers: { id: string; name: string; type: string; cost: number; hours: number }[]; +}) { + const maxCost = Math.max(1, ...workers.map((w) => w.cost)); + return ( + + +
+ Top 5 by cost +

Where the money has gone, all-time.

+
+ cost / total +
+ + {workers.length === 0 ? ( +
+ No cost data yet. +
+ ) : ( +
    + {workers.map((w, i) => { + const pct = (w.cost / maxCost) * 100; + return ( +
  • + + {i + 1} + +
    +
    + {w.name} + + {formatCurrency(w.cost)}{" "} + + · {formatNumber(w.hours, { maximumFractionDigits: 1 })}h + + +
    +
    +
    +
    +
    +
  • + ); + })} +
+ )} +
+
+ ); +} \ No newline at end of file