// 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 = { 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 = { 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 = { active: 0, in_process: 0, deregistered: 0, inactive: 0 }; presentLanes.forEach((lane, idx) => { laneXPositions[lane] = startX + idx * (laneWidth + 24); }); return (
{/* Header strip */}

Network

{totals.total.toLocaleString()} compan {totals.total === 1 ? "y" : "ies"} linked

{presentLanes.map((lane) => ( ))}
{/* ─── Desktop SVG canvas (≥1024px) ────────────────────────── */}
{/* ─── Mobile / Tablet fallback ───────────────────────────── */}
{/* Director strip */}
{directorInitials}

{directorName}

{directorIdNo}

{isDisqualified ? "Disq" : "Clear"}
{/* Lane stacks */} {presentLanes.map((lane) => { const items = grouped[lane]; return (
{items.map((c) => (

{c.enterprise_name || "Untitled"}

{c.enterprise_no || "—"} {LANES[lane].label}
))}
); })} {companies.length === 0 ? (
No companies linked to this director.
) : null}
); }