Adds the full Next.js app (app/, components/, lib/, scripts/, assets/, docs/, Dockerfile, compose, configs) to version control. Security: - CIPC BizPortal customer-code logins (Login 1 + Login 3) now read CIPC_LOGIN1_CODE / CIPC_LOGIN1_PASSWORD / CIPC_LOGIN3_CODE / CIPC_LOGIN3_PASSWORD from .env.local instead of source. - BizPortal personal login in apply-bizportal route reads BIZPORTAL_ID_NO / BIZPORTAL_PASSWORD. - Added .env.example as the committed template. .gitignore excludes .env*, node_modules, .next, logs, sqlite, Playwright storage_state.json (live CIPC cookies), scratch dirs, client PDFs and debug screenshots.
561 lines
20 KiB
TypeScript
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="currentColor"
|
|
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>
|
|
);
|
|
}
|