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.
390 lines
16 KiB
TypeScript
390 lines
16 KiB
TypeScript
"use client";
|
||
|
||
// components/AddEntityModal.tsx
|
||
// Shared modal for adding a new client director or company.
|
||
// Opens on "Add Director" / "Add Company" button click.
|
||
// On submit: POSTs to /api/pb/records/create, invalidates the
|
||
// relevant list query, shows success toast, closes modal.
|
||
|
||
import { useState, useEffect, useRef } from "react";
|
||
import { useQueryClient } from "@tanstack/react-query";
|
||
import { pbCreateClient } from "@/lib/api";
|
||
import { pbList } from "@/lib/api";
|
||
|
||
interface Props {
|
||
/** "director" | "company" */
|
||
entity: "director" | "company";
|
||
/** Controlled open state (passed from parent) */
|
||
open: boolean;
|
||
/** Called when modal requests to close (user dismissed or success) */
|
||
onClose: () => void;
|
||
}
|
||
|
||
interface Director {
|
||
id: string;
|
||
surname: string;
|
||
first_names: string;
|
||
id_no: string;
|
||
}
|
||
|
||
export default function AddEntityModal({ entity, open, onClose }: Props) {
|
||
const qc = useQueryClient();
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const overlayRef = useRef<HTMLDivElement>(null);
|
||
|
||
// Director form state
|
||
const [surname, setSurname] = useState("");
|
||
const [firstNames, setFirstNames] = useState("");
|
||
const [idNo, setIdNo] = useState("");
|
||
const [telephone, setTelephone] = useState("");
|
||
const [email, setEmail] = useState("");
|
||
|
||
// Company form state
|
||
const [enterpriseNo, setEnterpriseNo] = useState("");
|
||
const [enterpriseName, setEnterpriseName] = useState("");
|
||
const [enterpriseType, setEnterpriseType] = useState("");
|
||
const [registrationDate, setRegistrationDate] = useState("");
|
||
const [physicalAddress, setPhysicalAddress] = useState("");
|
||
const [postalAddress, setPostalAddress] = useState("");
|
||
const [selectedDirectorId, setSelectedDirectorId] = useState("");
|
||
|
||
// Directors list for company form dropdown
|
||
const [directors, setDirectors] = useState<Director[]>([]);
|
||
|
||
// Reset form whenever modal opens
|
||
useEffect(() => {
|
||
if (open) {
|
||
setSurname(""); setFirstNames(""); setIdNo(""); setTelephone(""); setEmail("");
|
||
setEnterpriseNo(""); setEnterpriseName(""); setEnterpriseType("");
|
||
setRegistrationDate(""); setPhysicalAddress(""); setPostalAddress("");
|
||
setSelectedDirectorId(""); setError(null);
|
||
}
|
||
}, [open]);
|
||
|
||
// Load directors list when opening the company form
|
||
useEffect(() => {
|
||
if (open && entity === "company") {
|
||
pbList<Director>({
|
||
collection: "ysterbaard_directors",
|
||
perPage: 500,
|
||
sort: "surname",
|
||
fields: "id,surname,first_names,id_no",
|
||
}).then((r) => setDirectors(r.items)).catch(() => {});
|
||
}
|
||
}, [open, entity]);
|
||
|
||
if (!open) return null;
|
||
|
||
const handleOverlayClick = (e: React.MouseEvent) => {
|
||
if (e.target === overlayRef.current) onClose();
|
||
};
|
||
|
||
const isDirector = entity === "director";
|
||
const title = isDirector ? "Add new client director" : "Add new client company";
|
||
const subtitle = isDirector
|
||
? "Fill in the details. The director will be saved with is_client=true."
|
||
: "Fill in the details. The company will be saved with is_client=true.";
|
||
|
||
const handleSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
setError(null);
|
||
setLoading(true);
|
||
|
||
try {
|
||
if (isDirector) {
|
||
if (!surname.trim() || !firstNames.trim()) {
|
||
setError("Surname and first names are required.");
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
const fields: Record<string, string> = {
|
||
surname: surname.trim(),
|
||
first_names: firstNames.trim(),
|
||
source: "app",
|
||
};
|
||
if (idNo.trim()) fields.id_no = idNo.trim();
|
||
if (telephone.trim()) fields.telephone = telephone.trim();
|
||
if (email.trim()) fields.email = email.trim();
|
||
await pbCreateClient({ collection: "ysterbaard_directors", fields });
|
||
await qc.invalidateQueries({ queryKey: ["pb-list", "ysterbaard_directors"] });
|
||
} else {
|
||
if (!enterpriseNo.trim() || !enterpriseName.trim()) {
|
||
setError("Enterprise number and name are required.");
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
const fields: Record<string, string> = {
|
||
enterprise_no: enterpriseNo.trim().toUpperCase(),
|
||
enterprise_name: enterpriseName.trim().toUpperCase(),
|
||
source: "app",
|
||
};
|
||
if (enterpriseType.trim()) fields.enterprise_type = enterpriseType.trim();
|
||
if (registrationDate.trim()) fields.registration_date = registrationDate.trim();
|
||
if (physicalAddress.trim()) fields.physical_address = physicalAddress.trim();
|
||
if (postalAddress.trim()) fields.postal_address = postalAddress.trim();
|
||
if (selectedDirectorId) fields.director = selectedDirectorId;
|
||
await pbCreateClient({ collection: "ysterbaard_companies", fields });
|
||
await qc.invalidateQueries({ queryKey: ["pb-list", "ysterbaard_companies"] });
|
||
}
|
||
onClose();
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Something went wrong. Please try again.");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div
|
||
ref={overlayRef}
|
||
onClick={handleOverlayClick}
|
||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label={title}
|
||
>
|
||
<div className="bg-surface border border-border rounded-xl shadow-2xl w-full max-w-lg flex flex-col max-h-[90vh]">
|
||
{/* Header */}
|
||
<div className="px-6 pt-6 pb-4 border-b border-border flex-shrink-0">
|
||
<div className="flex items-start justify-between">
|
||
<div>
|
||
<h2 className="text-lg font-bold text-fg">{title}</h2>
|
||
<p className="text-sm text-muted mt-0.5">{subtitle}</p>
|
||
</div>
|
||
<button
|
||
onClick={onClose}
|
||
className="ml-4 text-muted hover:text-fg text-xl leading-none p-1 rounded transition-colors"
|
||
aria-label="Close"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Form */}
|
||
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto px-6 py-5 space-y-4">
|
||
{error && (
|
||
<div className="bg-red-500/10 border border-red-500/30 rounded-lg px-4 py-3 text-sm text-red-400">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
{isDirector ? (
|
||
<>
|
||
{/* Surname */}
|
||
<div>
|
||
<label className="block text-sm font-semibold text-fg mb-1.5" htmlFor="surname">
|
||
Surname <span className="text-red-400">*</span>
|
||
</label>
|
||
<input
|
||
id="surname"
|
||
type="text"
|
||
value={surname}
|
||
onChange={(e) => setSurname(e.target.value.toUpperCase())}
|
||
className="w-full rounded-lg border border-border bg-surface-warm px-3 py-2.5 text-sm text-fg placeholder:text-muted focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent"
|
||
placeholder="e.g. JONES"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
{/* First names */}
|
||
<div>
|
||
<label className="block text-sm font-semibold text-fg mb-1.5" htmlFor="firstNames">
|
||
First names <span className="text-red-400">*</span>
|
||
</label>
|
||
<input
|
||
id="firstNames"
|
||
type="text"
|
||
value={firstNames}
|
||
onChange={(e) => setFirstNames(e.target.value.toUpperCase())}
|
||
className="w-full rounded-lg border border-border bg-surface-warm px-3 py-2.5 text-sm text-fg placeholder:text-muted focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent"
|
||
placeholder="e.g. PETER JOHANNES"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
{/* ID number */}
|
||
<div>
|
||
<label className="block text-sm font-semibold text-fg mb-1.5" htmlFor="idNo">
|
||
SA ID number <span className="text-xs text-muted font-normal">(optional)</span>
|
||
</label>
|
||
<input
|
||
id="idNo"
|
||
type="tel"
|
||
value={idNo}
|
||
onChange={(e) => setIdNo(e.target.value.replace(/\D/g, "").slice(0, 13))}
|
||
className="w-full rounded-lg border border-border bg-surface-warm px-3 py-2.5 text-sm text-fg placeholder:text-muted focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent font-mono"
|
||
placeholder="13 digits"
|
||
maxLength={13}
|
||
/>
|
||
</div>
|
||
|
||
{/* Telephone */}
|
||
<div>
|
||
<label className="block text-sm font-semibold text-fg mb-1.5" htmlFor="telephone">
|
||
Telephone <span className="text-xs text-muted font-normal">(optional)</span>
|
||
</label>
|
||
<input
|
||
id="telephone"
|
||
type="tel"
|
||
value={telephone}
|
||
onChange={(e) => setTelephone(e.target.value)}
|
||
className="w-full rounded-lg border border-border bg-surface-warm px-3 py-2.5 text-sm text-fg placeholder:text-muted focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent"
|
||
placeholder="0821234567"
|
||
/>
|
||
</div>
|
||
|
||
{/* Email */}
|
||
<div>
|
||
<label className="block text-sm font-semibold text-fg mb-1.5" htmlFor="email">
|
||
Email <span className="text-xs text-muted font-normal">(optional)</span>
|
||
</label>
|
||
<input
|
||
id="email"
|
||
type="email"
|
||
value={email}
|
||
onChange={(e) => setEmail(e.target.value)}
|
||
className="w-full rounded-lg border border-border bg-surface-warm px-3 py-2.5 text-sm text-fg placeholder:text-muted focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent"
|
||
placeholder="director@example.com"
|
||
/>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<>
|
||
{/* Enterprise number */}
|
||
<div>
|
||
<label className="block text-sm font-semibold text-fg mb-1.5" htmlFor="enterpriseNo">
|
||
Enterprise number <span className="text-red-400">*</span>
|
||
</label>
|
||
<input
|
||
id="enterpriseNo"
|
||
type="text"
|
||
value={enterpriseNo}
|
||
onChange={(e) => setEnterpriseNo(e.target.value.toUpperCase())}
|
||
className="w-full rounded-lg border border-border bg-surface-warm px-3 py-2.5 text-sm text-fg font-mono placeholder:text-muted focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent"
|
||
placeholder="K2022808158"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
{/* Enterprise name */}
|
||
<div>
|
||
<label className="block text-sm font-semibold text-fg mb-1.5" htmlFor="enterpriseName">
|
||
Enterprise name <span className="text-red-400">*</span>
|
||
</label>
|
||
<input
|
||
id="enterpriseName"
|
||
type="text"
|
||
value={enterpriseName}
|
||
onChange={(e) => setEnterpriseName(e.target.value.toUpperCase())}
|
||
className="w-full rounded-lg border border-border bg-surface-warm px-3 py-2.5 text-sm text-fg placeholder:text-muted focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent"
|
||
placeholder="ABC TRADING PTY LTD"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
{/* Enterprise type */}
|
||
<div>
|
||
<label className="block text-sm font-semibold text-fg mb-1.5" htmlFor="enterpriseType">
|
||
Enterprise type <span className="text-xs text-muted font-normal">(optional)</span>
|
||
</label>
|
||
<input
|
||
id="enterpriseType"
|
||
type="text"
|
||
value={enterpriseType}
|
||
onChange={(e) => setEnterpriseType(e.target.value)}
|
||
className="w-full rounded-lg border border-border bg-surface-warm px-3 py-2.5 text-sm text-fg placeholder:text-muted focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent"
|
||
placeholder="Private Company"
|
||
/>
|
||
</div>
|
||
|
||
{/* Registration date */}
|
||
<div>
|
||
<label className="block text-sm font-semibold text-fg mb-1.5" htmlFor="registrationDate">
|
||
Registration date <span className="text-xs text-muted font-normal">(optional)</span>
|
||
</label>
|
||
<input
|
||
id="registrationDate"
|
||
type="date"
|
||
value={registrationDate}
|
||
onChange={(e) => setRegistrationDate(e.target.value)}
|
||
className="w-full rounded-lg border border-border bg-surface-warm px-3 py-2.5 text-sm text-fg focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent"
|
||
/>
|
||
</div>
|
||
|
||
{/* Physical address */}
|
||
<div>
|
||
<label className="block text-sm font-semibold text-fg mb-1.5" htmlFor="physicalAddress">
|
||
Physical address <span className="text-xs text-muted font-normal">(optional)</span>
|
||
</label>
|
||
<textarea
|
||
id="physicalAddress"
|
||
value={physicalAddress}
|
||
onChange={(e) => setPhysicalAddress(e.target.value)}
|
||
rows={2}
|
||
className="w-full rounded-lg border border-border bg-surface-warm px-3 py-2.5 text-sm text-fg placeholder:text-muted focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent resize-none"
|
||
placeholder="123 Main Street, Pretoria, 0001"
|
||
/>
|
||
</div>
|
||
|
||
{/* Postal address */}
|
||
<div>
|
||
<label className="block text-sm font-semibold text-fg mb-1.5" htmlFor="postalAddress">
|
||
Postal address <span className="text-xs text-muted font-normal">(optional)</span>
|
||
</label>
|
||
<textarea
|
||
id="postalAddress"
|
||
value={postalAddress}
|
||
onChange={(e) => setPostalAddress(e.target.value)}
|
||
rows={2}
|
||
className="w-full rounded-lg border border-border bg-surface-warm px-3 py-2.5 text-sm text-fg placeholder:text-muted focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent resize-none"
|
||
placeholder="PO Box 123, Pretoria, 0001"
|
||
/>
|
||
</div>
|
||
|
||
{/* Linked director dropdown */}
|
||
<div>
|
||
<label className="block text-sm font-semibold text-fg mb-1.5" htmlFor="directorSelect">
|
||
Linked director <span className="text-xs text-muted font-normal">(optional)</span>
|
||
</label>
|
||
<select
|
||
id="directorSelect"
|
||
value={selectedDirectorId}
|
||
onChange={(e) => setSelectedDirectorId(e.target.value)}
|
||
className="w-full rounded-lg border border-border bg-surface-warm px-3 py-2.5 text-sm text-fg focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent"
|
||
>
|
||
<option value="">— None —</option>
|
||
{directors.map((d) => (
|
||
<option key={d.id} value={d.id}>
|
||
{d.surname}, {d.first_names}
|
||
{d.id_no ? ` (${d.id_no})` : ""}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* Submit */}
|
||
<div className="pt-2 flex gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="flex-1 px-4 py-2.5 rounded-lg border border-border bg-surface-warm text-sm font-semibold text-fg hover:bg-surface transition-colors"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
disabled={loading}
|
||
className="flex-1 px-4 py-2.5 rounded-lg bg-accent text-accent-on text-sm font-bold hover:bg-accent-hover transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||
>
|
||
{loading ? "Saving..." : "Save as client"}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|