feat(workforce): move workforce charts + richer KPIs to the landing page
This commit is contained in:
parent
02c1156e58
commit
68dc88e4c5
2 changed files with 209 additions and 45 deletions
|
|
@ -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>
|
||||
|
|
|
|||
139
src/app/dashboard/workforce/workforce-charts.tsx
Normal file
139
src/app/dashboard/workforce/workforce-charts.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue