366 lines
12 KiB
TypeScript
366 lines
12 KiB
TypeScript
|
|
// 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 OverviewSearch, {
|
||
|
|
type OverviewSearchItem,
|
||
|
|
} from "@/components/OverviewSearch";
|
||
|
|
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[];
|
||
|
|
searchIndex: OverviewSearchItem[];
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function Dashboard({
|
||
|
|
kpis,
|
||
|
|
statusDistribution,
|
||
|
|
recentCompanies,
|
||
|
|
recentDirectors,
|
||
|
|
searchIndex,
|
||
|
|
}: DashboardProps) {
|
||
|
|
const totalStatuses = statusDistribution.reduce(
|
||
|
|
(sum, b) => sum + b.count,
|
||
|
|
0
|
||
|
|
);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<>
|
||
|
|
{/* ───── 0. Global tax / CK number search ─────────────────────── */}
|
||
|
|
<OverviewSearch items={searchIndex} />
|
||
|
|
|
||
|
|
{/* ───── 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)]";
|
||
|
|
}
|
||
|
|
}
|