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.
486 lines
20 KiB
TypeScript
486 lines
20 KiB
TypeScript
// app/directors/DirectorExplorer.tsx
|
|
// Client explorer. Holds the query string, sort, and page state;
|
|
// uses TanStack Query to fetch subsequent pages / filtered views
|
|
// through /api/pb/records/list. Renders the desktop <table> on
|
|
// lg+ and falls back to stacked cards on small screens.
|
|
|
|
"use client";
|
|
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import Link from "next/link";
|
|
import AddEntityModal from "@/components/AddEntityModal";
|
|
import EditEntityModal from "@/components/EditEntityModal";
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { pbList } from "@/lib/api";
|
|
import { text, formatDate, number } from "@/lib/format";
|
|
import type {
|
|
PBList,
|
|
YsterbaardDirector
|
|
} from "@/lib/types";
|
|
import StatusBadge from "@/components/StatusBadge";
|
|
import Skeleton from "@/components/Skeleton";
|
|
import ClientToggle from "@/components/ClientToggle";
|
|
|
|
const SORT_OPTIONS: { value: string; label: string }[] = [
|
|
{ value: "-last_checked", label: "Last checked (newest)" },
|
|
{ value: "last_checked", label: "Last checked (oldest)" },
|
|
{ value: "surname", label: "Surname (A→Z)" },
|
|
{ value: "-surname", label: "Surname (Z→A)" },
|
|
{ value: "first_names", label: "Name (A→Z)" },
|
|
{ value: "-first_names", label: "Name (Z→A)" },
|
|
{ value: "-total_companies", label: "Most companies" }
|
|
];
|
|
|
|
type ClientFilter = "all" | "active" | "inactive";
|
|
|
|
const CLIENT_OPTIONS: { value: ClientFilter; label: string }[] = [
|
|
{ value: "active", label: "Active Client" },
|
|
{ value: "inactive", label: "Inactive Clients" },
|
|
{ value: "all", label: "All records" }
|
|
];
|
|
|
|
interface Props {
|
|
initial: PBList<YsterbaardDirector>;
|
|
initialQuery: string;
|
|
initialSort: string;
|
|
initialPage: number;
|
|
initialClientFilter: ClientFilter;
|
|
}
|
|
|
|
function buildFilter(q: string): string | undefined {
|
|
const term = q.trim();
|
|
if (!term) return undefined;
|
|
const safe = term.replace(/'/g, "''");
|
|
return `(surname ~ '${safe}' || first_names ~ '${safe}' || id_no ~ '${safe}' || telephone ~ '${safe}')`;
|
|
}
|
|
|
|
function buildClientFilter(c: ClientFilter): string | undefined {
|
|
// "Active Client" = is_client = true (client of mine, regardless of company status)
|
|
// "Inactive Clients" = is_client = false (not my client)
|
|
// "All records" = no filter
|
|
switch (c) {
|
|
case "active":
|
|
return "is_client = true";
|
|
case "inactive":
|
|
return "is_client = false";
|
|
case "all":
|
|
default:
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function mergeFilters(a: string | undefined, b: string | undefined): string | undefined {
|
|
if (a && b) return `(${a}) && (${b})`;
|
|
return a ?? b;
|
|
}
|
|
|
|
export default function DirectorExplorer({
|
|
initial,
|
|
initialQuery,
|
|
initialSort,
|
|
initialPage,
|
|
initialClientFilter
|
|
}: Props) {
|
|
const [showAddDirector, setShowAddDirector] = useState(false);
|
|
const [showEditDirector, setShowEditDirector] = useState(false);
|
|
const [editingDirector, setEditingDirector] = useState<YsterbaardDirector | null>(null);
|
|
const [query, setQuery] = useState(initialQuery);
|
|
const [appliedQuery, setAppliedQuery] = useState(initialQuery);
|
|
const [sort, setSort] = useState(initialSort);
|
|
const [page, setPage] = useState(initialPage);
|
|
const [clientFilter, setClientFilter] = useState<ClientFilter>(initialClientFilter);
|
|
const queryClient = useQueryClient();
|
|
|
|
/**
|
|
* After a successful toggle, refetch the current page so the row's
|
|
* `is_client` and `active_companies` reflect PB truth. We refetch ALL
|
|
* client-filter pages (cheap; only the visible one is in cache).
|
|
*/
|
|
function refreshAll() {
|
|
queryClient.invalidateQueries({ queryKey: ["directors"] });
|
|
}
|
|
|
|
// Debounce: only push to the server 300ms after typing stops.
|
|
useEffect(() => {
|
|
const handle = setTimeout(() => {
|
|
if (query !== appliedQuery) {
|
|
setAppliedQuery(query);
|
|
setPage(1);
|
|
}
|
|
}, 300);
|
|
return () => clearTimeout(handle);
|
|
}, [query, appliedQuery]);
|
|
|
|
const searchFilter = buildFilter(appliedQuery);
|
|
const clientFilterString = buildClientFilter(clientFilter);
|
|
const combined = mergeFilters(searchFilter, clientFilterString);
|
|
const queryKey = useMemo(
|
|
() => ["directors", page, sort, combined ?? ""] as const,
|
|
[page, sort, combined]
|
|
);
|
|
|
|
const { data, isFetching, isError, error } = useQuery<PBList<YsterbaardDirector>>({
|
|
queryKey,
|
|
queryFn: () =>
|
|
pbList<YsterbaardDirector>({
|
|
collection: "ysterbaard_directors",
|
|
page,
|
|
perPage: 25,
|
|
sort,
|
|
filter: combined,
|
|
fields:
|
|
"id,id_no,surname,first_names,telephone,email,notes,director_type,date_of_birth,tax_reference_no,residential_address,residential_address_line_1,residential_address_line_2,residential_town_city,residential_province,residential_postal_code,postal_address,postal_address_line_1,postal_address_line_2,postal_town_city,postal_province,postal_postal_code,total_companies,active_companies,inactive_companies,disqualification_status,last_checked,is_client"
|
|
}),
|
|
initialData:
|
|
page === initialPage &&
|
|
sort === initialSort &&
|
|
combined === undefined
|
|
? initial
|
|
: undefined,
|
|
placeholderData: (prev) => prev
|
|
});
|
|
|
|
const list: PBList<YsterbaardDirector> = data ?? initial;
|
|
const totalPages = Math.max(1, list.totalPages);
|
|
const rowCount = list.totalItems;
|
|
|
|
return (
|
|
<div className="flex flex-col gap-6">
|
|
{/* Filters bar */}
|
|
<div className="bg-surface border border-border rounded-lg p-4 md:p-5 flex flex-col md:flex-row gap-3 md:items-center">
|
|
<label className="flex-1 flex items-center gap-3 border border-border rounded-pill px-4 py-2.5 bg-surface">
|
|
<svg
|
|
aria-hidden="true"
|
|
viewBox="0 0 24 24"
|
|
className="w-5 h-5 text-muted shrink-0"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.7"
|
|
>
|
|
<circle cx="11" cy="11" r="7" />
|
|
<path d="m20 20-3-3" />
|
|
</svg>
|
|
<input
|
|
type="search"
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
placeholder="Search by surname, names, ID, or phone"
|
|
className="flex-1 bg-transparent outline-none text-base text-fg placeholder:text-meta"
|
|
/>
|
|
</label>
|
|
<label className="md:w-64 flex items-center gap-2">
|
|
<span className="sr-only">Sort</span>
|
|
<select
|
|
value={sort}
|
|
onChange={(e) => {
|
|
setSort(e.target.value);
|
|
setPage(1);
|
|
}}
|
|
className="w-full border border-border rounded-md bg-surface px-3 py-2.5 text-base text-fg"
|
|
>
|
|
{SORT_OPTIONS.map((opt) => (
|
|
<option key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowAddDirector(true)}
|
|
className="shrink-0 px-4 py-2.5 rounded-lg bg-accent text-accent-on text-sm font-bold hover:bg-accent-hover transition-colors"
|
|
>
|
|
+ Add director
|
|
</button>
|
|
</div>
|
|
|
|
{/* Add Director Modal */}
|
|
<AddEntityModal
|
|
entity="director"
|
|
open={showAddDirector}
|
|
onClose={() => setShowAddDirector(false)}
|
|
/>
|
|
<EditEntityModal
|
|
entity="director"
|
|
record={editingDirector as YsterbaardDirector}
|
|
open={showEditDirector}
|
|
onClose={() => { setShowEditDirector(false); setEditingDirector(null); }}
|
|
onAfterSave={refreshAll}
|
|
/>
|
|
|
|
{/* Result count */}
|
|
<div className="flex flex-wrap items-center gap-2" role="tablist" aria-label="Client filter">
|
|
{CLIENT_OPTIONS.map((opt) => {
|
|
const active = clientFilter === opt.value;
|
|
return (
|
|
<button
|
|
key={opt.value}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={active}
|
|
onClick={() => {
|
|
setClientFilter(opt.value);
|
|
setPage(1);
|
|
}}
|
|
className={
|
|
"px-4 py-2 rounded-pill border text-sm font-semibold transition-colors " +
|
|
(active
|
|
? "bg-[color-mix(in_oklch,var(--accent)_15%,white)] text-[color:var(--accent)] border-[color:var(--accent)]/45"
|
|
: "bg-surface text-fg-2 border-border hover:bg-surface-warm")
|
|
}
|
|
>
|
|
{opt.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Result count */}
|
|
<div className="flex items-center justify-between text-sm text-muted">
|
|
<span>
|
|
{rowCount.toLocaleString()} director{rowCount === 1 ? "" : "s"}
|
|
{appliedQuery ? ` matching “${appliedQuery}”` : ""}
|
|
{clientFilter !== "all" ? `, ${CLIENT_OPTIONS.find((o) => o.value === clientFilter)?.label.toLowerCase()}` : ""}
|
|
</span>
|
|
{isFetching ? <span>Refreshing…</span> : null}
|
|
</div>
|
|
|
|
{isError ? (
|
|
<div className="border border-danger/30 bg-[color-mix(in_oklch,var(--danger)_8%,white)] rounded-lg p-6 text-sm text-[color:var(--danger)]">
|
|
Couldn't load directors: {error?.message ?? "Unknown error"}
|
|
</div>
|
|
) : null}
|
|
|
|
{/* Desktop table */}
|
|
<div className="hidden lg:block bg-surface border border-border rounded-lg overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-surface-warm text-muted">
|
|
<tr>
|
|
<th className="text-left font-semibold px-4 py-3">ID Number</th>
|
|
<th className="text-left font-semibold px-4 py-3">Surname</th>
|
|
<th className="text-left font-semibold px-4 py-3">First Names</th>
|
|
<th className="text-right font-semibold px-4 py-3">Companies</th>
|
|
<th className="text-left font-semibold px-4 py-3">Telephone</th>
|
|
<th className="text-left font-semibold px-4 py-3">Status</th>
|
|
<th className="text-left font-semibold px-4 py-3">Client</th>
|
|
<th className="text-left font-semibold px-4 py-3">Last checked</th>
|
|
<th className="px-3 py-3"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{list.items.map((d) => {
|
|
const isDisqualified = (d.disqualification_status ?? "")
|
|
.toLowerCase()
|
|
.includes("disqual");
|
|
const isClient = Boolean(d.is_client);
|
|
return (
|
|
<tr
|
|
key={d.id}
|
|
className="border-t border-border hover:bg-surface-warm transition-colors"
|
|
>
|
|
<td className="px-4 py-3 font-mono text-fg whitespace-nowrap">
|
|
{text(d.id_no)}
|
|
</td>
|
|
<td className="px-4 py-3 text-fg font-semibold">
|
|
{text(d.surname)}
|
|
</td>
|
|
<td className="px-4 py-3 text-fg-2">{text(d.first_names)}</td>
|
|
<td className="px-4 py-3 text-right font-semibold text-fg tabular-nums">
|
|
{number(d.total_companies)}
|
|
</td>
|
|
<td className="px-4 py-3 text-fg-2 font-mono whitespace-nowrap">
|
|
{text(d.telephone)}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
{isDisqualified ? (
|
|
<StatusBadge variant="deregistered">Disqualified</StatusBadge>
|
|
) : (d.total_companies ?? 0) > 0 ? (
|
|
<StatusBadge variant="active">Active</StatusBadge>
|
|
) : (
|
|
<StatusBadge variant="muted">No roles</StatusBadge>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<ClientToggle
|
|
collection="ysterbaard_directors"
|
|
id={d.id}
|
|
isClient={isClient}
|
|
variant="pill"
|
|
onAfterToggle={refreshAll}
|
|
/>
|
|
</td>
|
|
<td className="px-4 py-3 text-muted whitespace-nowrap">
|
|
{formatDate(d.last_checked)}
|
|
</td>
|
|
<td className="px-4 py-3 pr-5 whitespace-nowrap">
|
|
<div className="flex items-center gap-1.5 justify-end">
|
|
<button
|
|
type="button"
|
|
aria-label={`Edit ${d.surname}`}
|
|
onClick={() => { setEditingDirector(d); setShowEditDirector(true); }}
|
|
className="text-xs px-2 py-0.5 rounded border border-border bg-surface-warm text-fg hover:border-accent hover:text-accent transition-colors"
|
|
>
|
|
Edit
|
|
</button>
|
|
<Link
|
|
href={`/bizportal?type=id&q=${encodeURIComponent(d.id_no || "")}`}
|
|
aria-label={`BizSearch ${d.surname}`}
|
|
title={d.id_no ? `Search BizPortal for ${d.id_no}` : "No ID number on file"}
|
|
className="text-xs px-2 py-0.5 rounded border border-border bg-surface-warm no-underline text-fg hover:border-accent hover:text-accent transition-colors"
|
|
>
|
|
BizSearch
|
|
</Link>
|
|
<Link
|
|
href={`/directors/${d.id}`}
|
|
aria-label={`View ${d.surname}`}
|
|
className="text-xs px-2 py-0.5 rounded border border-border bg-surface-warm no-underline text-fg hover:border-accent hover:text-accent transition-colors"
|
|
>
|
|
View →
|
|
</Link>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
{!isFetching && list.items.length === 0 ? (
|
|
<tr>
|
|
<td
|
|
colSpan={10}
|
|
className="px-5 py-12 text-center text-muted"
|
|
>
|
|
No directors found.
|
|
</td>
|
|
</tr>
|
|
) : null}
|
|
{isFetching
|
|
? Array.from({ length: 6 }).map((_, i) => (
|
|
<tr key={`sk-${i}`} className="border-t border-border">
|
|
<td className="px-4 py-3"><Skeleton className="h-4 w-32" /></td>
|
|
<td className="px-4 py-3"><Skeleton className="h-4 w-28" /></td>
|
|
<td className="px-4 py-3"><Skeleton className="h-4 w-36" /></td>
|
|
<td className="px-4 py-3"><Skeleton className="h-4 w-10" /></td>
|
|
<td className="px-4 py-3"><Skeleton className="h-4 w-28" /></td>
|
|
<td className="px-4 py-3"><Skeleton className="h-5 w-20" /></td>
|
|
<td className="px-4 py-3"><Skeleton className="h-5 w-24" /></td>
|
|
<td className="px-4 py-3"><Skeleton className="h-4 w-24" /></td>
|
|
<td className="px-4 py-3"><Skeleton className="h-5 w-24" /></td>
|
|
<td className="px-3 py-3"></td>
|
|
</tr>
|
|
))
|
|
: null}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Mobile cards */}
|
|
<div className="lg:hidden flex flex-col gap-3">
|
|
{list.items.map((d) => {
|
|
const isDisqualified = (d.disqualification_status ?? "")
|
|
.toLowerCase()
|
|
.includes("disqual");
|
|
const isClient = Boolean(d.is_client);
|
|
return (
|
|
<div
|
|
key={d.id}
|
|
className="bg-surface border border-border rounded-lg p-4 flex flex-col gap-3"
|
|
>
|
|
<Link
|
|
href={`/directors/${d.id}`}
|
|
className="flex items-center justify-between gap-3 no-underline hover:opacity-80 transition-opacity"
|
|
>
|
|
<div>
|
|
<p className="text-base font-semibold text-fg">
|
|
{text(d.first_names)} {text(d.surname)}
|
|
</p>
|
|
<p className="text-sm text-muted font-mono">
|
|
{text(d.id_no)}
|
|
</p>
|
|
</div>
|
|
{isDisqualified ? (
|
|
<StatusBadge variant="deregistered">Disqualified</StatusBadge>
|
|
) : (d.total_companies ?? 0) > 0 ? (
|
|
<StatusBadge variant="active">Active</StatusBadge>
|
|
) : (
|
|
<StatusBadge variant="muted">No roles</StatusBadge>
|
|
)}
|
|
</Link>
|
|
<div className="grid grid-cols-2 gap-2 text-sm text-fg-2">
|
|
<div>
|
|
<p className="text-xs text-muted uppercase tracking-wide">Companies</p>
|
|
<p className="font-semibold tabular-nums">
|
|
{number(d.total_companies)}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-muted uppercase tracking-wide">Phone</p>
|
|
<p className="font-mono">{text(d.telephone)}</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center justify-between gap-3 pt-1 border-t border-border-soft">
|
|
<ClientToggle
|
|
collection="ysterbaard_directors"
|
|
id={d.id}
|
|
isClient={isClient}
|
|
variant="pill"
|
|
onAfterToggle={refreshAll}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => { setEditingDirector(d); setShowEditDirector(true); }}
|
|
className="text-xs px-2.5 py-1 rounded border border-border bg-surface-warm text-fg hover:border-accent hover:text-accent transition-colors"
|
|
>
|
|
Edit
|
|
</button>
|
|
<Link
|
|
href={`/bizportal?type=id&q=${encodeURIComponent(d.id_no || "")}`}
|
|
title={d.id_no ? `Search BizPortal for ${d.id_no}` : "No ID number on file"}
|
|
className="text-xs px-2.5 py-1 rounded border border-border bg-surface-warm no-underline text-fg hover:border-accent hover:text-accent transition-colors"
|
|
>
|
|
BizSearch
|
|
</Link>
|
|
<Link
|
|
href={`/directors/${d.id}`}
|
|
className="text-xs font-semibold no-underline text-fg hover:text-accent transition-colors"
|
|
>
|
|
View →
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
{!isFetching && list.items.length === 0 ? (
|
|
<div className="bg-surface border border-border rounded-lg p-8 text-center text-muted">
|
|
No directors found.
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
{/* Pagination */}
|
|
{totalPages > 1 ? (
|
|
<div className="flex items-center justify-between border-t border-border pt-4">
|
|
<button
|
|
type="button"
|
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
disabled={page <= 1}
|
|
className="px-4 py-2 rounded-md border border-border bg-surface text-fg text-sm font-semibold disabled:opacity-40 disabled:cursor-not-allowed hover:bg-surface-warm"
|
|
>
|
|
← Previous
|
|
</button>
|
|
<span className="text-sm text-muted">
|
|
Page {page} of {totalPages}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
|
disabled={page >= totalPages}
|
|
className="px-4 py-2 rounded-md border border-border bg-surface text-fg text-sm font-semibold disabled:opacity-40 disabled:cursor-not-allowed hover:bg-surface-warm"
|
|
>
|
|
Next →
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|