Workflow-App/Workflow/components/TaxRepresentativeSection.tsx

293 lines
10 KiB
TypeScript
Raw Permalink Normal View History

"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>
);
}