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.
293 lines
No EOL
10 KiB
TypeScript
293 lines
No EOL
10 KiB
TypeScript
"use client";
|
|
|
|
// components/TaxRepresentativeSection.tsx
|
|
// Single-director picker for the company's SARS tax representative.
|
|
// Pattern mirrors LinkEntityModal: search-as-you-type via /api/pb/records/list,
|
|
// click to select. The selection is saved directly on the company via PATCH
|
|
// (no junction table — one tax rep per company, by design). Added 2026-09-16.
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
import Link from "next/link";
|
|
import { useRouter } from "next/navigation";
|
|
import { pbPatch } from "@/lib/api";
|
|
import { text, number } from "@/lib/format";
|
|
|
|
interface DirectorHit {
|
|
id: string;
|
|
first_names: string | null;
|
|
surname: string | null;
|
|
id_no: string | null;
|
|
tax_reference_no: number | null;
|
|
}
|
|
|
|
interface Props {
|
|
companyId: string;
|
|
/** Currently-linked director ID, or empty string. */
|
|
initialDirectorId: string;
|
|
/** Expanded payload for the currently-linked director. May be undefined
|
|
* if the server-side expand wasn't populated. */
|
|
initialDirector?: DirectorHit;
|
|
}
|
|
|
|
export default function TaxRepresentativeSection({
|
|
companyId,
|
|
initialDirectorId,
|
|
initialDirector,
|
|
}: Props) {
|
|
const router = useRouter();
|
|
const [picking, setPicking] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// Search state — only active when picking=true
|
|
const [query, setQuery] = useState("");
|
|
const [results, setResults] = useState<DirectorHit[]>([]);
|
|
const [searching, setSearching] = useState(false);
|
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
|
|
|
// Optimistic local state for instant feedback after PATCH
|
|
const [directorId, setDirectorId] = useState(initialDirectorId);
|
|
const [director, setDirector] = useState<DirectorHit | undefined>(
|
|
initialDirector,
|
|
);
|
|
|
|
// Search-as-you-type, debounced 300ms (same as LinkEntityModal)
|
|
useEffect(() => {
|
|
if (!picking) return;
|
|
clearTimeout(debounceRef.current);
|
|
if (!query.trim()) {
|
|
setResults([]);
|
|
return;
|
|
}
|
|
debounceRef.current = setTimeout(async () => {
|
|
setSearching(true);
|
|
setError(null);
|
|
try {
|
|
const q = query.trim().replace(/'/g, "''");
|
|
const filter = `(surname ~ '${q}' || first_names ~ '${q}' || id_no ~ '${q}')`;
|
|
const res = await fetch(
|
|
`/api/pb/records/list?collection=ysterbaard_directors&filter=${encodeURIComponent(filter)}&perPage=20&fields=id,first_names,surname,id_no,tax_reference_no`,
|
|
);
|
|
const data = await res.json();
|
|
const items: DirectorHit[] = (data.items ?? []).map(
|
|
(it: Record<string, unknown>) => ({
|
|
id: it.id as string,
|
|
first_names: (it.first_names as string | null) ?? null,
|
|
surname: (it.surname as string | null) ?? null,
|
|
id_no: (it.id_no as string | null) ?? null,
|
|
tax_reference_no:
|
|
(it.tax_reference_no as number | null) ?? null,
|
|
}),
|
|
);
|
|
setResults(items);
|
|
} catch {
|
|
setError("Search failed. Try again.");
|
|
} finally {
|
|
setSearching(false);
|
|
}
|
|
}, 300);
|
|
return () => clearTimeout(debounceRef.current);
|
|
}, [query, picking]);
|
|
|
|
async function choose(d: DirectorHit) {
|
|
setSaving(true);
|
|
setError(null);
|
|
try {
|
|
await pbPatch({
|
|
collection: "ysterbaard_companies",
|
|
id: companyId,
|
|
fields: { tax_representative: d.id },
|
|
});
|
|
setDirectorId(d.id);
|
|
setDirector(d);
|
|
setPicking(false);
|
|
setQuery("");
|
|
setResults([]);
|
|
router.refresh();
|
|
} catch (e: unknown) {
|
|
setError(e instanceof Error ? e.message : "Could not save");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
async function unlink() {
|
|
if (
|
|
!confirm(
|
|
"Remove the tax representative? The director will remain in the database.",
|
|
)
|
|
)
|
|
return;
|
|
setSaving(true);
|
|
setError(null);
|
|
try {
|
|
await pbPatch({
|
|
collection: "ysterbaard_companies",
|
|
id: companyId,
|
|
fields: { tax_representative: "" },
|
|
});
|
|
setDirectorId("");
|
|
setDirector(undefined);
|
|
router.refresh();
|
|
} catch (e: unknown) {
|
|
setError(e instanceof Error ? e.message : "Could not save");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
const linked = directorId && director;
|
|
const displayName = (d: DirectorHit) =>
|
|
`${text(d.first_names)} ${text(d.surname)}`.trim();
|
|
|
|
return (
|
|
<section className="bg-surface border border-border rounded-lg p-6 md:p-8 flex flex-col gap-4">
|
|
<div className="flex items-baseline justify-between gap-3 flex-wrap">
|
|
<div>
|
|
<h2 className="text-xl font-semibold text-fg">Tax representative</h2>
|
|
<p className="text-sm text-muted mt-1">
|
|
Director who acts as the SARS tax representative for this company.
|
|
</p>
|
|
</div>
|
|
{!picking ? (
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setPicking(true)}
|
|
disabled={saving}
|
|
className="px-3 py-1.5 rounded-md bg-accent text-accent-on text-xs font-bold hover:bg-accent-hover transition-colors disabled:opacity-50"
|
|
>
|
|
{linked ? "Change" : "+ Link tax representative"}
|
|
</button>
|
|
{linked ? (
|
|
<button
|
|
type="button"
|
|
onClick={unlink}
|
|
disabled={saving}
|
|
className="text-xs px-2.5 py-1 rounded border border-red-500/30 bg-red-500/10 text-red-400 hover:bg-red-500/20 transition-colors disabled:opacity-40"
|
|
>
|
|
Remove
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
{error ? (
|
|
<div className="px-4 py-2.5 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm">
|
|
{error}
|
|
</div>
|
|
) : null}
|
|
|
|
{linked ? (
|
|
<div className="border-t border-border-soft pt-4 grid grid-cols-1 sm:grid-cols-3 gap-px bg-border border border-border rounded-md overflow-hidden">
|
|
<div className="bg-surface p-4">
|
|
<p className="text-xs uppercase tracking-[0.08em] text-muted font-semibold mb-1">
|
|
Name
|
|
</p>
|
|
<p className="text-base font-bold text-fg">
|
|
{text(director.surname) || "—"}
|
|
</p>
|
|
{text(director.first_names) ? (
|
|
<p className="text-xs text-muted mt-0.5">
|
|
{text(director.first_names)}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
<div className="bg-surface p-4">
|
|
<p className="text-xs uppercase tracking-[0.08em] text-muted font-semibold mb-1">
|
|
ID Number
|
|
</p>
|
|
<p className="text-base text-fg font-mono">{text(director.id_no)}</p>
|
|
</div>
|
|
<div className="bg-surface p-4">
|
|
<p className="text-xs uppercase tracking-[0.08em] text-muted font-semibold mb-1">
|
|
Tax reference
|
|
</p>
|
|
<p className="text-base text-fg font-mono">
|
|
{director.tax_reference_no != null && director.tax_reference_no > 0
|
|
? number(director.tax_reference_no)
|
|
: "—"}
|
|
</p>
|
|
</div>
|
|
<div className="col-span-1 sm:col-span-3 bg-surface p-4 border-t border-border-soft">
|
|
<Link
|
|
href={`/directors/${director.id}`}
|
|
className="text-xs font-semibold text-accent hover:underline"
|
|
>
|
|
View director profile →
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
) : !picking ? (
|
|
<div className="border-t border-border-soft pt-4 text-sm text-muted">
|
|
No tax representative assigned.
|
|
</div>
|
|
) : null}
|
|
|
|
{picking ? (
|
|
<div className="border-t border-border-soft pt-4 flex flex-col gap-3">
|
|
<label className="flex flex-col gap-1.5">
|
|
<span className="text-xs uppercase tracking-[0.08em] text-muted font-semibold">
|
|
Search directors
|
|
</span>
|
|
<input
|
|
type="search"
|
|
autoFocus
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
placeholder="Type surname, first names, or ID no…"
|
|
className="w-full border border-border rounded-lg bg-surface-warm px-3 py-2.5 text-sm text-fg outline-none focus:border-accent transition-colors"
|
|
/>
|
|
</label>
|
|
|
|
{searching ? (
|
|
<p className="text-sm text-muted text-center py-3">Searching…</p>
|
|
) : !query.trim() ? (
|
|
<p className="text-sm text-muted text-center py-3">Type to search…</p>
|
|
) : results.length === 0 ? (
|
|
<p className="text-sm text-muted text-center py-3">No matches found.</p>
|
|
) : (
|
|
<div className="flex flex-col gap-2">
|
|
{results.map((d) => (
|
|
<button
|
|
key={d.id}
|
|
type="button"
|
|
disabled={saving}
|
|
onClick={() => choose(d)}
|
|
className="w-full flex flex-col items-start gap-1 px-4 py-3 rounded-lg border border-border bg-surface-warm hover:border-accent transition-colors text-left disabled:opacity-50"
|
|
>
|
|
<p className="font-semibold text-fg truncate w-full">
|
|
{displayName(d) || "—"}
|
|
</p>
|
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-0.5 text-xs text-muted font-mono">
|
|
<span>ID {text(d.id_no)}</span>
|
|
{d.tax_reference_no != null && d.tax_reference_no > 0 ? (
|
|
<span>Tax ref {number(d.tax_reference_no)}</span>
|
|
) : null}
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center justify-end gap-2 pt-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setPicking(false);
|
|
setQuery("");
|
|
setResults([]);
|
|
setError(null);
|
|
}}
|
|
disabled={saving}
|
|
className="text-xs px-3 py-1.5 rounded-md border border-border bg-surface-warm text-fg hover:bg-surface transition-colors disabled:opacity-50"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</section>
|
|
);
|
|
} |