WORKFLOW-APP/components/EditEntityModal.tsx

475 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
// components/EditEntityModal.tsx
// Pre-filled edit form for existing director or company records.
// Opens from the Edit button on a table row or from the detail page.
// PUT/PATCH via /api/pb/records/:id, invalidates list + detail cache.
import { useState, useEffect } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { pbPatch, pbList } from "@/lib/api";
interface Director {
id: string;
surname?: string | null;
first_names?: string | null;
id_no?: string | null;
telephone?: string | null;
email?: string | null;
postal_address?: string | null;
director_type?: string | null;
notes?: string | null;
}
interface Company {
id: string;
enterprise_no?: string | null;
enterprise_name?: string | null;
enterprise_type?: string | null;
registration_date?: string | null;
physical_address?: string | null;
postal_address?: string | null;
director?: string | null;
notes?: string | null;
sars_login_username?: string | null;
sars_login_password?: string | null;
sars_paye_no?: string | null;
sars_vat_no?: string | null;
sars_tax_number?: string | null;
sars_vat_returns_period?: number | null;
sars_paye_return_period?: number | null;
groups?: string[] | null;
}
interface GroupOption {
id: string;
name: string;
description?: string | null;
}
interface Props {
entity: "director" | "company";
/** Original record data for pre-filling the form */
record: Director | Company;
open: boolean;
onClose: () => void;
/** Called after a successful save */
onAfterSave?: () => void;
}
export default function EditEntityModal({ entity, record, open, onClose, onAfterSave }: Props) {
const qc = useQueryClient();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Director fields
const [surname, setSurname] = useState("");
const [firstNames, setFirstNames] = useState("");
const [idNo, setIdNo] = useState("");
const [telephone, setTelephone] = useState("");
const [email, setEmail] = useState("");
const [postalAddress, setPostalAddress] = useState("");
const [directorType, setDirectorType] = useState("");
const [notes, setNotes] = useState("");
// Company fields
const [enterpriseNo, setEnterpriseNo] = useState("");
const [enterpriseName, setEnterpriseName] = useState("");
const [enterpriseType, setEnterpriseType] = useState("");
const [registrationDate, setRegistrationDate] = useState("");
const [physicalAddress, setPhysicalAddress] = useState("");
const [postalAddressCo, setPostalAddressCo] = useState("");
const [notesCo, setNotesCo] = useState("");
// SARS fields
const [sarsLoginUsername, setSarsLoginUsername] = useState("");
const [sarsLoginPassword, setSarsLoginPassword] = useState("");
const [sarsPayeNo, setSarsPayeNo] = useState("");
const [sarsVatNo, setSarsVatNo] = useState("");
const [sarsTaxNo, setSarsTaxNo] = useState("");
const [sarsVatReturnsPeriod, setSarsVatReturnsPeriod] = useState("");
const [sarsPayeReturnPeriod, setSarsPayeReturnPeriod] = useState("");
// Group fields
const [selectedGroups, setSelectedGroups] = useState<string[]>([]);
const [groupOptions, setGroupOptions] = useState<GroupOption[]>([]);
// Populate form when modal opens or record changes
useEffect(() => {
if (!open) return;
setError(null);
if (entity === "director") {
const d = record as Director;
setSurname(d.surname ?? "");
setFirstNames(d.first_names ?? "");
setIdNo(d.id_no ?? "");
setTelephone(d.telephone ?? "");
setEmail(d.email ?? "");
setPostalAddress(d.postal_address ?? "");
setDirectorType(d.director_type ?? "");
setNotes(d.notes ?? "");
} else {
const c = record as Company;
setEnterpriseNo(c.enterprise_no ?? "");
setEnterpriseName(c.enterprise_name ?? "");
setEnterpriseType(c.enterprise_type ?? "");
setRegistrationDate(c.registration_date ?? "");
setPhysicalAddress(c.physical_address ?? "");
setPostalAddressCo(c.postal_address ?? "");
setNotesCo(c.notes ?? "");
setSarsLoginUsername(c.sars_login_username ?? "");
setSarsLoginPassword(c.sars_login_password ?? "");
setSarsPayeNo(c.sars_paye_no ?? "");
setSarsVatNo(c.sars_vat_no ?? "");
setSarsTaxNo(c.sars_tax_number ?? "");
setSarsVatReturnsPeriod(c.sars_vat_returns_period != null ? String(c.sars_vat_returns_period) : "");
setSarsPayeReturnPeriod(c.sars_paye_return_period != null ? String(c.sars_paye_return_period) : "");
setSelectedGroups(c.groups ?? []);
}
}, [open, entity, record]);
// Fetch group options when modal opens (company entity only)
useEffect(() => {
if (!open || entity !== "company") return;
pbList<GroupOption>({ collection: "groups", perPage: 200, sort: "name" })
.then((res) => setGroupOptions(res.items))
.catch(() => {});
}, [open, entity]);
if (!open) return null;
const isDirector = entity === "director";
const title = isDirector ? `Edit director — ${(record as Director).surname}, ${(record as Director).first_names}` : `Edit company — ${(record as Company).enterprise_name}`;
const collection = isDirector ? "ysterbaard_directors" : "ysterbaard_companies";
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
const fields: Record<string, string | number | string[]> = {};
if (isDirector) {
if (!surname.trim() || !firstNames.trim()) {
setError("Surname and first names are required.");
setLoading(false);
return;
}
fields.surname = surname.trim();
fields.first_names = firstNames.trim();
if (idNo.trim()) fields.id_no = idNo.trim();
if (telephone.trim()) fields.telephone = telephone.trim();
if (email.trim()) fields.email = email.trim();
if (postalAddress.trim()) fields.postal_address = postalAddress.trim();
if (directorType.trim()) fields.director_type = directorType.trim();
if (notes.trim()) fields.notes = notes.trim();
} else {
if (!enterpriseNo.trim() || !enterpriseName.trim()) {
setError("Enterprise number and name are required.");
setLoading(false);
return;
}
fields.enterprise_no = enterpriseNo.trim().toUpperCase();
fields.enterprise_name = enterpriseName.trim().toUpperCase();
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 (postalAddressCo.trim()) fields.postal_address = postalAddressCo.trim();
if (notesCo.trim()) fields.notes = notesCo.trim();
if (sarsLoginUsername.trim()) fields.sars_login_username = sarsLoginUsername.trim();
if (sarsLoginPassword.trim()) fields.sars_login_password = sarsLoginPassword.trim();
if (sarsPayeNo.trim()) fields.sars_paye_no = sarsPayeNo.trim();
if (sarsVatNo.trim()) fields.sars_vat_no = sarsVatNo.trim();
if (sarsTaxNo.trim()) fields.sars_tax_number = sarsTaxNo.trim();
// Period fields: store the selected numeric value as string (PocketBase number field accepts string->number conversion)
const vatPeriod = sarsVatReturnsPeriod;
const payePeriod = sarsPayeReturnPeriod;
if (vatPeriod !== "" || (record as Company).sars_vat_returns_period != null) {
fields.sars_vat_returns_period = vatPeriod !== "" ? Number(vatPeriod) : 0;
}
if (payePeriod !== "" || (record as Company).sars_paye_return_period != null) {
fields.sars_paye_return_period = payePeriod !== "" ? Number(payePeriod) : 0;
}
// Groups — always send current selection (empty array is valid to clear)
fields.groups = selectedGroups;
}
await pbPatch({ collection, id: record.id, fields });
// Invalidate both list and detail caches
await qc.invalidateQueries({ queryKey: ["pb-list", collection] });
await qc.invalidateQueries({ queryKey: ["director", record.id] });
await qc.invalidateQueries({ queryKey: ["company", record.id] });
onAfterSave?.();
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong. Please try again.");
} finally {
setLoading(false);
}
};
return (
<div
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">Edit {isDirector ? "director" : "company"}</h2>
<p className="text-sm text-muted mt-0.5">{title}</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 ? (
<>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-fg mb-1.5" htmlFor="edit-surname">Surname <span className="text-red-400">*</span></label>
<input id="edit-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 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent"
required />
</div>
<div>
<label className="block text-sm font-semibold text-fg mb-1.5" htmlFor="edit-firstnames">First names <span className="text-red-400">*</span></label>
<input id="edit-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 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent"
required />
</div>
</div>
<div>
<label className="block text-sm font-semibold text-fg mb-1.5" htmlFor="edit-idno">SA ID number <span className="text-xs text-muted font-normal">(optional)</span></label>
<input id="edit-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 font-mono focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent"
maxLength={13} />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-fg mb-1.5" htmlFor="edit-tel">Telephone <span className="text-xs text-muted font-normal">(optional)</span></label>
<input id="edit-tel" 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 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent" />
</div>
<div>
<label className="block text-sm font-semibold text-fg mb-1.5" htmlFor="edit-email">Email <span className="text-xs text-muted font-normal">(optional)</span></label>
<input id="edit-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 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent" />
</div>
</div>
<div>
<label className="block text-sm font-semibold text-fg mb-1.5" htmlFor="edit-postal">Postal address <span className="text-xs text-muted font-normal">(optional)</span></label>
<textarea id="edit-postal" value={postalAddress} rows={2}
onChange={(e) => setPostalAddress(e.target.value)}
className="w-full rounded-lg border border-border bg-surface-warm px-3 py-2.5 text-sm text-fg resize-none focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent" />
</div>
<div>
<label className="block text-sm font-semibold text-fg mb-1.5" htmlFor="edit-type">Director type <span className="text-xs text-muted font-normal">(optional)</span></label>
<input id="edit-type" type="text" value={directorType}
onChange={(e) => setDirectorType(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"
placeholder="e.g. Individual, Corporate" />
</div>
<div>
<label className="block text-sm font-semibold text-fg mb-1.5" htmlFor="edit-notes">Notes <span className="text-xs text-muted font-normal">(optional)</span></label>
<textarea id="edit-notes" value={notes} rows={2}
onChange={(e) => setNotes(e.target.value)}
className="w-full rounded-lg border border-border bg-surface-warm px-3 py-2.5 text-sm text-fg resize-none focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent" />
</div>
</>
) : (
<>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-fg mb-1.5" htmlFor="edit-eno">Enterprise No. <span className="text-red-400">*</span></label>
<input id="edit-eno" 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 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent"
required />
</div>
<div>
<label className="block text-sm font-semibold text-fg mb-1.5" htmlFor="edit-etype">Enterprise type <span className="text-xs text-muted font-normal">(optional)</span></label>
<input id="edit-etype" 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 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent"
placeholder="Private Company" />
</div>
</div>
<div>
<label className="block text-sm font-semibold text-fg mb-1.5" htmlFor="edit-ename">Enterprise name <span className="text-red-400">*</span></label>
<input id="edit-ename" 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 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent"
required />
</div>
<div>
<label className="block text-sm font-semibold text-fg mb-1.5" htmlFor="edit-rdate">Registration date <span className="text-xs text-muted font-normal">(optional)</span></label>
<input id="edit-rdate" 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>
<div>
<label className="block text-sm font-semibold text-fg mb-1.5" htmlFor="edit-phys">Physical address <span className="text-xs text-muted font-normal">(optional)</span></label>
<textarea id="edit-phys" value={physicalAddress} rows={2}
onChange={(e) => setPhysicalAddress(e.target.value)}
className="w-full rounded-lg border border-border bg-surface-warm px-3 py-2.5 text-sm text-fg resize-none focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent" />
</div>
<div>
<label className="block text-sm font-semibold text-fg mb-1.5" htmlFor="edit-postalco">Postal address <span className="text-xs text-muted font-normal">(optional)</span></label>
<textarea id="edit-postalco" value={postalAddressCo} rows={2}
onChange={(e) => setPostalAddressCo(e.target.value)}
className="w-full rounded-lg border border-border bg-surface-warm px-3 py-2.5 text-sm text-fg resize-none focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent" />
</div>
<div>
<label className="block text-sm font-semibold text-fg mb-1.5" htmlFor="edit-notesco">Notes <span className="text-xs text-muted font-normal">(optional)</span></label>
<textarea id="edit-notesco" value={notesCo} rows={2}
onChange={(e) => setNotesCo(e.target.value)}
className="w-full rounded-lg border border-border bg-surface-warm px-3 py-2.5 text-sm text-fg resize-none focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent" />
</div>
{/* Group membership */}
<div>
<label className="block text-sm font-semibold text-fg mb-1.5" htmlFor="edit-groups">Group Membership <span className="text-xs text-muted font-normal">(optional)</span></label>
<p className="text-xs text-muted mb-2">Hold Ctrl (or Cmd on Mac) to select multiple groups.</p>
<select
id="edit-groups"
multiple
value={selectedGroups}
onChange={(e) => {
const opts = Array.from(e.target.selectedOptions).map((o) => o.value);
setSelectedGroups(opts);
}}
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 min-h-[100px]">
{groupOptions.length === 0 ? (
<option value="" disabled>No groups created yet — save and add groups via PocketBase</option>
) : (
groupOptions.map((g) => (
<option key={g.id} value={g.id}>{g.name}</option>
))
)}
</select>
</div>
{/* SARS credentials */}
<div className="border-t border-border pt-4 mt-2">
<p className="text-sm font-semibold text-fg mb-3">SARS eFiling Details</p>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-fg mb-1.5" htmlFor="edit-sars-user">SARS Login Username <span className="text-xs text-muted font-normal">(optional)</span></label>
<input id="edit-sars-user" type="text" value={sarsLoginUsername}
onChange={(e) => setSarsLoginUsername(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"
placeholder="eFiling username" />
</div>
<div>
<label className="block text-sm font-semibold text-fg mb-1.5" htmlFor="edit-sars-pass">SARS Login Password <span className="text-xs text-muted font-normal">(optional)</span></label>
<input id="edit-sars-pass" type="text" value={sarsLoginPassword}
onChange={(e) => setSarsLoginPassword(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"
placeholder="eFiling password" />
</div>
</div>
<div className="grid grid-cols-2 gap-4 mt-3">
<div>
<label className="block text-sm font-semibold text-fg mb-1.5" htmlFor="edit-sars-paye">SARS PAYE No. <span className="text-xs text-muted font-normal">(optional)</span></label>
<input id="edit-sars-paye" type="text" value={sarsPayeNo}
onChange={(e) => setSarsPayeNo(e.target.value)}
className="w-full rounded-lg border border-border bg-surface-warm px-3 py-2.5 text-sm text-fg font-mono focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent"
placeholder="e.g. 7020829828" />
</div>
<div>
<label className="block text-sm font-semibold text-fg mb-1.5" htmlFor="edit-sars-vat">SARS VAT No. <span className="text-xs text-muted font-normal">(optional)</span></label>
<input id="edit-sars-vat" type="text" value={sarsVatNo}
onChange={(e) => setSarsVatNo(e.target.value)}
className="w-full rounded-lg border border-border bg-surface-warm px-3 py-2.5 text-sm text-fg font-mono focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent"
placeholder="e.g. 4123456789" />
</div>
</div>
<div className="mt-3">
<label className="block text-sm font-semibold text-fg mb-1.5" htmlFor="edit-sars-tax">SARS Tax No. <span className="text-xs text-muted font-normal">(optional)</span></label>
<input id="edit-sars-tax" type="text" value={sarsTaxNo}
onChange={(e) => setSarsTaxNo(e.target.value)}
className="w-full rounded-lg border border-border bg-surface-warm px-3 py-2.5 text-sm text-fg font-mono focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent"
placeholder="e.g. 9004615283" />
</div>
<div className="grid grid-cols-2 gap-4 mt-3">
<div>
<label className="block text-sm font-semibold text-fg mb-1.5" htmlFor="edit-sars-vat-period">SARS VAT Returns Period</label>
<select id="edit-sars-vat-period" value={sarsVatReturnsPeriod}
onChange={(e) => setSarsVatReturnsPeriod(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="">— select —</option>
<option value="0">None</option>
<option value="1">Every Month</option>
<option value="2">Even Month</option>
<option value="3">Uneven Months</option>
<option value="6">Bi Annually</option>
<option value="12">Annually</option>
</select>
</div>
<div>
<label className="block text-sm font-semibold text-fg mb-1.5" htmlFor="edit-sars-paye-period">SARS PAYE Returns Period</label>
<select id="edit-sars-paye-period" value={sarsPayeReturnPeriod}
onChange={(e) => setSarsPayeReturnPeriod(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="">— select —</option>
<option value="0">None</option>
<option value="1">Every month</option>
</select>
</div>
</div>
</div>
</>
)}
<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 changes"}
</button>
</div>
</form>
</div>
</div>
);
}