WORKFLOW-APP/components/Dashboard.tsx

358 lines
12 KiB
TypeScript
Raw Normal View History

// components/Dashboard.tsx
// Server component that renders the dashboard surface for the
// registry overview. All data is passed in via props from the page
// (which is responsible for the PocketBase calls).
//
// Sections, top to bottom:
// 1. KPI strip — five tiles across the top
// 2. Status distribution — single progress-bar showing how the
// company portfolio splits across the four status buckets
// 3. Onboarding activity — last 5 submissions, with a CTA into
// the inbox
// 4. Recently updated — last 5 companies, last 5 directors
// 5. Quick links — replaces the old prose "What you can do here"
// with actionable links to the most useful filters
//
// All numbers are pre-formatted in the parent so this component
// stays free of formatting concerns.
import Link from "next/link";
import StatCard from "@/components/StatCard";
import StatusBadge from "@/components/StatusBadge";
import { formatDate } from "@/lib/format";
import type {
YsterbaardCompany,
YsterbaardDirector,
} from "@/lib/types";
export interface DashboardProps {
kpis: {
directors: { total: number; activeRoles: number; clients: number };
companies: {
total: number;
active: number;
inProcess: number;
inactive: number;
deregistered: number;
clients: number;
};
};
statusDistribution: ReadonlyArray<{
key: "active" | "in_process" | "inactive" | "deregistered";
label: string;
count: number;
}>;
recentCompanies: YsterbaardCompany[];
recentDirectors: YsterbaardDirector[];
}
export default function Dashboard({
kpis,
statusDistribution,
recentCompanies,
recentDirectors,
}: DashboardProps) {
const totalStatuses = statusDistribution.reduce(
(sum, b) => sum + b.count,
0
);
return (
<>
{/* ───── 1. KPI strip ─────────────────────────────────────────── */}
<section className="container-x pt-10 pb-2">
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
<StatCard
label="Directors"
value={kpis.directors.total.toLocaleString()}
hint={`${kpis.directors.activeRoles.toLocaleString()} with active roles`}
accent
/>
<StatCard
label="Companies"
value={kpis.companies.total.toLocaleString()}
hint={`${kpis.companies.clients.toLocaleString()} client companies`}
/>
<StatCard
label="Active companies"
value={kpis.companies.active.toLocaleString()}
hint={`${pct(kpis.companies.active, kpis.companies.total)}% of portfolio`}
accent
/>
<StatCard
label="In process"
value={kpis.companies.inProcess.toLocaleString()}
hint="Registrations pending with CIPC"
/>
<StatCard
label="Deregistered"
value={kpis.companies.deregistered.toLocaleString()}
hint={`${kpis.companies.inactive.toLocaleString()} also inactive`}
/>
</div>
</section>
{/* ───── 2. Status distribution ───────────────────────────────── */}
<section className="container-x py-6">
<div className="bg-surface border border-border rounded-lg p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-fg">
Company status distribution
</h2>
<Link
href="/companies"
className="text-sm font-semibold text-muted hover:text-fg no-underline"
>
See all →
</Link>
</div>
{/* Stacked bar */}
<div className="flex h-3 w-full overflow-hidden rounded-pill bg-surface-warm border border-border">
{statusDistribution.map((b) => {
const width =
totalStatuses === 0 ? 0 : (b.count / totalStatuses) * 100;
if (width === 0) return null;
return (
<div
key={b.key}
className={barColor(b.key)}
style={{ width: `${width}%` }}
title={`${b.label}: ${b.count}`}
aria-label={`${b.label}: ${b.count}`}
/>
);
})}
</div>
{/* Legend rows */}
<ul className="mt-5 grid grid-cols-2 md:grid-cols-4 gap-3">
{statusDistribution.map((b) => (
<li
key={b.key}
className="flex items-center justify-between gap-3 text-sm"
>
<span className="flex items-center gap-2">
<span
aria-hidden="true"
className={`inline-block w-2.5 h-2.5 rounded-pill ${dotColor(b.key)}`}
/>
<span className="text-fg-2">{b.label}</span>
</span>
<span className="font-semibold text-fg tabular-nums">
{b.count.toLocaleString()}
</span>
</li>
))}
</ul>
</div>
</section>
{/* ───── 3. Recently updated — companies + directors ──────────── */}
<section className="container-x py-6 grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Companies */}
<div className="bg-surface border border-border rounded-lg overflow-hidden">
<div className="flex items-center justify-between px-6 py-5 border-b border-border">
<h2 className="text-lg font-semibold text-fg">
Recent companies
</h2>
<Link
href="/companies"
className="text-sm font-semibold text-muted hover:text-fg no-underline"
>
All →
</Link>
</div>
{recentCompanies.length === 0 ? (
<div className="px-6 py-10 text-center text-sm text-muted">
No companies in registry yet.
</div>
) : (
<ul>
{recentCompanies.map((c) => (
<li
key={c.id}
className="border-b border-border-soft last:border-b-0"
>
<Link
href={`/companies/${c.id}`}
className="block px-6 py-4 hover:bg-surface-warm transition-colors no-underline"
>
<div className="flex items-baseline justify-between gap-3">
<p className="text-sm font-semibold text-fg truncate">
{c.enterprise_name || "—"}
</p>
<span className="text-[11px] text-muted tabular-nums flex-shrink-0">
{formatDate(c.last_checked)}
</span>
</div>
<p className="text-xs text-muted font-mono mt-0.5">
{c.enterprise_no}
</p>
</Link>
</li>
))}
</ul>
)}
</div>
{/* Directors */}
<div className="bg-surface border border-border rounded-lg overflow-hidden">
<div className="flex items-center justify-between px-6 py-5 border-b border-border">
<h2 className="text-lg font-semibold text-fg">
Recent directors
</h2>
<Link
href="/directors"
className="text-sm font-semibold text-muted hover:text-fg no-underline"
>
All →
</Link>
</div>
{recentDirectors.length === 0 ? (
<div className="px-6 py-10 text-center text-sm text-muted">
No directors in registry yet.
</div>
) : (
<ul>
{recentDirectors.map((d) => (
<li
key={d.id}
className="border-b border-border-soft last:border-b-0"
>
<Link
href={`/directors/${d.id}`}
className="flex items-center justify-between gap-3 px-6 py-4 hover:bg-surface-warm transition-colors no-underline"
>
<div>
<p className="text-sm font-semibold text-fg">
{d.first_names} {d.surname}
</p>
<p className="text-xs text-muted font-mono mt-0.5">
{d.id_no}
</p>
</div>
<span className="text-[11px] text-muted tabular-nums flex-shrink-0">
{formatDate(d.last_checked)}
</span>
</Link>
</li>
))}
</ul>
)}
</div>
</section>
{/* ───── 5. Quick links ───────────────────────────────────────── */}
<section className="container-x py-10">
<div className="bg-surface border border-border rounded-lg p-6 md:p-8">
<h2 className="text-lg font-semibold text-fg mb-4">Quick filters</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<QuickLink
href="/companies?status=active"
label="Active companies"
hint="In business with CIPC"
/>
<QuickLink
href="/companies?status=in_process"
label="In-process registrations"
hint="Pending with CIPC"
/>
<QuickLink
href="/companies?status=deregistered"
label="Deregistered companies"
hint="Removed from registry"
/>
<QuickLink
href="/directors?client=1"
label="Client directors"
hint="Ysterbaard-appointed"
/>
<QuickLink
href="/resolutions"
label="Resolutions"
hint="All company resolutions"
/>
<QuickLink
href="/resolutions?tab=onboarding"
label="New client onboarding"
hint="Start a new client intake"
cta
/>
</div>
</div>
</section>
</>
);
}
/* ─────────────────────────────────────────────────────────────────────── */
function QuickLink({
href,
label,
hint,
cta = false,
}: {
href: string;
label: string;
hint: string;
cta?: boolean;
}) {
return (
<Link
href={href}
className={
"flex flex-col gap-1 p-4 rounded-md border no-underline transition-colors " +
(cta
? "bg-accent text-accent-on border-transparent hover:bg-accent-hover"
: "bg-surface border-border hover:bg-surface-warm")
}
>
<span
className={
"text-sm font-semibold " + (cta ? "" : "text-fg")
}
>
{label}
</span>
<span
className={
"text-xs " + (cta ? "text-accent-on/80" : "text-muted")
}
>
{hint}
</span>
</Link>
);
}
function pct(part: number, whole: number): string {
if (whole === 0) return "0";
return ((part / whole) * 100).toFixed(0);
}
function barColor(key: DashboardProps["statusDistribution"][number]["key"]): string {
switch (key) {
case "active":
return "bg-[color:var(--success)]";
case "in_process":
return "bg-[color:var(--warn)]";
case "inactive":
return "bg-border";
case "deregistered":
return "bg-[color:var(--danger)]";
}
}
function dotColor(key: DashboardProps["statusDistribution"][number]["key"]): string {
switch (key) {
case "active":
return "bg-[color:var(--success)]";
case "in_process":
return "bg-[color:var(--warn)]";
case "inactive":
return "bg-border";
case "deregistered":
return "bg-[color:var(--danger)]";
}
}