feat(workforce): move workforce charts + richer KPIs to the landing page

This commit is contained in:
DEls 2026-09-11 20:58:34 +02:00
parent 02c1156e58
commit 68dc88e4c5
2 changed files with 209 additions and 45 deletions

View file

@ -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<string, { date: string; hours: number }[]>();
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}
/>
<KpiTile
label="Total cost"
value={formatCurrency(summary.totalCost)}
sub={`${formatNumber(summary.totalHours, { maximumFractionDigits: 0 })} hrs, all-time`}
icon={Banknote}
/>
<KpiTile
label="Hours this week"
value={formatNumber(summary.weekHours, { maximumFractionDigits: 0 })}
@ -94,19 +125,19 @@ export default async function WorkforceLanding() {
icon={Clock}
/>
<KpiTile
label="Permanent"
value={formatNumber(summary.permanent)}
sub="On payroll"
icon={Briefcase}
/>
<KpiTile
label="Casual"
value={formatNumber(summary.casual)}
sub="Day / contract"
icon={HardHat}
label="Idle 30d"
value={formatNumber(summary.idle)}
sub="No hours in the last 30 days"
icon={AlertTriangle}
tone={summary.idle > 0 ? "warning" : undefined}
/>
</section>
<section className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<WorkforceHoursChart weeklyTrend={summary.weeklyTrend} />
<TopWorkersCostChart workers={summary.topByCost} />
</section>
<section
aria-label="Workforce sections"
className="grid grid-cols-1 gap-4 md:grid-cols-2"
@ -114,7 +145,7 @@ export default async function WorkforceLanding() {
<SectionCard
href="/dashboard/workforce/workers"
title="Workers"
description="The full employee list — rate, position, project, last 30 days, total cost."
description="The full roster — rate, position, project, and each worker's QR badge."
icon={Users}
/>
<SectionCard
@ -134,27 +165,24 @@ function KpiTile({
value,
sub,
icon: Icon,
tone,
}: {
label: string;
value: React.ReactNode;
sub: string;
icon: React.ComponentType<{ className?: string }>;
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 (
<Card className="relative overflow-hidden border-border/60 transition-colors hover:border-[#F2B165]/40">
<span
aria-hidden
className="absolute inset-y-0 left-0 w-1 bg-gradient-to-b from-[#F2B165] to-[#E08A3C]"
/>
<span aria-hidden className={`absolute inset-y-0 left-0 w-1 bg-gradient-to-b ${strip}`} />
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{label}
</CardTitle>
<span
aria-hidden
className="inline-flex h-9 w-9 items-center justify-center rounded-lg bg-[#F2B165]/15"
>
<Icon className="h-4 w-4 text-[#F2B165]" />
<CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle>
<span aria-hidden className={`inline-flex h-9 w-9 items-center justify-center rounded-lg ${iconBg}`}>
<Icon className={`h-4 w-4 ${iconFg}`} />
</span>
</CardHeader>
<CardContent>
@ -183,10 +211,7 @@ function SectionCard({
<CardTitle className="text-base">{title}</CardTitle>
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
</div>
<span
aria-hidden
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[#F2B165]/15"
>
<span aria-hidden className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[#F2B165]/15">
<Icon className="h-4 w-4 text-[#F2B165]" />
</span>
</CardHeader>

View file

@ -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 (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<div>
<CardTitle className="text-base">Workforce hours</CardTitle>
<p className="mt-1 text-xs text-muted-foreground">
Total hours across all workers, last 12 weeks.
</p>
</div>
<div className="text-right">
<div className="text-xl font-bold tabular-nums">
{formatNumber(Math.round(avgPerWeek))} hrs/wk
</div>
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">
12-week average
</div>
</div>
</CardHeader>
<CardContent className="h-52">
{totalLast12 === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
No timesheet data yet. Import a CSV/XLSX to populate this chart.
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={weeklyTrend} margin={{ top: 6, right: 8, bottom: 0, left: -16 }}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(127,127,127,0.2)" vertical={false} />
<XAxis
dataKey="weekStart"
tickFormatter={(v: string) => format(parseISO(v), "MMM d")}
stroke="rgba(127,127,127,0.6)"
fontSize={11}
tickLine={false}
/>
<YAxis
stroke="rgba(127,127,127,0.6)"
fontSize={11}
tickLine={false}
axisLine={false}
tickFormatter={(v: number) => `${v}`}
/>
<Tooltip
contentStyle={{
backgroundColor: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: 8,
fontSize: 12,
}}
labelFormatter={(v: string) => `Week of ${format(parseISO(v), "yyyy-MM-dd")}`}
formatter={(value: number) => [`${formatNumber(Math.round(value))} hrs`, "Hours"]}
/>
<Bar dataKey="hours" fill="#F2B165" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
);
}
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 (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<div>
<CardTitle className="text-base">Top 5 by cost</CardTitle>
<p className="mt-1 text-xs text-muted-foreground">Where the money has gone, all-time.</p>
</div>
<Badge variant="outline">cost / total</Badge>
</CardHeader>
<CardContent>
{workers.length === 0 ? (
<div className="flex h-52 items-center justify-center text-sm text-muted-foreground">
No cost data yet.
</div>
) : (
<ul className="space-y-2">
{workers.map((w, i) => {
const pct = (w.cost / maxCost) * 100;
return (
<li key={w.id} className="flex items-center gap-3 text-sm">
<span className="w-5 shrink-0 text-right font-mono text-xs text-muted-foreground">
{i + 1}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<span className="truncate font-medium">{w.name}</span>
<span className="shrink-0 tabular-nums text-muted-foreground">
{formatCurrency(w.cost)}{" "}
<span className="text-[10px]">
· {formatNumber(w.hours, { maximumFractionDigits: 1 })}h
</span>
</span>
</div>
<div className="mt-1 h-2 overflow-hidden rounded-full bg-muted">
<div
className={cn(
"h-full rounded-full",
w.type === "permanent" ? "bg-[#F2B165]" : "bg-slate-400",
)}
style={{ width: `${pct}%` }}
/>
</div>
</div>
</li>
);
})}
</ul>
)}
</CardContent>
</Card>
);
}