Workflow-App/Workflow/app/directors/DirectorExplorer.tsx
sjcoetzeesj-coder 11d0178ab1 feat: commit Workflow app source; move CIPC credentials to env
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.
2026-10-18 15:21:45 +02:00

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&apos;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>
);
}