WORKFLOW-APP/components/OrgChart.tsx
SJCoetz bb97fef453 feat: initialize full ysterbaard frontend application
Add complete production-ready Ysterbaard PocketBase registry browser frontend:
- Next.js 14 App Router with TypeScript, Tailwind CSS, and TanStack React Query
- Secure server-side PocketBase API proxy to avoid exposing superuser tokens
- Read-only directors and companies registry browse pages with search, filtering, and pagination
- Client onboarding workflow for creating and managing client submissions
- Docker container configuration for easy deployment
- All supporting UI components, utility functions, and configuration files
2026-07-23 19:10:47 +02:00

561 lines
20 KiB
TypeScript

// components/OrgChart.tsx
// Visual hierarchy chart: a director at the root with their companies
// grouped into status lanes (Active / In process / Deregistered / Inactive).
//
// Layout strategy:
// - Desktop (≥1024px): Centered root card with horizontal lanes below.
// Each lane is a vertical column holding its own cards. Empty lanes
// don't take space — the active lanes are evenly distributed across
// the canvas width. A vertical "trunk" line drops from the root to
// a horizontal bus that branches into each lane.
// - Tablet/Mobile: CSS stack — director strip on top, then a vertical
// list of lane rows (header + cards stacked under each lane header).
//
// All text uses foreignObject so Director names wrap properly instead of
// being truncated by SVG text length limits.
import { ReactNode } from "react";
import Link from "next/link";
import StatusBadge from "./StatusBadge";
import type { YsterbaardCompany } from "@/lib/types";
import { deriveCompanyStatus } from "@/lib/format";
interface OrgChartProps {
directorName: string;
directorIdNo: string;
isDisqualified: boolean;
totals: { total: number; active: number; inactive: number };
companies: YsterbaardCompany[];
}
type Lane = "active" | "in_process" | "deregistered" | "inactive";
interface LaneMeta {
label: string;
variant: "active" | "in_process" | "deregistered" | "inactive";
tokenColor: string;
}
const LANES: Record<Lane, LaneMeta> = {
active: {
label: "In business",
variant: "active",
tokenColor: "var(--success)"
},
in_process: {
label: "In process",
variant: "in_process",
tokenColor: "var(--warn)"
},
deregistered: {
label: "Deregistered",
variant: "deregistered",
tokenColor: "var(--danger)"
},
inactive: {
label: "Inactive",
variant: "inactive",
tokenColor: "var(--meta)"
}
};
const LANE_ORDER: Lane[] = ["active", "in_process", "deregistered", "inactive"];
function statusToLane(c: YsterbaardCompany): Lane {
const bucket = deriveCompanyStatus(c.enterprise_status, c.is_active);
if (bucket === "active") return "active";
if (bucket === "in_process") return "in_process";
if (bucket === "deregistered") return "deregistered";
return "inactive";
}
export default function OrgChart({
directorName,
directorIdNo,
isDisqualified,
totals,
companies
}: OrgChartProps): ReactNode {
// Group companies by lane
const grouped: Record<Lane, YsterbaardCompany[]> = {
active: [],
deregistered: [],
inactive: [],
in_process: []
};
for (const c of companies) grouped[statusToLane(c)].push(c);
// Only render lanes that have at least one company
const presentLanes = LANE_ORDER.filter((l) => grouped[l].length > 0);
const directorInitials =
(directorName.trim()[0] ?? "?").toUpperCase() || "?";
// Compact root card dimensions (rendered via foreignObject)
const ROOT_W = 340;
const ROOT_H = 96;
const CARD_W = 200;
const CARD_H = 88;
const CARD_GAP_X = 12;
const CARD_GAP_Y = 12;
const CARDS_PER_ROW = 2;
// Calculate dimensions for the desktop SVG canvas based on the
// widest lane. The canvas natively supports up to 4 lanes wide.
const numLanes = Math.max(1, presentLanes.length);
const laneWidth = CARD_W; // each lane column is CARD_W wide
const totalLaneWidth = numLanes * laneWidth + (numLanes - 1) * 24; // 24px gap between lanes
const canvasW = Math.max(720, totalLaneWidth + 64); // 32px gutter each side
const canvasCenterX = canvasW / 2;
// Tallest lane determines canvas height
const maxLaneCount = Math.max(...presentLanes.map((l) => grouped[l].length), 1);
const rowsNeeded = Math.max(1, Math.ceil(maxLaneCount / CARDS_PER_ROW));
const rootBottomY = 32 + ROOT_H;
const headerOffset = 28; // space for lane label above first row
const trunkY = rootBottomY + 24;
const laneTopY = trunkY + 24;
const cardAreaHeight = rowsNeeded * CARD_H + (rowsNeeded - 1) * CARD_GAP_Y;
const canvasH = laneTopY + cardAreaHeight + 32;
// Compute lane column x-positions, centered in the canvas
const startX = canvasCenterX - totalLaneWidth / 2;
const laneXPositions: Record<Lane, number> = {
active: 0,
in_process: 0,
deregistered: 0,
inactive: 0
};
presentLanes.forEach((lane, idx) => {
laneXPositions[lane] = startX + idx * (laneWidth + 24);
});
return (
<section
data-od-id="org-chart"
aria-label={`Network graph for ${directorName} showing ${totals.total} linked companies across ${presentLanes.length} status lanes`}
className="bg-surface border border-border rounded-lg p-5 md:p-7 flex flex-col gap-6"
>
{/* Header strip */}
<div className="flex items-baseline justify-between gap-3 flex-wrap">
<div className="flex items-baseline gap-3 flex-wrap">
<h2 className="text-xl font-semibold text-fg">Network</h2>
<p className="text-sm text-muted">
{totals.total.toLocaleString()} compan
{totals.total === 1 ? "y" : "ies"} linked
</p>
</div>
<div className="flex flex-wrap gap-3">
{presentLanes.map((lane) => (
<span
key={lane}
className="inline-flex items-center gap-1.5 text-xs font-semibold text-muted"
>
<span
aria-hidden="true"
className="w-2.5 h-2.5 rounded-pill"
style={{ backgroundColor: LANES[lane].tokenColor }}
/>
{grouped[lane].length} {LANES[lane].label.toLowerCase()}
</span>
))}
</div>
</div>
{/* ─── Desktop SVG canvas (≥1024px) ────────────────────────── */}
<div className="hidden lg:block">
<svg
viewBox={`0 0 ${canvasW} ${canvasH}`}
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-hidden="true"
className="w-full h-auto block"
preserveAspectRatio="xMidYMin meet"
>
<defs>
<filter id="org-card-shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow
dx="0"
dy="2"
stdDeviation="3"
floodColor="#000000"
floodOpacity="0.06"
/>
</filter>
</defs>
{/* ─── Vertical trunk from root to bus ─────────────────── */}
<line
x1={canvasCenterX}
y1={rootBottomY}
x2={canvasCenterX}
y2={trunkY}
stroke="var(--border)"
strokeWidth={1.5}
strokeDasharray="4 4"
/>
{/* ─── Horizontal bus across all lanes ─────────────────── */}
{presentLanes.length > 1 ? (
<line
x1={laneXPositions[presentLanes[0]] + CARD_W / 2}
y1={trunkY}
x2={
laneXPositions[presentLanes[presentLanes.length - 1]] + CARD_W / 2
}
y2={trunkY}
stroke="var(--border)"
strokeWidth={1.5}
strokeDasharray="4 4"
/>
) : null}
{/* ─── Connectors from bus to each lane ─────────────────── */}
{presentLanes.map((lane) => {
const laneX = laneXPositions[lane];
const laneMidX = laneX + CARD_W / 2;
return (
<g key={`conn-${lane}`}>
<line
x1={laneMidX}
y1={trunkY}
x2={laneMidX}
y2={laneTopY}
stroke={LANES[lane].tokenColor}
strokeWidth={1.5}
strokeOpacity={0.6}
strokeDasharray="4 4"
/>
<circle
cx={laneMidX}
cy={laneTopY}
r={3}
fill={LANES[lane].tokenColor}
/>
</g>
);
})}
{/* ─── Director root card ─────────────────────────────── */}
<g filter="url(#org-card-shadow)">
<rect
x={canvasCenterX - ROOT_W / 2}
y={32}
width={ROOT_W}
height={ROOT_H}
rx={14}
ry={14}
fill="var(--surface)"
stroke="var(--accent)"
strokeWidth={2}
/>
</g>
<foreignObject
x={canvasCenterX - ROOT_W / 2}
y={32}
width={ROOT_W}
height={ROOT_H}
>
<div
style={{
boxSizing: "border-box",
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
gap: 10,
padding: "10px 12px"
}}
>
<div
style={{
width: 40,
height: 40,
borderRadius: 9999,
background: "var(--accent)",
color: "var(--accent-on)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontFamily: "var(--font-display)",
fontWeight: 700,
fontSize: 16,
flexShrink: 0
}}
>
{directorInitials}
</div>
<div
style={{
minWidth: 0,
flex: 1,
display: "flex",
flexDirection: "column",
gap: 1
}}
>
<p
style={{
fontFamily: "var(--font-display)",
fontWeight: 600,
fontSize: 12,
color: "var(--fg)",
lineHeight: 1.2,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap"
}}
>
{directorName}
</p>
<p
style={{
fontFamily: "var(--font-mono)",
fontSize: 10,
color: "var(--muted)",
lineHeight: 1.3,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap"
}}
>
{directorIdNo}
</p>
<p
style={{
fontFamily: "var(--font-display)",
fontWeight: 600,
fontSize: 8,
letterSpacing: "0.08em",
textTransform: "uppercase",
color: isDisqualified ? "var(--danger)" : "var(--success)",
marginTop: 1
}}
>
{isDisqualified ? "Disqualified" : "Clear"}
</p>
</div>
</div>
</foreignObject>
{/* ─── Lane labels ────────────────────────────────────── */}
{presentLanes.map((lane) => {
const laneX = laneXPositions[lane];
const laneMidX = laneX + CARD_W / 2;
return (
<text
key={`label-${lane}`}
x={laneMidX}
y={laneTopY - 12}
textAnchor="middle"
fill="var(--muted)"
fontFamily="var(--font-display)"
fontWeight={600}
fontSize={10}
letterSpacing="0.08em"
>
{LANES[lane].label.toUpperCase()}
{" · "}
{grouped[lane].length}
</text>
);
})}
{/* ─── Company cards ──────────────────────────────────── */}
{presentLanes.map((lane) => {
const items = grouped[lane];
const laneX = laneXPositions[lane];
return items.map((c, idx) => {
const col = idx % CARDS_PER_ROW;
const row = Math.floor(idx / CARDS_PER_ROW);
const x = laneX + col * (CARD_W + CARD_GAP_X);
const y = laneTopY + row * (CARD_H + CARD_GAP_Y);
return (
<g key={c.id}>
<foreignObject
x={x}
y={y}
width={CARD_W}
height={CARD_H}
>
<Link
href={`/companies/${c.id}`}
style={{
display: "block",
width: "100%",
height: "100%",
textDecoration: "none"
}}
>
<div
style={{
boxSizing: "border-box",
width: "100%",
height: "100%",
background: "var(--surface)",
border: "1px solid var(--border)",
borderTop: `3px solid ${LANES[lane].tokenColor}`,
borderRadius: 10,
padding: "10px 12px",
fontFamily: "var(--font-display)",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
gap: 6,
boxShadow:
"rgba(0, 0, 0, 0.02) 0 0 0 1px, rgba(0, 0, 0, 0.04) 0 2px 6px 0",
cursor: "pointer"
}}
>
<div
style={{
fontSize: 13,
fontWeight: 600,
color: "var(--fg)",
lineHeight: 1.25,
overflow: "hidden",
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical"
}}
>
{c.enterprise_name || "Untitled"}
</div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 6
}}
>
<span
style={{
fontSize: 10,
fontFamily: "var(--font-mono)",
color: "var(--muted)"
}}
>
{c.enterprise_no || "—"}
</span>
<span
style={{
width: 8,
height: 8,
borderRadius: 9999,
background: LANES[lane].tokenColor,
flexShrink: 0
}}
aria-hidden="true"
/>
</div>
</div>
</Link>
</foreignObject>
</g>
);
});
})}
{/* Empty-state hint when a director has zero companies */}
{companies.length === 0 ? (
<text
x={canvasCenterX}
y={laneTopY + 60}
textAnchor="middle"
fill="var(--muted)"
fontFamily="var(--font-display)"
fontSize={14}
>
No companies linked to this director.
</text>
) : null}
</svg>
</div>
{/* ─── Mobile / Tablet fallback ───────────────────────────── */}
<div className="lg:hidden flex flex-col gap-5">
{/* Director strip */}
<div
className="flex items-center gap-3 p-4 bg-surface border-2 rounded-md"
style={{ borderColor: "var(--accent)" }}
>
<div className="w-12 h-12 rounded-pill bg-accent text-accent-on flex items-center justify-center text-lg font-bold shrink-0">
{directorInitials}
</div>
<div className="min-w-0 flex-1">
<p className="text-base font-semibold text-fg break-words leading-snug">
{directorName}
</p>
<p className="text-xs text-muted font-mono break-words">
{directorIdNo}
</p>
</div>
<span
className={
"text-[10px] font-semibold uppercase tracking-[0.08em] px-2 py-1 rounded-pill shrink-0 " +
(isDisqualified
? "bg-danger text-accent-on"
: "bg-success text-accent-on")
}
>
{isDisqualified ? "Disq" : "Clear"}
</span>
</div>
{/* Lane stacks */}
{presentLanes.map((lane) => {
const items = grouped[lane];
return (
<div key={lane} className="flex flex-col gap-2">
<div className="flex items-center gap-2 px-1">
<span
aria-hidden="true"
className="w-2.5 h-2.5 rounded-pill shrink-0"
style={{ backgroundColor: LANES[lane].tokenColor }}
/>
<h3 className="text-xs uppercase tracking-[0.08em] font-semibold text-muted">
{LANES[lane].label}
</h3>
<span className="text-xs text-meta ml-auto">
{items.length}
</span>
</div>
<div className="flex flex-col gap-2">
{items.map((c) => (
<Link
key={c.id}
href={`/companies/${c.id}`}
data-od-id={`org-link-${c.id}`}
className="bg-surface border border-border rounded-md p-3 no-underline hover:bg-surface-warm transition-colors flex flex-col gap-1"
style={{
borderLeft: `3px solid ${LANES[lane].tokenColor}`
}}
>
<p className="text-sm font-semibold text-fg break-words leading-snug">
{c.enterprise_name || "Untitled"}
</p>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted font-mono">
{c.enterprise_no || "—"}
</span>
<StatusBadge variant={LANES[lane].variant}>
{LANES[lane].label}
</StatusBadge>
</div>
</Link>
))}
</div>
</div>
);
})}
{companies.length === 0 ? (
<div className="p-6 text-center text-sm text-muted border border-border-soft rounded-md">
No companies linked to this director.
</div>
) : null}
</div>
</section>
);
}