939 lines
56 KiB
TypeScript
939 lines
56 KiB
TypeScript
|
|
"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";
|
|||
|
|
import { dateInputValue } from "@/lib/format";
|
|||
|
|
|
|||
|
|
interface Director {
|
|||
|
|
id: string;
|
|||
|
|
surname?: string | null;
|
|||
|
|
first_names?: string | null;
|
|||
|
|
id_no?: string | null;
|
|||
|
|
/** SARS income-tax reference number (number; added 2026-09-16). */
|
|||
|
|
tax_reference_no?: number | null;
|
|||
|
|
telephone?: string | null;
|
|||
|
|
email?: string | null;
|
|||
|
|
postal_address?: string | null;
|
|||
|
|
director_type?: string | null;
|
|||
|
|
notes?: string | null;
|
|||
|
|
// ─── Structured address fields (added 2026-08-18) ───
|
|||
|
|
residential_address?: string | null;
|
|||
|
|
residential_address_line_1?: string | null;
|
|||
|
|
residential_address_line_2?: string | null;
|
|||
|
|
residential_town_city?: string | null;
|
|||
|
|
residential_province?: string | null;
|
|||
|
|
residential_postal_code?: string | null;
|
|||
|
|
postal_address_line_1?: string | null;
|
|||
|
|
postal_address_line_2?: string | null;
|
|||
|
|
postal_town_city?: string | null;
|
|||
|
|
postal_province?: string | null;
|
|||
|
|
postal_postal_code?: string | null;
|
|||
|
|
date_of_birth?: 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;
|
|||
|
|
company_email?: 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;
|
|||
|
|
// ─── SARS login behaviour (added 2026-09-11) ───
|
|||
|
|
otp?: boolean | null;
|
|||
|
|
groups?: string[] | null;
|
|||
|
|
// ─── Structured address fields (added 2026-08-18) ───
|
|||
|
|
physical_address_line_1?: string | null;
|
|||
|
|
physical_address_line_2?: string | null;
|
|||
|
|
physical_town_city?: string | null;
|
|||
|
|
physical_province?: string | null;
|
|||
|
|
physical_postal_code?: string | null;
|
|||
|
|
postal_address_line_1?: string | null;
|
|||
|
|
postal_address_line_2?: string | null;
|
|||
|
|
postal_town_city?: string | null;
|
|||
|
|
postal_province?: string | null;
|
|||
|
|
postal_postal_code?: string | null;
|
|||
|
|
// ─── Company-level fields (added 2026-08-18) ───
|
|||
|
|
financial_year_end_month?: number | null;
|
|||
|
|
authorised_shares?: number | null;
|
|||
|
|
company_cell_phone?: string | null;
|
|||
|
|
public_officer_name?: string | null;
|
|||
|
|
company_secretary_name?: string | null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface GroupOption {
|
|||
|
|
id: string;
|
|||
|
|
name: string;
|
|||
|
|
description?: string | null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const SA_PROVINCES = [
|
|||
|
|
"Eastern Cape",
|
|||
|
|
"Free State",
|
|||
|
|
"Gauteng",
|
|||
|
|
"KwaZulu-Natal",
|
|||
|
|
"Limpopo",
|
|||
|
|
"Mpumalanga",
|
|||
|
|
"Northern Cape",
|
|||
|
|
"North West",
|
|||
|
|
"Western Cape",
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
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("");
|
|||
|
|
// SARS tax reference — number, optional. Stored as a string in the input
|
|||
|
|
// so we can render empty cleanly; coerced to a number on save (added 2026-09-16).
|
|||
|
|
const [taxReferenceNo, setTaxReferenceNo] = useState("");
|
|||
|
|
const [telephone, setTelephone] = useState("");
|
|||
|
|
const [email, setEmail] = useState("");
|
|||
|
|
const [postalAddress, setPostalAddress] = useState("");
|
|||
|
|
const [residentialAddress, setResidentialAddress] = useState(""); // flat legacy — added 2026-08-20
|
|||
|
|
const [directorType, setDirectorType] = useState("");
|
|||
|
|
const [notes, setNotes] = useState("");
|
|||
|
|
// Director structured address fields (added 2026-08-18)
|
|||
|
|
const [dateOfBirth, setDateOfBirth] = useState("");
|
|||
|
|
const [resAddr1, setResAddr1] = useState("");
|
|||
|
|
const [resAddr2, setResAddr2] = useState("");
|
|||
|
|
const [resTown, setResTown] = useState("");
|
|||
|
|
const [resProvince, setResProvince] = useState("");
|
|||
|
|
const [resPostalCode, setResPostalCode] = useState("");
|
|||
|
|
const [postAddr1, setPostAddr1] = useState("");
|
|||
|
|
const [postAddr2, setPostAddr2] = useState("");
|
|||
|
|
const [postTown, setPostTown] = useState("");
|
|||
|
|
const [postProvince, setPostProvince] = useState("");
|
|||
|
|
const [postPostalCode, setPostPostalCode] = 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 [companyEmail, setCompanyEmail] = useState("");
|
|||
|
|
const [notesCo, setNotesCo] = useState("");
|
|||
|
|
// Company structured address fields (added 2026-08-18)
|
|||
|
|
const [physAddr1, setPhysAddr1] = useState("");
|
|||
|
|
const [physAddr2, setPhysAddr2] = useState("");
|
|||
|
|
const [physTown, setPhysTown] = useState("");
|
|||
|
|
const [physProvince, setPhysProvince] = useState("");
|
|||
|
|
const [physPostalCode, setPhysPostalCode] = useState("");
|
|||
|
|
const [postCoAddr1, setPostCoAddr1] = useState("");
|
|||
|
|
const [postCoAddr2, setPostCoAddr2] = useState("");
|
|||
|
|
const [postCoTown, setPostCoTown] = useState("");
|
|||
|
|
const [postCoProvince, setPostCoProvince] = useState("");
|
|||
|
|
const [postCoPostalCode, setPostCoPostalCode] = useState("");
|
|||
|
|
// Company-level fields (added 2026-08-18)
|
|||
|
|
const [financialYearEndMonth, setFinancialYearEndMonth] = useState("");
|
|||
|
|
const [authorisedShares, setAuthorisedShares] = useState("");
|
|||
|
|
const [companyCellPhone, setCompanyCellPhone] = useState("");
|
|||
|
|
const [publicOfficerName, setPublicOfficerName] = useState("");
|
|||
|
|
const [companySecretaryName, setCompanySecretaryName] = 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("");
|
|||
|
|
// OTP-required flag — when ON, the SARS login script expects an
|
|||
|
|
// SMS one-time PIN after the password and waits for it (added 2026-09-11).
|
|||
|
|
const [otpRequired, setOtpRequired] = useState(false);
|
|||
|
|
|
|||
|
|
// 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 ?? "");
|
|||
|
|
setTaxReferenceNo(d.tax_reference_no != null ? String(d.tax_reference_no) : "");
|
|||
|
|
setTelephone(d.telephone ?? "");
|
|||
|
|
setEmail(d.email ?? "");
|
|||
|
|
setPostalAddress(d.postal_address ?? "");
|
|||
|
|
setResidentialAddress(d.residential_address ?? "");
|
|||
|
|
setDirectorType(d.director_type ?? "");
|
|||
|
|
setNotes(d.notes ?? "");
|
|||
|
|
setDateOfBirth(dateInputValue(d.date_of_birth));
|
|||
|
|
setResAddr1(d.residential_address_line_1 ?? "");
|
|||
|
|
setResAddr2(d.residential_address_line_2 ?? "");
|
|||
|
|
setResTown(d.residential_town_city ?? "");
|
|||
|
|
setResProvince(d.residential_province ?? "");
|
|||
|
|
setResPostalCode(d.residential_postal_code ?? "");
|
|||
|
|
setPostAddr1(d.postal_address_line_1 ?? "");
|
|||
|
|
setPostAddr2(d.postal_address_line_2 ?? "");
|
|||
|
|
setPostTown(d.postal_town_city ?? "");
|
|||
|
|
setPostProvince(d.postal_province ?? "");
|
|||
|
|
setPostPostalCode(d.postal_postal_code ?? "");
|
|||
|
|
} else {
|
|||
|
|
const c = record as Company;
|
|||
|
|
setEnterpriseNo(c.enterprise_no ?? "");
|
|||
|
|
setEnterpriseName(c.enterprise_name ?? "");
|
|||
|
|
setEnterpriseType(c.enterprise_type ?? "");
|
|||
|
|
setRegistrationDate(dateInputValue(c.registration_date));
|
|||
|
|
setPhysicalAddress(c.physical_address ?? "");
|
|||
|
|
setPostalAddressCo(c.postal_address ?? "");
|
|||
|
|
setCompanyEmail(c.company_email ?? "");
|
|||
|
|
setNotesCo(c.notes ?? "");
|
|||
|
|
setPhysAddr1(c.physical_address_line_1 ?? "");
|
|||
|
|
setPhysAddr2(c.physical_address_line_2 ?? "");
|
|||
|
|
setPhysTown(c.physical_town_city ?? "");
|
|||
|
|
setPhysProvince(c.physical_province ?? "");
|
|||
|
|
setPhysPostalCode(c.physical_postal_code ?? "");
|
|||
|
|
setPostCoAddr1(c.postal_address_line_1 ?? "");
|
|||
|
|
setPostCoAddr2(c.postal_address_line_2 ?? "");
|
|||
|
|
setPostCoTown(c.postal_town_city ?? "");
|
|||
|
|
setPostCoProvince(c.postal_province ?? "");
|
|||
|
|
setPostCoPostalCode(c.postal_postal_code ?? "");
|
|||
|
|
setFinancialYearEndMonth(c.financial_year_end_month != null ? String(c.financial_year_end_month) : "");
|
|||
|
|
setAuthorisedShares(c.authorised_shares != null ? String(c.authorised_shares) : "");
|
|||
|
|
setCompanyCellPhone(c.company_cell_phone ?? "");
|
|||
|
|
setPublicOfficerName(c.public_officer_name ?? "");
|
|||
|
|
setCompanySecretaryName(c.company_secretary_name ?? "");
|
|||
|
|
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) : "");
|
|||
|
|
setOtpRequired(Boolean(c.otp));
|
|||
|
|
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 | boolean | string[]> = {};
|
|||
|
|
|
|||
|
|
// Always send every field — empty string clears the value in PB.
|
|||
|
|
// The previous "if (value.trim())" guard LOST the user's intent to
|
|||
|
|
// clear a field: empty fields were silently skipped, so a delete in
|
|||
|
|
// the UI never landed in PB. We now send the literal value the user
|
|||
|
|
// left in the field, including "" to clear, and only validate
|
|||
|
|
// required fields up-front.
|
|||
|
|
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();
|
|||
|
|
fields.id_no = idNo.trim();
|
|||
|
|
// tax_reference_no: PB `number` field. Empty string means clear it
|
|||
|
|
// (send 0 — PB coerces to null on the way out for `required:false`
|
|||
|
|
// number fields, matching how we already handle financial_year_end
|
|||
|
|
// month and authorised_shares below). Added 2026-09-16.
|
|||
|
|
fields.tax_reference_no = taxReferenceNo.trim() !== "" ? Number(taxReferenceNo) : 0;
|
|||
|
|
fields.telephone = telephone.trim();
|
|||
|
|
fields.email = email.trim();
|
|||
|
|
fields.postal_address = postalAddress.trim();
|
|||
|
|
fields.residential_address = residentialAddress.trim();
|
|||
|
|
fields.director_type = directorType.trim();
|
|||
|
|
fields.notes = notes.trim();
|
|||
|
|
// Structured address fields
|
|||
|
|
fields.date_of_birth = dateOfBirth.trim();
|
|||
|
|
fields.residential_address_line_1 = resAddr1.trim();
|
|||
|
|
fields.residential_address_line_2 = resAddr2.trim();
|
|||
|
|
fields.residential_town_city = resTown.trim();
|
|||
|
|
fields.residential_province = resProvince.trim();
|
|||
|
|
fields.residential_postal_code = resPostalCode.trim();
|
|||
|
|
fields.postal_address_line_1 = postAddr1.trim();
|
|||
|
|
fields.postal_address_line_2 = postAddr2.trim();
|
|||
|
|
fields.postal_town_city = postTown.trim();
|
|||
|
|
fields.postal_province = postProvince.trim();
|
|||
|
|
fields.postal_postal_code = postPostalCode.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();
|
|||
|
|
fields.enterprise_type = enterpriseType.trim();
|
|||
|
|
fields.registration_date = registrationDate.trim();
|
|||
|
|
fields.physical_address = physicalAddress.trim();
|
|||
|
|
fields.postal_address = postalAddressCo.trim();
|
|||
|
|
fields.company_email = companyEmail.trim();
|
|||
|
|
fields.notes = notesCo.trim();
|
|||
|
|
// Structured address fields
|
|||
|
|
fields.physical_address_line_1 = physAddr1.trim();
|
|||
|
|
fields.physical_address_line_2 = physAddr2.trim();
|
|||
|
|
fields.physical_town_city = physTown.trim();
|
|||
|
|
fields.physical_province = physProvince.trim();
|
|||
|
|
fields.physical_postal_code = physPostalCode.trim();
|
|||
|
|
fields.postal_address_line_1 = postCoAddr1.trim();
|
|||
|
|
fields.postal_address_line_2 = postCoAddr2.trim();
|
|||
|
|
fields.postal_town_city = postCoTown.trim();
|
|||
|
|
fields.postal_province = postCoProvince.trim();
|
|||
|
|
fields.postal_postal_code = postCoPostalCode.trim();
|
|||
|
|
// Company-level fields
|
|||
|
|
fields.financial_year_end_month = financialYearEndMonth !== "" ? Number(financialYearEndMonth) : 0;
|
|||
|
|
fields.authorised_shares = authorisedShares !== "" ? Number(authorisedShares) : 0;
|
|||
|
|
fields.company_cell_phone = companyCellPhone.trim();
|
|||
|
|
fields.public_officer_name = publicOfficerName.trim();
|
|||
|
|
fields.company_secretary_name = companySecretaryName.trim();
|
|||
|
|
fields.sars_login_username = sarsLoginUsername.trim();
|
|||
|
|
fields.sars_login_password = sarsLoginPassword.trim();
|
|||
|
|
fields.sars_paye_no = sarsPayeNo.trim();
|
|||
|
|
fields.sars_vat_no = sarsVatNo.trim();
|
|||
|
|
fields.sars_tax_number = sarsTaxNo.trim();
|
|||
|
|
fields.sars_vat_returns_period = sarsVatReturnsPeriod !== "" ? Number(sarsVatReturnsPeriod) : 0;
|
|||
|
|
fields.sars_paye_return_period = sarsPayeReturnPeriod !== "" ? Number(sarsPayeReturnPeriod) : 0;
|
|||
|
|
fields.otp = otpRequired;
|
|||
|
|
// Groups — always send current selection (empty array is valid to clear)
|
|||
|
|
fields.groups = selectedGroups;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Pre-check for ID collisions on directors before PATCH (added 2026-08-20).
|
|||
|
|
// PB's UNIQUE index on id_no_masked rejects the PATCH with a 502 if
|
|||
|
|
// the typed id_no masks to a value already on another director.
|
|||
|
|
// Detect this client-side and offer to open the existing record.
|
|||
|
|
if (isDirector && fields.id_no && (fields.id_no as string).length >= 12) {
|
|||
|
|
const typedId = fields.id_no as string;
|
|||
|
|
const masked = typedId.slice(0, 6) + "XXXX" + typedId.slice(10, 12) + "X";
|
|||
|
|
try {
|
|||
|
|
const existing = await pbList<{ id: string; surname: string; first_names: string; id_no_masked: string }>({
|
|||
|
|
collection: "ysterbaard_directors",
|
|||
|
|
perPage: 5,
|
|||
|
|
filter: `id_no_masked="${masked}" || id_no="${typedId}"`,
|
|||
|
|
});
|
|||
|
|
const conflict = existing.items.find((d) => d.id !== record.id);
|
|||
|
|
if (conflict) {
|
|||
|
|
setLoading(false);
|
|||
|
|
setError(
|
|||
|
|
`A director with this ID already exists: ${conflict.surname}, ${conflict.first_names}. ` +
|
|||
|
|
`Open their record instead?`
|
|||
|
|
);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
// If pre-check fails, let the PATCH proceed and surface the 502.
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await pbPatch({ collection, id: record.id, fields });
|
|||
|
|
|
|||
|
|
// Invalidate both list and detail caches
|
|||
|
|
// Invalidate every key the detail page or list pages might be holding.
|
|||
|
|
// The list pages use "companies" / "directors" keys (not "pb-list"),
|
|||
|
|
// and the detail page uses "director" / "company" keys.
|
|||
|
|
await qc.invalidateQueries({ queryKey: ["pb-list", collection] });
|
|||
|
|
await qc.invalidateQueries({ queryKey: ["companies"] });
|
|||
|
|
await qc.invalidateQueries({ queryKey: ["directors"] });
|
|||
|
|
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);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const monthNames = ["January", "February", "March", "April", "May", "June",
|
|||
|
|
"July", "August", "September", "October", "November", "December"];
|
|||
|
|
|
|||
|
|
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>
|
|||
|
|
<label className="block text-sm font-semibold text-fg mb-1.5" htmlFor="edit-taxrefno">Tax reference number <span className="text-xs text-muted font-normal">(optional)</span></label>
|
|||
|
|
<input id="edit-taxrefno" type="number" inputMode="numeric" value={taxReferenceNo}
|
|||
|
|
onChange={(e) => setTaxReferenceNo(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>
|
|||
|
|
<label className="block text-sm font-semibold text-fg mb-1.5" htmlFor="edit-dob">Date of birth <span className="text-xs text-muted font-normal">(optional)</span></label>
|
|||
|
|
<input id="edit-dob" type="date" value={dateOfBirth}
|
|||
|
|
onChange={(e) => setDateOfBirth(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 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, digits only)</span></label>
|
|||
|
|
<input id="edit-tel" type="tel" value={telephone}
|
|||
|
|
onChange={(e) => setTelephone(e.target.value.replace(/\D/g, "").slice(0, 11))}
|
|||
|
|
inputMode="numeric"
|
|||
|
|
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">CIPC Postal address <span className="text-xs text-muted font-normal">(optional, free-text)</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-residential">Residential address <span className="text-xs text-muted font-normal">(optional, free-text)</span></label>
|
|||
|
|
<textarea id="edit-residential" value={residentialAddress} rows={2}
|
|||
|
|
onChange={(e) => setResidentialAddress(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>
|
|||
|
|
|
|||
|
|
{/* Structured residential address (added 2026-08-18) */}
|
|||
|
|
<div className="border-t border-border pt-4 mt-2">
|
|||
|
|
<p className="text-sm font-semibold text-fg mb-3">Residential address <span className="text-xs text-muted font-normal">(structured)</span></p>
|
|||
|
|
<div className="space-y-3">
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-sm font-medium text-fg-2 mb-1.5" htmlFor="edit-res1">Address line 1 <span className="text-xs text-muted font-normal">(optional)</span></label>
|
|||
|
|
<input id="edit-res1" type="text" value={resAddr1}
|
|||
|
|
onChange={(e) => setResAddr1(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-medium text-fg-2 mb-1.5" htmlFor="edit-res2">Address line 2 <span className="text-xs text-muted font-normal">(optional)</span></label>
|
|||
|
|
<input id="edit-res2" type="text" value={resAddr2}
|
|||
|
|
onChange={(e) => setResAddr2(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 className="grid grid-cols-2 gap-3">
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-sm font-medium text-fg-2 mb-1.5" htmlFor="edit-restown">Town / city <span className="text-xs text-muted font-normal">(optional)</span></label>
|
|||
|
|
<input id="edit-restown" type="text" value={resTown}
|
|||
|
|
onChange={(e) => setResTown(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-medium text-fg-2 mb-1.5" htmlFor="edit-resprov">Province <span className="text-xs text-muted font-normal">(optional)</span></label>
|
|||
|
|
<select id="edit-resprov" value={resProvince}
|
|||
|
|
onChange={(e) => setResProvince(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>
|
|||
|
|
{SA_PROVINCES.map((p) => (
|
|||
|
|
<option key={p} value={p}>{p}</option>
|
|||
|
|
))}
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-sm font-medium text-fg-2 mb-1.5" htmlFor="edit-reszip">Postal code <span className="text-xs text-muted font-normal">(optional)</span></label>
|
|||
|
|
<input id="edit-reszip" type="text" value={resPostalCode}
|
|||
|
|
onChange={(e) => setResPostalCode(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" />
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Structured postal address (added 2026-08-18) */}
|
|||
|
|
<div className="border-t border-border pt-4 mt-2">
|
|||
|
|
<p className="text-sm font-semibold text-fg mb-3">Postal address <span className="text-xs text-muted font-normal">(structured)</span></p>
|
|||
|
|
<div className="space-y-3">
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-sm font-medium text-fg-2 mb-1.5" htmlFor="edit-post1">Address line 1 <span className="text-xs text-muted font-normal">(optional)</span></label>
|
|||
|
|
<input id="edit-post1" type="text" value={postAddr1}
|
|||
|
|
onChange={(e) => setPostAddr1(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-medium text-fg-2 mb-1.5" htmlFor="edit-post2">Address line 2 <span className="text-xs text-muted font-normal">(optional)</span></label>
|
|||
|
|
<input id="edit-post2" type="text" value={postAddr2}
|
|||
|
|
onChange={(e) => setPostAddr2(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 className="grid grid-cols-2 gap-3">
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-sm font-medium text-fg-2 mb-1.5" htmlFor="edit-posttown">Town / city <span className="text-xs text-muted font-normal">(optional)</span></label>
|
|||
|
|
<input id="edit-posttown" type="text" value={postTown}
|
|||
|
|
onChange={(e) => setPostTown(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-medium text-fg-2 mb-1.5" htmlFor="edit-postprov">Province <span className="text-xs text-muted font-normal">(optional)</span></label>
|
|||
|
|
<select id="edit-postprov" value={postProvince}
|
|||
|
|
onChange={(e) => setPostProvince(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>
|
|||
|
|
{SA_PROVINCES.map((p) => (
|
|||
|
|
<option key={p} value={p}>{p}</option>
|
|||
|
|
))}
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-sm font-medium text-fg-2 mb-1.5" htmlFor="edit-postzip">Postal code <span className="text-xs text-muted font-normal">(optional)</span></label>
|
|||
|
|
<input id="edit-postzip" type="text" value={postPostalCode}
|
|||
|
|
onChange={(e) => setPostPostalCode(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" />
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</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">CIPC Physical address <span className="text-xs text-muted font-normal">(optional, free-text)</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">CIPC Postal address <span className="text-xs text-muted font-normal">(optional, free-text)</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>
|
|||
|
|
|
|||
|
|
{/* Structured physical address (added 2026-08-18) */}
|
|||
|
|
<div className="border-t border-border pt-4 mt-2">
|
|||
|
|
<p className="text-sm font-semibold text-fg mb-3">Physical address <span className="text-xs text-muted font-normal">(structured)</span></p>
|
|||
|
|
<div className="space-y-3">
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-sm font-medium text-fg-2 mb-1.5" htmlFor="edit-phys1">Address line 1 <span className="text-xs text-muted font-normal">(optional)</span></label>
|
|||
|
|
<input id="edit-phys1" type="text" value={physAddr1}
|
|||
|
|
onChange={(e) => setPhysAddr1(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-medium text-fg-2 mb-1.5" htmlFor="edit-phys2">Address line 2 <span className="text-xs text-muted font-normal">(optional)</span></label>
|
|||
|
|
<input id="edit-phys2" type="text" value={physAddr2}
|
|||
|
|
onChange={(e) => setPhysAddr2(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 className="grid grid-cols-2 gap-3">
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-sm font-medium text-fg-2 mb-1.5" htmlFor="edit-phystown">Town / city <span className="text-xs text-muted font-normal">(optional)</span></label>
|
|||
|
|
<input id="edit-phystown" type="text" value={physTown}
|
|||
|
|
onChange={(e) => setPhysTown(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-medium text-fg-2 mb-1.5" htmlFor="edit-physprov">Province <span className="text-xs text-muted font-normal">(optional)</span></label>
|
|||
|
|
<select id="edit-physprov" value={physProvince}
|
|||
|
|
onChange={(e) => setPhysProvince(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>
|
|||
|
|
{SA_PROVINCES.map((p) => (
|
|||
|
|
<option key={p} value={p}>{p}</option>
|
|||
|
|
))}
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-sm font-medium text-fg-2 mb-1.5" htmlFor="edit-physzip">Postal code <span className="text-xs text-muted font-normal">(optional)</span></label>
|
|||
|
|
<input id="edit-physzip" type="text" value={physPostalCode}
|
|||
|
|
onChange={(e) => setPhysPostalCode(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" />
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Structured postal address (added 2026-08-18) */}
|
|||
|
|
<div className="border-t border-border pt-4 mt-2">
|
|||
|
|
<p className="text-sm font-semibold text-fg mb-3">Postal address <span className="text-xs text-muted font-normal">(structured)</span></p>
|
|||
|
|
<div className="space-y-3">
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-sm font-medium text-fg-2 mb-1.5" htmlFor="edit-postco1">Address line 1 <span className="text-xs text-muted font-normal">(optional)</span></label>
|
|||
|
|
<input id="edit-postco1" type="text" value={postCoAddr1}
|
|||
|
|
onChange={(e) => setPostCoAddr1(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-medium text-fg-2 mb-1.5" htmlFor="edit-postco2">Address line 2 <span className="text-xs text-muted font-normal">(optional)</span></label>
|
|||
|
|
<input id="edit-postco2" type="text" value={postCoAddr2}
|
|||
|
|
onChange={(e) => setPostCoAddr2(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 className="grid grid-cols-2 gap-3">
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-sm font-medium text-fg-2 mb-1.5" htmlFor="edit-postcotown">Town / city <span className="text-xs text-muted font-normal">(optional)</span></label>
|
|||
|
|
<input id="edit-postcotown" type="text" value={postCoTown}
|
|||
|
|
onChange={(e) => setPostCoTown(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-medium text-fg-2 mb-1.5" htmlFor="edit-postcoprov">Province <span className="text-xs text-muted font-normal">(optional)</span></label>
|
|||
|
|
<select id="edit-postcoprov" value={postCoProvince}
|
|||
|
|
onChange={(e) => setPostCoProvince(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>
|
|||
|
|
{SA_PROVINCES.map((p) => (
|
|||
|
|
<option key={p} value={p}>{p}</option>
|
|||
|
|
))}
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-sm font-medium text-fg-2 mb-1.5" htmlFor="edit-postcozip">Postal code <span className="text-xs text-muted font-normal">(optional)</span></label>
|
|||
|
|
<input id="edit-postcozip" type="text" value={postCoPostalCode}
|
|||
|
|
onChange={(e) => setPostCoPostalCode(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" />
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-sm font-semibold text-fg mb-1.5" htmlFor="edit-emailco">Company email <span className="text-xs text-muted font-normal">(optional)</span></label>
|
|||
|
|
<input id="edit-emailco" type="email" value={companyEmail}
|
|||
|
|
onChange={(e) => setCompanyEmail(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="company@example.com" />
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-sm font-semibold text-fg mb-1.5" htmlFor="edit-cellco">Company cell phone <span className="text-xs text-muted font-normal">(optional)</span></label>
|
|||
|
|
<input id="edit-cellco" type="tel" value={companyCellPhone}
|
|||
|
|
onChange={(e) => setCompanyCellPhone(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>
|
|||
|
|
|
|||
|
|
{/* Company-level fields (added 2026-08-18) */}
|
|||
|
|
<div className="border-t border-border pt-4 mt-2">
|
|||
|
|
<p className="text-sm font-semibold text-fg mb-3">Company details</p>
|
|||
|
|
<div className="space-y-3">
|
|||
|
|
<div className="grid grid-cols-2 gap-3">
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-sm font-medium text-fg-2 mb-1.5" htmlFor="edit-fye">Financial year end <span className="text-xs text-muted font-normal">(optional)</span></label>
|
|||
|
|
<select id="edit-fye" value={financialYearEndMonth}
|
|||
|
|
onChange={(e) => setFinancialYearEndMonth(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>
|
|||
|
|
{monthNames.map((m, i) => (
|
|||
|
|
<option key={i + 1} value={String(i + 1)}>{m}</option>
|
|||
|
|
))}
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-sm font-medium text-fg-2 mb-1.5" htmlFor="edit-shares">Authorised shares <span className="text-xs text-muted font-normal">(optional)</span></label>
|
|||
|
|
<select id="edit-shares" value={authorisedShares}
|
|||
|
|
onChange={(e) => setAuthorisedShares(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="100">100</option>
|
|||
|
|
<option value="1000">1,000</option>
|
|||
|
|
<option value="10000">10,000</option>
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-sm font-medium text-fg-2 mb-1.5" htmlFor="edit-officer">Public officer <span className="text-xs text-muted font-normal">(optional)</span></label>
|
|||
|
|
<input id="edit-officer" type="text" value={publicOfficerName}
|
|||
|
|
onChange={(e) => setPublicOfficerName(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-medium text-fg-2 mb-1.5" htmlFor="edit-secretary">Company secretary <span className="text-xs text-muted font-normal">(optional)</span></label>
|
|||
|
|
<input id="edit-secretary" type="text" value={companySecretaryName}
|
|||
|
|
onChange={(e) => setCompanySecretaryName(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>
|
|||
|
|
|
|||
|
|
<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>
|
|||
|
|
{/* OTP toggle (added 2026-09-11) — when ON, the SARS login
|
|||
|
|
script expects an SMS one-time PIN after the password
|
|||
|
|
and pauses for the agent to inject it via CDP. */}
|
|||
|
|
<div className="mt-4 flex items-start gap-2">
|
|||
|
|
<input
|
|||
|
|
id="edit-sars-otp"
|
|||
|
|
type="checkbox"
|
|||
|
|
checked={otpRequired}
|
|||
|
|
onChange={(e) => setOtpRequired(e.target.checked)}
|
|||
|
|
className="mt-0.5 h-4 w-4 rounded border-border text-accent focus:ring-accent cursor-pointer"
|
|||
|
|
/>
|
|||
|
|
<label htmlFor="edit-sars-otp" className="cursor-pointer">
|
|||
|
|
<span className="text-sm font-semibold text-fg">OTP</span>
|
|||
|
|
<span className="block text-xs text-muted font-normal mt-0.5">
|
|||
|
|
SARS sends an SMS one-time PIN on every login as this account.
|
|||
|
|
Tick if you've seen this — the login script will pause and wait
|
|||
|
|
for the PIN instead of waiting the full dashboard timeout.
|
|||
|
|
</span>
|
|||
|
|
</label>
|
|||
|
|
</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>
|
|||
|
|
);
|
|||
|
|
}
|