720 lines
28 KiB
TypeScript
720 lines
28 KiB
TypeScript
|
|
// lib/report.ts
|
||
|
|
// SERVER-ONLY — builds the JSON block list that minimax-pdf consumes.
|
||
|
|
// Used by /api/report/[type]/[id]/pdf and .../email routes.
|
||
|
|
//
|
||
|
|
// Block types we emit (see minimax-pdf skill SKILL.md):
|
||
|
|
// h1, h2, callout, table, spacer, divider, caption
|
||
|
|
// h2 automatically carries `keepWithNext` so headings never orphan
|
||
|
|
// at the bottom of a page.
|
||
|
|
|
||
|
|
import "server-only";
|
||
|
|
import type { YsterbaardCompany, YsterbaardDirector } from "@/lib/types";
|
||
|
|
|
||
|
|
// ─── Public API ──────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
export type ReportType = "company" | "director";
|
||
|
|
|
||
|
|
export interface ReportData {
|
||
|
|
type: ReportType;
|
||
|
|
company?: YsterbaardCompany & { expand?: Record<string, unknown> };
|
||
|
|
director?: YsterbaardDirector;
|
||
|
|
linkedCompanies?: Array<{ company: YsterbaardCompany; director_type?: string }>;
|
||
|
|
linkedDirectors?: Array<{ director: YsterbaardDirector; director_type?: string }>;
|
||
|
|
}
|
||
|
|
|
||
|
|
export type ContentBlock =
|
||
|
|
| { type: "h1"; text: string }
|
||
|
|
| { type: "h2"; text: string }
|
||
|
|
| { type: "h3"; text: string }
|
||
|
|
| { type: "callout"; text: string }
|
||
|
|
| { type: "spacer"; pt?: number }
|
||
|
|
| { type: "divider" }
|
||
|
|
| { type: "caption"; text: string }
|
||
|
|
| { type: "pagebreak" }
|
||
|
|
| { type: "table"; col_widths: number[]; headers: string[]; rows: (string | number)[][] };
|
||
|
|
|
||
|
|
export function buildReportBlocks(data: ReportData): ContentBlock[] {
|
||
|
|
return data.type === "company" ? companyBlocks(data) : directorBlocks(data);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function reportTitle(data: ReportData): string {
|
||
|
|
if (data.type === "company") {
|
||
|
|
return `Company Compliance Report — ${data.company?.enterprise_name || "—"}`;
|
||
|
|
}
|
||
|
|
const name = [data.director?.first_names, data.director?.surname]
|
||
|
|
.filter(Boolean).join(" ").trim() || "—";
|
||
|
|
return `Director Compliance Report — ${name}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function reportFilename(data: ReportData): string {
|
||
|
|
const slugify = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, "");
|
||
|
|
if (data.type === "company") {
|
||
|
|
return `${slugify(data.company?.enterprise_name || "company")}_company_report.pdf`;
|
||
|
|
}
|
||
|
|
const name = slugify(
|
||
|
|
[data.director?.first_names, data.director?.surname].filter(Boolean).join("_")
|
||
|
|
) || "director";
|
||
|
|
return `${name}_director_report.pdf`;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
function val(s: unknown): string {
|
||
|
|
const v = String(s ?? "").trim();
|
||
|
|
return v || "—";
|
||
|
|
}
|
||
|
|
|
||
|
|
function fmtDate(d: unknown): string {
|
||
|
|
if (!d) return "—";
|
||
|
|
try {
|
||
|
|
return new Date(String(d)).toLocaleDateString("en-ZA", {
|
||
|
|
day: "2-digit", month: "long", year: "numeric",
|
||
|
|
});
|
||
|
|
} catch { return "—"; }
|
||
|
|
}
|
||
|
|
|
||
|
|
function fmtNumber(n: unknown): string {
|
||
|
|
const v = Number(n);
|
||
|
|
return isNaN(v) ? "—" : v.toLocaleString("en-ZA");
|
||
|
|
}
|
||
|
|
|
||
|
|
const MONTHS = ["", "January", "February", "March", "April", "May", "June",
|
||
|
|
"July", "August", "September", "October", "November", "December"];
|
||
|
|
|
||
|
|
function fmtMonth(m: unknown): string {
|
||
|
|
const n = Number(m);
|
||
|
|
if (!n || n < 1 || n > 12) return "—";
|
||
|
|
return MONTHS[n];
|
||
|
|
}
|
||
|
|
|
||
|
|
function fmtSource(s: unknown): string {
|
||
|
|
return val(s).replace(/bizportal/gi, "BizPortal");
|
||
|
|
}
|
||
|
|
|
||
|
|
function joinAddr(...parts: unknown[]): string {
|
||
|
|
const filtered = parts
|
||
|
|
.map((p) => (p == null ? null : String(p).trim()))
|
||
|
|
.filter((p) => p !== null && p !== "" && p !== "None");
|
||
|
|
return filtered.join(", ");
|
||
|
|
}
|
||
|
|
|
||
|
|
function payePeriod(v: unknown): string {
|
||
|
|
if (!v || v === 0) return "None";
|
||
|
|
if (v === 1) return "Monthly";
|
||
|
|
return `Every ${v} months`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function vatPeriod(v: unknown): string {
|
||
|
|
if (!v || v === 0) return "None";
|
||
|
|
const map: Record<number, string> = {
|
||
|
|
1: "Monthly", 2: "Even months", 3: "Uneven months",
|
||
|
|
6: "Bi-annually", 12: "Annually",
|
||
|
|
};
|
||
|
|
return map[Number(v)] ?? `Every ${v} months`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function arStatusText(c: YsterbaardCompany): string {
|
||
|
|
return c.ar_outstanding
|
||
|
|
? `OUTSTANDING — ${c.ar_outstanding_details || "see CIPC for the next filing date"}`
|
||
|
|
: `Compliant — ${c.ar_outstanding_details || "all filings up to date"}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function entityStatusText(status: unknown): string {
|
||
|
|
const u = String(status ?? "").toUpperCase();
|
||
|
|
return u.includes("IN BUSINESS") ? "Active — in good standing" : val(status);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── Company report ─────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
function companyBlocks(data: ReportData): ContentBlock[] {
|
||
|
|
const c = data.company!;
|
||
|
|
const banner_status = entityStatusText(c.enterprise_status);
|
||
|
|
const ar_status = arStatusText(c);
|
||
|
|
|
||
|
|
// CIPC physical address (prefer) → fall back to manual fields
|
||
|
|
const phys = joinAddr(
|
||
|
|
c.address_line1 || c.physical_address_line_1,
|
||
|
|
c.address_line2 || c.physical_address_line_2,
|
||
|
|
c.address_street,
|
||
|
|
c.address_city || c.physical_town_city,
|
||
|
|
c.address_province || c.physical_province,
|
||
|
|
c.address_zip || c.physical_postal_code,
|
||
|
|
c.address_country,
|
||
|
|
);
|
||
|
|
// PO Box / private bag address (manual)
|
||
|
|
const pob = joinAddr(
|
||
|
|
c.pob_address_line1, c.pob_address_line2,
|
||
|
|
c.pob_city, c.pob_state, c.pob_zip, c.pob_country,
|
||
|
|
);
|
||
|
|
// Postal address: combined field → split manual fields
|
||
|
|
const post = c.postal_address || joinAddr(
|
||
|
|
c.postal_address_line_1, c.postal_address_line_2,
|
||
|
|
c.postal_town_city, c.postal_province, c.postal_postal_code,
|
||
|
|
);
|
||
|
|
|
||
|
|
const groups: string[] = (
|
||
|
|
(c.expand?.groups as Array<{ name: string }> | undefined) ?? []
|
||
|
|
).map((g) => g.name);
|
||
|
|
|
||
|
|
const yearsRegistered = c.registration_date
|
||
|
|
? String(new Date().getFullYear() - new Date(String(c.registration_date)).getFullYear())
|
||
|
|
: "—";
|
||
|
|
|
||
|
|
const linked = (data.linkedDirectors || []).map((j) => {
|
||
|
|
const d = j.director;
|
||
|
|
const disq = String(d?.disqualification_status ?? "").toUpperCase();
|
||
|
|
const status = disq.includes("NOT DISQUAL") ? "Clear" : (d?.disqualification_status || "—");
|
||
|
|
return [
|
||
|
|
[d?.first_names, d?.surname].filter(Boolean).join(" "),
|
||
|
|
val(d?.id_no),
|
||
|
|
j.director_type || "Director",
|
||
|
|
status,
|
||
|
|
];
|
||
|
|
});
|
||
|
|
const linkedRows: (string | number)[][] = linked.length
|
||
|
|
? linked
|
||
|
|
: [["—", "—", "—", "—"]];
|
||
|
|
|
||
|
|
return [
|
||
|
|
{ type: "h1", text: "Company Compliance Report" },
|
||
|
|
|
||
|
|
{ type: "callout", text:
|
||
|
|
`<b>Prepared for:</b> ${val(c.enterprise_name)} (Reg. No. ${val(c.enterprise_no)})<br/>` +
|
||
|
|
`<b>Entity status:</b> ${banner_status}<br/>` +
|
||
|
|
`<b>Annual return status:</b> ${ar_status}`
|
||
|
|
},
|
||
|
|
|
||
|
|
{ type: "h2", text: "At a glance" },
|
||
|
|
{ type: "table",
|
||
|
|
col_widths: [0.25, 0.25, 0.25, 0.25],
|
||
|
|
headers: ["Director status", "Entity status", "Years registered", "Financial year end"],
|
||
|
|
rows: [[ val(c.director_status), val(c.enterprise_status), yearsRegistered, fmtMonth(c.financial_year_end_month) ]],
|
||
|
|
},
|
||
|
|
|
||
|
|
{ type: "h2", text: "1 · Company identity" },
|
||
|
|
{ type: "table", col_widths: [0.38, 0.62], headers: ["Field", "Value"], rows: [
|
||
|
|
["Enterprise number", val(c.enterprise_no)],
|
||
|
|
["Enterprise name", val(c.enterprise_name)],
|
||
|
|
["Enterprise type", val(c.enterprise_type)],
|
||
|
|
["Enterprise status", val(c.enterprise_status)],
|
||
|
|
["Registration date", fmtDate(c.registration_date)],
|
||
|
|
["Financial year end", fmtMonth(c.financial_year_end_month)],
|
||
|
|
["Authorised shares", fmtNumber(c.authorised_shares)],
|
||
|
|
["Source", fmtSource(c.source)],
|
||
|
|
["Groups", groups.length ? groups.join(", ") : "None assigned"],
|
||
|
|
["First seen", fmtDate(c.first_seen)],
|
||
|
|
["Last checked", fmtDate(c.last_checked)],
|
||
|
|
]},
|
||
|
|
|
||
|
|
{ type: "h2", text: "2 · Registered addresses" },
|
||
|
|
{ type: "h3", text: "CIPC physical address" },
|
||
|
|
{
|
||
|
|
type: "table", col_widths: [0.38, 0.62], headers: ["Field", "Value"], rows: [
|
||
|
|
["CIPC physical address", val(c.physical_address)],
|
||
|
|
]
|
||
|
|
},
|
||
|
|
{ type: "h3", text: "Physical address (manual)" },
|
||
|
|
{ type: "table", col_widths: [0.38, 0.62], headers: ["Field", "Value"], rows: [
|
||
|
|
["Address line 1", val(c.physical_address_line_1)],
|
||
|
|
["Address line 2", val(c.physical_address_line_2)],
|
||
|
|
["Town / city", val(c.physical_town_city)],
|
||
|
|
["Province", val(c.physical_province)],
|
||
|
|
["Postal code", val(c.physical_postal_code)],
|
||
|
|
]},
|
||
|
|
{ type: "h3", text: "CIPC postal address" },
|
||
|
|
{
|
||
|
|
type: "table", col_widths: [0.38, 0.62], headers: ["Field", "Value"], rows: [
|
||
|
|
["CIPC postal address", val(c.postal_address)],
|
||
|
|
]
|
||
|
|
},
|
||
|
|
{ type: "h3", text: "Postal address (manual)" },
|
||
|
|
{ type: "table", col_widths: [0.38, 0.62], headers: ["Field", "Value"], rows: [
|
||
|
|
["Address line 1", val(c.postal_address_line_1)],
|
||
|
|
["Address line 2", val(c.postal_address_line_2)],
|
||
|
|
["Town / city", val(c.postal_town_city)],
|
||
|
|
["Province", val(c.postal_province)],
|
||
|
|
["Postal code", val(c.postal_postal_code)],
|
||
|
|
]},
|
||
|
|
{ type: "h3", text: "PO Box address" },
|
||
|
|
{ type: "table", col_widths: [0.38, 0.62], headers: ["Field", "Value"], rows: [
|
||
|
|
["PO Box line 1", val(c.pob_address_line1)],
|
||
|
|
["PO Box line 2", val(c.pob_address_line2)],
|
||
|
|
["Town / city", val(c.pob_city)],
|
||
|
|
["Province", val(c.pob_state)],
|
||
|
|
["Postal code", val(c.pob_zip)],
|
||
|
|
]},
|
||
|
|
{ type: "h3", text: "Contact details" },
|
||
|
|
{ type: "table", col_widths: [0.38, 0.62], headers: ["Field", "Value"], rows: [
|
||
|
|
["Email", val(c.company_email)],
|
||
|
|
["Cell phone", val(c.company_cell_phone)],
|
||
|
|
]},
|
||
|
|
|
||
|
|
{ type: "h2", text: "3 · Governance & company officers" },
|
||
|
|
{ type: "table", col_widths: [0.38, 0.62], headers: ["Field", "Value"], rows: [
|
||
|
|
["Public officer", val(c.public_officer_name)],
|
||
|
|
["Company secretary", val(c.company_secretary_name)],
|
||
|
|
["Director status", val(c.director_status)],
|
||
|
|
["Compliance notice", val(c.compliance_notice)],
|
||
|
|
]},
|
||
|
|
|
||
|
|
{ type: "h2", text: "4 · CIPC compliance snapshot" },
|
||
|
|
{ type: "callout", text: `<b>Annual returns:</b> ${ar_status}` },
|
||
|
|
{ type: "table", col_widths: [0.38, 0.62], headers: ["Field", "Value"], rows: [
|
||
|
|
["B-BBEE status", val(c.bbbee_status)],
|
||
|
|
["B-BBEE certificate applied", c.bbbee_certificate_applied ? "Yes" : "No"],
|
||
|
|
["Info Regulator registered", c.info_regulator_registered ? "Yes" : "No"],
|
||
|
|
["UIF registration number", val(c.uif_registration_number)],
|
||
|
|
["Compensation fund reg. no.", val(c.compensation_fund_registration_number)],
|
||
|
|
]},
|
||
|
|
|
||
|
|
{ type: "h2", text: "5 · SARS eFiling details" },
|
||
|
|
{ type: "callout", text: "Confidential — credentials for the addressee only. Do not distribute." },
|
||
|
|
{ type: "table", col_widths: [0.38, 0.62], headers: ["Field", "Value"], rows: [
|
||
|
|
["SARS login username", val(c.sars_login_username)],
|
||
|
|
["SARS login password", val(c.sars_login_password)],
|
||
|
|
["SARS tax number", val(c.sars_tax_number)],
|
||
|
|
["SARS PAYE number", val(c.sars_paye_no)],
|
||
|
|
["PAYE return period", payePeriod(c.sars_paye_return_period)],
|
||
|
|
["SARS VAT number", val(c.sars_vat_no)],
|
||
|
|
["VAT return period", vatPeriod(c.sars_vat_returns_period)],
|
||
|
|
]},
|
||
|
|
|
||
|
|
{ type: "h2", text: "6 · Linked directors" },
|
||
|
|
{ type: "table",
|
||
|
|
col_widths: [0.40, 0.25, 0.20, 0.15],
|
||
|
|
headers: ["Full name", "ID number", "Appointment", "Status"],
|
||
|
|
rows: linkedRows,
|
||
|
|
},
|
||
|
|
|
||
|
|
{ type: "spacer", pt: 18 },
|
||
|
|
{ type: "divider" },
|
||
|
|
{ type: "caption", text:
|
||
|
|
"This report is generated from live CIPC and SARS data on file at Ysterbaard Corporate Services. For queries, contact your account manager."
|
||
|
|
},
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── Director report ────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
function directorBlocks(data: ReportData): ContentBlock[] {
|
||
|
|
const d = data.director!;
|
||
|
|
const fullName = [d.first_names, d.surname].filter(Boolean).join(" ") || "—";
|
||
|
|
const disq = String(d.disqualification_status ?? "").toUpperCase();
|
||
|
|
const dirBanner = disq.includes("NOT DISQUAL")
|
||
|
|
? "Director is in good standing"
|
||
|
|
: val(d.disqualification_status);
|
||
|
|
|
||
|
|
const res = d.residential_address || joinAddr(
|
||
|
|
d.residential_address_line_1, d.residential_address_line_2,
|
||
|
|
d.residential_town_city, d.residential_province, d.residential_postal_code,
|
||
|
|
);
|
||
|
|
const post = d.postal_address || joinAddr(
|
||
|
|
d.postal_address_line_1, d.postal_address_line_2,
|
||
|
|
d.postal_town_city, d.postal_province, d.postal_postal_code,
|
||
|
|
);
|
||
|
|
|
||
|
|
const yearsRegistered = d.appointment_date
|
||
|
|
? String(new Date().getFullYear() - new Date(String(d.appointment_date)).getFullYear())
|
||
|
|
: "—";
|
||
|
|
|
||
|
|
const linkedCoRows = (data.linkedCompanies || []).map((j) => {
|
||
|
|
const co = j.company;
|
||
|
|
return [
|
||
|
|
val(co.enterprise_no),
|
||
|
|
val(co.enterprise_name),
|
||
|
|
j.director_type || "Director",
|
||
|
|
val(co.enterprise_status),
|
||
|
|
];
|
||
|
|
});
|
||
|
|
const linkedRows: (string | number)[][] = linkedCoRows.length
|
||
|
|
? linkedCoRows
|
||
|
|
: [["—", "—", "—", "—"]];
|
||
|
|
|
||
|
|
return [
|
||
|
|
{ type: "h1", text: "Director Compliance Report" },
|
||
|
|
|
||
|
|
{ type: "callout", text:
|
||
|
|
`<b>Prepared for:</b> ${fullName} (ID No. ${val(d.id_no)})<br/>` +
|
||
|
|
`<b>Disqualification status:</b> ${dirBanner}<br/>` +
|
||
|
|
`<b>Company affiliations:</b> ${val(d.total_companies)} total · ${val(d.active_companies)} active · ${val(d.inactive_companies)} inactive`
|
||
|
|
},
|
||
|
|
|
||
|
|
{ type: "h2", text: "At a glance" },
|
||
|
|
{ type: "table",
|
||
|
|
col_widths: [0.25, 0.25, 0.25, 0.25],
|
||
|
|
headers: ["Total companies", "Active companies", "Years registered", "Status"],
|
||
|
|
rows: [[ val(d.total_companies), val(d.active_companies), yearsRegistered, val(d.disqualification_status) ]],
|
||
|
|
},
|
||
|
|
|
||
|
|
{ type: "h2", text: "1 · Director identity" },
|
||
|
|
{ type: "table", col_widths: [0.38, 0.62], headers: ["Field", "Value"], rows: [
|
||
|
|
["Full name", fullName],
|
||
|
|
["SA ID number", val(d.id_no)],
|
||
|
|
["Date of birth", fmtDate(d.date_of_birth)],
|
||
|
|
["Disqualification status", val(d.disqualification_status)],
|
||
|
|
["Appointment date", fmtDate(d.appointment_date)],
|
||
|
|
["First seen", fmtDate(d.first_seen)],
|
||
|
|
["Last checked", fmtDate(d.last_checked)],
|
||
|
|
]},
|
||
|
|
|
||
|
|
{ type: "h2", text: "2 · Contact details" },
|
||
|
|
{ type: "table", col_widths: [0.38, 0.62], headers: ["Field", "Value"], rows: [
|
||
|
|
["Telephone", val(d.telephone)],
|
||
|
|
["Email", val(d.email)],
|
||
|
|
["Source", fmtSource(d.source)],
|
||
|
|
]},
|
||
|
|
|
||
|
|
{ type: "h2", text: "3 · Registered addresses" },
|
||
|
|
{ type: "table", col_widths: [0.38, 0.62], headers: ["Field", "Value"], rows: [
|
||
|
|
["Residential address", res],
|
||
|
|
["Postal address", post],
|
||
|
|
]},
|
||
|
|
|
||
|
|
{ type: "h2", text: "4 · Company affiliations" },
|
||
|
|
{ type: "table",
|
||
|
|
col_widths: [0.20, 0.40, 0.20, 0.20],
|
||
|
|
headers: ["Reg. No.", "Company name", "Role", "Status"],
|
||
|
|
rows: linkedRows,
|
||
|
|
},
|
||
|
|
|
||
|
|
{ type: "spacer", pt: 18 },
|
||
|
|
{ type: "divider" },
|
||
|
|
{ type: "caption", text:
|
||
|
|
"This report is generated from live CIPC data on file at Ysterbaard Corporate Services. For queries, contact your account manager."
|
||
|
|
},
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── Group report ─────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
export interface GroupReportData {
|
||
|
|
group: { id: string; name: string; description?: string };
|
||
|
|
companies: Array<YsterbaardCompany & { expand?: Record<string, unknown> }>;
|
||
|
|
directors: Array<YsterbaardDirector>;
|
||
|
|
/** companyId → its linked directors (with expand.director) */
|
||
|
|
companyDirectors?: Map<string, Array<{ director: YsterbaardDirector; director_type?: string }>>;
|
||
|
|
/** directorId → their linked companies (with expand.company) */
|
||
|
|
directorCompanies?: Map<string, Array<{ company: YsterbaardCompany; director_type?: string }>>;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function groupReportFilename(data: GroupReportData): string {
|
||
|
|
const display = data.group.description || data.group.name;
|
||
|
|
const slug = display.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, "");
|
||
|
|
return `${slug}_group_report.pdf`;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function groupReportTitle(data: GroupReportData): string {
|
||
|
|
const display = data.group.description || data.group.name;
|
||
|
|
return `Group Compliance Report — ${display}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function buildGroupReportBlocks(data: GroupReportData): ContentBlock[] {
|
||
|
|
const { group, companies, directors, companyDirectors, directorCompanies } = data;
|
||
|
|
const display = group.description || group.name;
|
||
|
|
|
||
|
|
const today = new Date().toLocaleDateString("en-ZA", {
|
||
|
|
day: "2-digit", month: "long", year: "numeric",
|
||
|
|
});
|
||
|
|
|
||
|
|
const blocks: ContentBlock[] = [
|
||
|
|
{ type: "h1", text: `Group Compliance Report — ${display}` },
|
||
|
|
{ type: "spacer", pt: 4 },
|
||
|
|
|
||
|
|
// ── Individual company reports ────────────────────────────────
|
||
|
|
...companies.flatMap((company, idx) => {
|
||
|
|
const coDirectors = companyDirectors?.get(company.id) ?? [];
|
||
|
|
return [
|
||
|
|
{ type: "pagebreak" as const },
|
||
|
|
...buildCompanySectionBlocks(company, coDirectors, idx + 1),
|
||
|
|
];
|
||
|
|
}),
|
||
|
|
|
||
|
|
// ── Individual director reports ─────────────────────────────
|
||
|
|
...directors.flatMap((director, idx) => {
|
||
|
|
const dCompanies = directorCompanies?.get(String(director.id_no ?? director.id)) ?? [];
|
||
|
|
return [
|
||
|
|
{ type: "pagebreak" as const },
|
||
|
|
...buildDirectorSectionBlocks(director, dCompanies, idx + 1),
|
||
|
|
];
|
||
|
|
}),
|
||
|
|
|
||
|
|
{ type: "spacer", pt: 18 },
|
||
|
|
{ type: "divider" },
|
||
|
|
{
|
||
|
|
type: "caption",
|
||
|
|
text: `Group Compliance Report — ${display}. Generated ${today} by Ysterbaard Corporate Services. Confidential — for the intended recipient only.`,
|
||
|
|
},
|
||
|
|
];
|
||
|
|
|
||
|
|
return blocks;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── Individual company section blocks ───────────────────────────────────────
|
||
|
|
|
||
|
|
function buildCompanySectionBlocks(
|
||
|
|
c: YsterbaardCompany & { expand?: Record<string, unknown> },
|
||
|
|
linkedDirectors: Array<{ director: YsterbaardDirector; director_type?: string }>,
|
||
|
|
index: number,
|
||
|
|
): ContentBlock[] {
|
||
|
|
const banner_status = entityStatusText(c.enterprise_status);
|
||
|
|
const ar_status = arStatusText(c);
|
||
|
|
|
||
|
|
// CIPC physical address (prefer) → fall back to manual fields
|
||
|
|
const phys = joinAddr(
|
||
|
|
c.address_line1 || c.physical_address_line_1,
|
||
|
|
c.address_line2 || c.physical_address_line_2,
|
||
|
|
c.address_street,
|
||
|
|
c.address_city || c.physical_town_city,
|
||
|
|
c.address_province || c.physical_province,
|
||
|
|
c.address_zip || c.physical_postal_code,
|
||
|
|
c.address_country,
|
||
|
|
);
|
||
|
|
// PO Box / private bag address (manual)
|
||
|
|
const pob = joinAddr(
|
||
|
|
c.pob_address_line1, c.pob_address_line2,
|
||
|
|
c.pob_city, c.pob_state, c.pob_zip, c.pob_country,
|
||
|
|
);
|
||
|
|
// Postal address: combined field → split manual fields
|
||
|
|
const post = c.postal_address || joinAddr(
|
||
|
|
c.postal_address_line_1, c.postal_address_line_2,
|
||
|
|
c.postal_town_city, c.postal_province, c.postal_postal_code,
|
||
|
|
);
|
||
|
|
|
||
|
|
const groups: string[] = (
|
||
|
|
(c.expand?.groups as Array<{ name: string }> | undefined) ?? []
|
||
|
|
).map((g) => g.name);
|
||
|
|
|
||
|
|
const yearsRegistered = c.registration_date
|
||
|
|
? String(new Date().getFullYear() - new Date(String(c.registration_date)).getFullYear())
|
||
|
|
: "—";
|
||
|
|
|
||
|
|
const linked = linkedDirectors.map((j) => {
|
||
|
|
const d = j.director;
|
||
|
|
const disq = String(d?.disqualification_status ?? "").toUpperCase();
|
||
|
|
const status = disq.includes("NOT DISQUAL") ? "Clear" : (d?.disqualification_status || "—");
|
||
|
|
return [
|
||
|
|
[d?.first_names, d?.surname].filter(Boolean).join(" "),
|
||
|
|
val(d?.id_no),
|
||
|
|
j.director_type || "Director",
|
||
|
|
status,
|
||
|
|
] as (string | number)[];
|
||
|
|
});
|
||
|
|
const linkedRows = linked.length ? linked : [["—", "—", "—", "—"]] as (string | number)[][];
|
||
|
|
|
||
|
|
return [
|
||
|
|
{ type: "h2", text: `${index}. ${val(c.enterprise_name)}` },
|
||
|
|
|
||
|
|
{
|
||
|
|
type: "callout", text:
|
||
|
|
`<b>Reg. No.:</b> ${val(c.enterprise_no)} · ` +
|
||
|
|
`<b>Status:</b> ${banner_status}<br/>` +
|
||
|
|
`<b>Annual returns:</b> ${ar_status}`
|
||
|
|
},
|
||
|
|
|
||
|
|
{ type: "h2", text: "At a glance" },
|
||
|
|
{
|
||
|
|
type: "table",
|
||
|
|
col_widths: [0.25, 0.25, 0.25, 0.25],
|
||
|
|
headers: ["Director status", "Entity status", "Years registered", "Financial year end"],
|
||
|
|
rows: [[val(c.director_status), val(c.enterprise_status), yearsRegistered, fmtMonth(c.financial_year_end_month)]],
|
||
|
|
},
|
||
|
|
|
||
|
|
{ type: "h2", text: "1 · Company identity" },
|
||
|
|
{
|
||
|
|
type: "table", col_widths: [0.38, 0.62], headers: ["Field", "Value"], rows: [
|
||
|
|
["Enterprise number", val(c.enterprise_no)],
|
||
|
|
["Enterprise name", val(c.enterprise_name)],
|
||
|
|
["Enterprise type", val(c.enterprise_type)],
|
||
|
|
["Enterprise status", val(c.enterprise_status)],
|
||
|
|
["Registration date", fmtDate(c.registration_date)],
|
||
|
|
["Financial year end", fmtMonth(c.financial_year_end_month)],
|
||
|
|
["Authorised shares", fmtNumber(c.authorised_shares)],
|
||
|
|
["Source", fmtSource(c.source)],
|
||
|
|
["Groups", groups.length ? groups.join(", ") : "None assigned"],
|
||
|
|
["First seen", fmtDate(c.first_seen)],
|
||
|
|
["Last checked", fmtDate(c.last_checked)],
|
||
|
|
]
|
||
|
|
},
|
||
|
|
|
||
|
|
{ type: "h2", text: "2 · Registered addresses" },
|
||
|
|
{ type: "h3", text: "CIPC physical address" },
|
||
|
|
{
|
||
|
|
type: "table", col_widths: [0.38, 0.62], headers: ["Field", "Value"], rows: [
|
||
|
|
["CIPC physical address", val(c.physical_address)],
|
||
|
|
]
|
||
|
|
},
|
||
|
|
{ type: "h3", text: "Physical address (manual)" },
|
||
|
|
{ type: "table", col_widths: [0.38, 0.62], headers: ["Field", "Value"], rows: [
|
||
|
|
["Address line 1", val(c.physical_address_line_1)],
|
||
|
|
["Address line 2", val(c.physical_address_line_2)],
|
||
|
|
["Town / city", val(c.physical_town_city)],
|
||
|
|
["Province", val(c.physical_province)],
|
||
|
|
["Postal code", val(c.physical_postal_code)],
|
||
|
|
]},
|
||
|
|
{ type: "h3", text: "CIPC postal address" },
|
||
|
|
{
|
||
|
|
type: "table", col_widths: [0.38, 0.62], headers: ["Field", "Value"], rows: [
|
||
|
|
["CIPC postal address", val(c.postal_address)],
|
||
|
|
]
|
||
|
|
},
|
||
|
|
{ type: "h3", text: "Postal address (manual)" },
|
||
|
|
{ type: "table", col_widths: [0.38, 0.62], headers: ["Field", "Value"], rows: [
|
||
|
|
["Address line 1", val(c.postal_address_line_1)],
|
||
|
|
["Address line 2", val(c.postal_address_line_2)],
|
||
|
|
["Town / city", val(c.postal_town_city)],
|
||
|
|
["Province", val(c.postal_province)],
|
||
|
|
["Postal code", val(c.postal_postal_code)],
|
||
|
|
]},
|
||
|
|
{ type: "h3", text: "PO Box address" },
|
||
|
|
{ type: "table", col_widths: [0.38, 0.62], headers: ["Field", "Value"], rows: [
|
||
|
|
["PO Box line 1", val(c.pob_address_line1)],
|
||
|
|
["PO Box line 2", val(c.pob_address_line2)],
|
||
|
|
["Town / city", val(c.pob_city)],
|
||
|
|
["Province", val(c.pob_state)],
|
||
|
|
["Postal code", val(c.pob_zip)],
|
||
|
|
]},
|
||
|
|
{ type: "h3", text: "Contact details" },
|
||
|
|
{ type: "table", col_widths: [0.38, 0.62], headers: ["Field", "Value"], rows: [
|
||
|
|
["Email", val(c.company_email)],
|
||
|
|
["Cell phone", val(c.company_cell_phone)],
|
||
|
|
]},
|
||
|
|
|
||
|
|
{ type: "h2", text: "3 · Governance & company officers" },
|
||
|
|
{
|
||
|
|
type: "table", col_widths: [0.38, 0.62], headers: ["Field", "Value"], rows: [
|
||
|
|
["Public officer", val(c.public_officer_name)],
|
||
|
|
["Company secretary", val(c.company_secretary_name)],
|
||
|
|
["Director status", val(c.director_status)],
|
||
|
|
["Compliance notice", val(c.compliance_notice)],
|
||
|
|
]
|
||
|
|
},
|
||
|
|
|
||
|
|
{ type: "h2", text: "4 · CIPC compliance snapshot" },
|
||
|
|
{ type: "callout", text: `<b>Annual returns:</b> ${ar_status}` },
|
||
|
|
{
|
||
|
|
type: "table", col_widths: [0.38, 0.62], headers: ["Field", "Value"], rows: [
|
||
|
|
["B-BBEE status", val(c.bbbee_status)],
|
||
|
|
["B-BBEE certificate applied", c.bbbee_certificate_applied ? "Yes" : "No"],
|
||
|
|
["Info Regulator registered", c.info_regulator_registered ? "Yes" : "No"],
|
||
|
|
["UIF registration number", val(c.uif_registration_number)],
|
||
|
|
["Compensation fund reg. no.", val(c.compensation_fund_registration_number)],
|
||
|
|
]
|
||
|
|
},
|
||
|
|
|
||
|
|
{ type: "h2", text: "5 · SARS eFiling details" },
|
||
|
|
{ type: "callout", text: "Confidential — credentials for the addressee only. Do not distribute." },
|
||
|
|
{
|
||
|
|
type: "table", col_widths: [0.38, 0.62], headers: ["Field", "Value"], rows: [
|
||
|
|
["SARS login username", val(c.sars_login_username)],
|
||
|
|
["SARS login password", val(c.sars_login_password)],
|
||
|
|
["SARS tax number", val(c.sars_tax_number)],
|
||
|
|
["SARS PAYE number", val(c.sars_paye_no)],
|
||
|
|
["PAYE return period", payePeriod(c.sars_paye_return_period)],
|
||
|
|
["SARS VAT number", val(c.sars_vat_no)],
|
||
|
|
["VAT return period", vatPeriod(c.sars_vat_returns_period)],
|
||
|
|
]
|
||
|
|
},
|
||
|
|
|
||
|
|
{ type: "h2", text: "6 · Linked directors" },
|
||
|
|
{
|
||
|
|
type: "table",
|
||
|
|
col_widths: [0.40, 0.25, 0.20, 0.15],
|
||
|
|
headers: ["Full name", "ID number", "Appointment", "Status"],
|
||
|
|
rows: linkedRows,
|
||
|
|
},
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── Individual director section blocks ───────────────────────────────────────
|
||
|
|
|
||
|
|
function buildDirectorSectionBlocks(
|
||
|
|
d: YsterbaardDirector,
|
||
|
|
linkedCompanies: Array<{ company: YsterbaardCompany; director_type?: string }>,
|
||
|
|
index: number,
|
||
|
|
): ContentBlock[] {
|
||
|
|
const fullName = [d.first_names, d.surname].filter(Boolean).join(" ") || "—";
|
||
|
|
const disq = String(d.disqualification_status ?? "").toUpperCase();
|
||
|
|
const dirBanner = disq.includes("NOT DISQUAL")
|
||
|
|
? "Director is in good standing"
|
||
|
|
: val(d.disqualification_status);
|
||
|
|
|
||
|
|
const res = d.residential_address || joinAddr(
|
||
|
|
d.residential_address_line_1, d.residential_address_line_2,
|
||
|
|
d.residential_town_city, d.residential_province, d.residential_postal_code,
|
||
|
|
);
|
||
|
|
const post = d.postal_address || joinAddr(
|
||
|
|
d.postal_address_line_1, d.postal_address_line_2,
|
||
|
|
d.postal_town_city, d.postal_province, d.postal_postal_code,
|
||
|
|
);
|
||
|
|
|
||
|
|
const yearsRegistered = d.appointment_date
|
||
|
|
? String(new Date().getFullYear() - new Date(String(d.appointment_date)).getFullYear())
|
||
|
|
: "—";
|
||
|
|
|
||
|
|
const linkedCoRows = linkedCompanies.map((j) => [
|
||
|
|
val(j.company.enterprise_no),
|
||
|
|
val(j.company.enterprise_name),
|
||
|
|
j.director_type || "Director",
|
||
|
|
val(j.company.enterprise_status),
|
||
|
|
] as (string | number)[]);
|
||
|
|
const linkedRows = linkedCoRows.length ? linkedCoRows : [["—", "—", "—", "—"]] as (string | number)[][];
|
||
|
|
|
||
|
|
return [
|
||
|
|
{ type: "h2", text: `${index}. ${fullName}` },
|
||
|
|
|
||
|
|
{
|
||
|
|
type: "callout", text:
|
||
|
|
`<b>ID No.:</b> ${val(d.id_no)} · ` +
|
||
|
|
`<b>Status:</b> ${dirBanner}<br/>` +
|
||
|
|
`<b>Companies:</b> ${val(d.total_companies)} total · ${val(d.active_companies)} active · ${val(d.inactive_companies)} inactive`
|
||
|
|
},
|
||
|
|
|
||
|
|
{ type: "h2", text: "At a glance" },
|
||
|
|
{
|
||
|
|
type: "table",
|
||
|
|
col_widths: [0.25, 0.25, 0.25, 0.25],
|
||
|
|
headers: ["Total companies", "Active companies", "Years registered", "Status"],
|
||
|
|
rows: [[val(d.total_companies), val(d.active_companies), yearsRegistered, val(d.disqualification_status)]],
|
||
|
|
},
|
||
|
|
|
||
|
|
{ type: "h2", text: "1 · Director identity" },
|
||
|
|
{
|
||
|
|
type: "table", col_widths: [0.38, 0.62], headers: ["Field", "Value"], rows: [
|
||
|
|
["Full name", fullName],
|
||
|
|
["SA ID number", val(d.id_no)],
|
||
|
|
["Date of birth", fmtDate(d.date_of_birth)],
|
||
|
|
["Disqualification status", val(d.disqualification_status)],
|
||
|
|
["Appointment date", fmtDate(d.appointment_date)],
|
||
|
|
["First seen", fmtDate(d.first_seen)],
|
||
|
|
["Last checked", fmtDate(d.last_checked)],
|
||
|
|
]
|
||
|
|
},
|
||
|
|
|
||
|
|
{ type: "h2", text: "2 · Contact details" },
|
||
|
|
{
|
||
|
|
type: "table", col_widths: [0.38, 0.62], headers: ["Field", "Value"], rows: [
|
||
|
|
["Telephone", val(d.telephone)],
|
||
|
|
["Email", val(d.email)],
|
||
|
|
["Source", fmtSource(d.source)],
|
||
|
|
]
|
||
|
|
},
|
||
|
|
|
||
|
|
{ type: "h2", text: "3 · Registered addresses" },
|
||
|
|
{
|
||
|
|
type: "table", col_widths: [0.38, 0.62], headers: ["Field", "Value"], rows: [
|
||
|
|
["Residential address", res],
|
||
|
|
["Postal address", post],
|
||
|
|
]
|
||
|
|
},
|
||
|
|
|
||
|
|
{ type: "h2", text: "4 · Company affiliations" },
|
||
|
|
{
|
||
|
|
type: "table",
|
||
|
|
col_widths: [0.20, 0.40, 0.20, 0.20],
|
||
|
|
headers: ["Reg. No.", "Company name", "Role", "Status"],
|
||
|
|
rows: linkedRows,
|
||
|
|
},
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|