feat: add BizPortal tab + name-already-approved flow + registrations improvements
This commit is contained in:
parent
c5ee550795
commit
5cedaf714c
8 changed files with 581 additions and 23 deletions
325
app/api/register/[id]/name-already-approved/route.ts
Normal file
325
app/api/register/[id]/name-already-approved/route.ts
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
// app/api/register/[id]/name-already-approved/route.ts
|
||||
// POST — "Name Already Approved" button in Step 2
|
||||
//
|
||||
// Searches DeltaDet for existing CIPC Name Reservation + COR9.1 emails matching
|
||||
// this registration (by proposed_name), then:
|
||||
// 1. Patch status = bizportal_in_progress + cipc_reference_no
|
||||
// 2. Parse COR9.1 PDF to extract decision (approved / rejected)
|
||||
// 3. Patch registration with cipc_decision + status
|
||||
// 4. Generate Director Acceptance Letter + Resolution PDFs
|
||||
// 5. Save all 3 documents to PB
|
||||
// 6. Email client with all 3 documents attached
|
||||
//
|
||||
// No new emails injected. Uses existing emails already in DeltaDet.
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { execSync } from "child_process";
|
||||
import { writeFileSync, mkdirSync, rmSync } from "fs";
|
||||
|
||||
const PB_URL = process.env.PB_URL;
|
||||
const PB_TOKEN = process.env.PB_TOKEN;
|
||||
const COLLECTION_ID = "pbc_3075950410";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const maxDuration = 180;
|
||||
|
||||
const SCRIPTS = "/Volumes/KIOXIA/.hermes/profiles/ysterbaard/scripts";
|
||||
const PYTHON = "/Volumes/KIOXIA/.hermes/hermes-agent/venv/bin/python";
|
||||
const PB_AUTH = PB_TOKEN;
|
||||
|
||||
async function loadReg(id) {
|
||||
const r = await fetch(`${PB_URL}/api/collections/${COLLECTION_ID}/records/${id}`, {
|
||||
headers: { Authorization: PB_AUTH },
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!r.ok) return null;
|
||||
return await r.json();
|
||||
}
|
||||
|
||||
async function patchReg(id, body) {
|
||||
const r = await fetch(`${PB_URL}/api/collections/${COLLECTION_ID}/records/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
Authorization: PB_AUTH,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
return await r.json();
|
||||
}
|
||||
|
||||
function runCmd(cmd) {
|
||||
try {
|
||||
return execSync(cmd, { timeout: 120 * 1000, encoding: "utf-8", maxBuffer: 10 * 1024 * 1024 });
|
||||
} catch (err) {
|
||||
return err.stdout?.toString() || err.message || "";
|
||||
}
|
||||
}
|
||||
|
||||
// Escape a string for embedding in a shell double-quoted string
|
||||
function shellEscape(s) {
|
||||
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/`/g, '\\`').replace(/\$/g, '\\$');
|
||||
}
|
||||
|
||||
// Find CIPC ref by proposed name — searches recent DeltaDet emails
|
||||
function findCipcRefByName(proposedName) {
|
||||
// Build a temp Python script file so we avoid all JS template literal escaping issues
|
||||
const pythonCode = [
|
||||
"import subprocess, re",
|
||||
`proposed_name = "${shellEscape(proposedName)}"`,
|
||||
"name_norm = proposed_name.strip().upper()",
|
||||
"",
|
||||
"result = subprocess.run(",
|
||||
' ["himalaya", "-a", "deltadet", "--backend", "imap", "envelope", "list", "-p", "1", "-s", "60"],',
|
||||
" capture_output=True, text=True, timeout=30",
|
||||
")",
|
||||
"",
|
||||
'LOGGED_RE = re.compile(r"Name reservation lodged", re.IGNORECASE)',
|
||||
'REF_RE = re.compile(r"Reference No\\\\s*:\\\\s*(\\\\d{8,})", re.IGNORECASE)',
|
||||
'NAME_RE = re.compile(r"First proposed name\\\\s*:\\\\s*([^\\\\n\\\\r.]+?)\\\\s*(?:\\\\.|$)", re.IGNORECASE)',
|
||||
"",
|
||||
'for line in result.stdout.split("\\n"):',
|
||||
' if "│" not in line:',
|
||||
" continue",
|
||||
' parts = [c.strip() for c in line.replace("┆", "│").split("│") if c.strip() and not set(c.strip()) <= set("─═┌┐└┘├┤┬┴┼ ")]',
|
||||
" if len(parts) < 2:",
|
||||
" continue",
|
||||
" env_id = parts[0]",
|
||||
" if not env_id.isdigit():",
|
||||
" continue",
|
||||
"",
|
||||
" body_out = subprocess.run(",
|
||||
' ["himalaya", "-a", "deltadet", "--backend", "imap", "message", "read", env_id],',
|
||||
" capture_output=True, text=True, timeout=30",
|
||||
" )",
|
||||
" body = body_out.stdout",
|
||||
" if not LOGGED_RE.search(body):",
|
||||
" continue",
|
||||
"",
|
||||
" ref_match = REF_RE.search(body)",
|
||||
" name_match = NAME_RE.search(body)",
|
||||
" if not ref_match or not name_match:",
|
||||
" continue",
|
||||
"",
|
||||
' ref = ref_match.group(1).strip()',
|
||||
' name = name_match.group(1).strip().upper()',
|
||||
" if name == name_norm:",
|
||||
' print(f"REF:{ref} ENV:{env_id}")',
|
||||
].join("\n");
|
||||
|
||||
const scriptPath = `/tmp/find_cipc_ref_${Date.now()}.py`;
|
||||
writeFileSync(scriptPath, pythonCode);
|
||||
const out = runCmd(`${PYTHON} "${scriptPath}" 2>&1`);
|
||||
try { rmSync(scriptPath, { force: true }); } catch (_) {}
|
||||
|
||||
const match = out.match(/REF:(\d+)\s+ENV:(\d+)/);
|
||||
if (match) return { ref: match[1], envId: match[2] };
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find COR9.1 received email by CIPC reference
|
||||
function findCor91ByRef(ref) {
|
||||
const pythonCode = [
|
||||
"import subprocess, re",
|
||||
`search_ref = "${shellEscape(ref)}"`,
|
||||
"",
|
||||
"result = subprocess.run(",
|
||||
' ["himalaya", "-a", "deltadet", "--backend", "imap", "envelope", "list", "-p", "1", "-s", "60"],',
|
||||
" capture_output=True, text=True, timeout=30",
|
||||
")",
|
||||
"",
|
||||
'COR9_RE = re.compile(r"COR9\\\\.1 received", re.IGNORECASE)',
|
||||
'REF_RE = re.compile(r"Tracking reference\\\\s*:?\\\\s*(\\\\d+)", re.IGNORECASE)',
|
||||
"",
|
||||
'for line in result.stdout.split("\\n"):',
|
||||
' if "│" not in line:',
|
||||
" continue",
|
||||
' parts = [c.strip() for c in line.replace("┆", "│").split("│") if c.strip() and not set(c.strip()) <= set("─═┌┐└┘├┤┬┴┼ ")]',
|
||||
" if len(parts) < 2:",
|
||||
" continue",
|
||||
" env_id = parts[0]",
|
||||
" if not env_id.isdigit():",
|
||||
" continue",
|
||||
"",
|
||||
" body_out = subprocess.run(",
|
||||
' ["himalaya", "-a", "deltadet", "--backend", "imap", "message", "read", env_id],',
|
||||
" capture_output=True, text=True, timeout=30",
|
||||
" )",
|
||||
" body = body_out.stdout",
|
||||
" if not COR9_RE.search(body):",
|
||||
" continue",
|
||||
"",
|
||||
" ref_match = REF_RE.search(body)",
|
||||
" if not ref_match:",
|
||||
" continue",
|
||||
"",
|
||||
' found_ref = ref_match.group(1).strip()',
|
||||
" if found_ref == search_ref:",
|
||||
' print(f"ENV:{env_id}")',
|
||||
].join("\n");
|
||||
|
||||
const scriptPath = `/tmp/find_cor91_${Date.now()}.py`;
|
||||
writeFileSync(scriptPath, pythonCode);
|
||||
const out = runCmd(`${PYTHON} "${scriptPath}" 2>&1`);
|
||||
try { rmSync(scriptPath, { force: true }); } catch (_) {}
|
||||
|
||||
const match = out.match(/ENV:(\d+)/);
|
||||
if (match) return { envId: match[1] };
|
||||
return null;
|
||||
}
|
||||
|
||||
// Download COR9.1 PDF attachment from email
|
||||
function downloadCor91Pdf(envId, outDir) {
|
||||
const out = runCmd(`himalaya -a deltadet --backend imap attachment download ${envId} 1 --dir "${outDir}" 2>&1`);
|
||||
const lsOut = runCmd(`ls -t "${outDir}"/*.pdf 2>/dev/null | head -1`);
|
||||
const pdfPath = lsOut.trim();
|
||||
return pdfPath && pdfPath.length > 0 ? pdfPath : null;
|
||||
}
|
||||
|
||||
// Parse COR9.1 PDF to determine decision
|
||||
function parseCor91Decision(pdfPath) {
|
||||
const out = runCmd(`${PYTHON} "${SCRIPTS}/parse_cor91.py" "${pdfPath}" 2>&1`);
|
||||
const text = out.toLowerCase();
|
||||
if (text.includes("has been reserved")) return "approved";
|
||||
if (text.includes("regret to inform") && text.includes("no name has been approved")) return "rejected";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
// Generate PDFs and return their paths
|
||||
function generatePdfs(regId, outDir) {
|
||||
runCmd(`TMPDIR="${outDir}" ${PYTHON} "${SCRIPTS}/reg_letter_generator.py" ${regId} 2>&1`);
|
||||
runCmd(`TMPDIR="${outDir}" ${PYTHON} "${SCRIPTS}/reg_resolution_generator.py" ${regId} 2>&1`);
|
||||
const letterPdf = runCmd(`ls "${outDir}"/Director_Acceptance*.pdf 2>/dev/null | head -1`).trim() || null;
|
||||
const resPdf = runCmd(`ls "${outDir}"/New_Company_Resolution.pdf 2>/dev/null | head -1`).trim() || null;
|
||||
return { letterPdf, resPdf };
|
||||
}
|
||||
|
||||
// Upload a file to PB via the app's upload endpoint
|
||||
function uploadToPB(regId, filePath, docType) {
|
||||
const out = runCmd(
|
||||
`curl -s -X POST "http://localhost:6001/api/register/${regId}/upload" ` +
|
||||
`-H "Authorization: Bearer ${PB_TOKEN}" ` +
|
||||
`-F "files=@${filePath};type=application/pdf" ` +
|
||||
`-F "type=${docType}" 2>&1`
|
||||
);
|
||||
console.log(`Upload ${docType}: ${out.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
async function POST(req, { params }) {
|
||||
const regId = params.id;
|
||||
const reg = await loadReg(regId);
|
||||
|
||||
if (!reg) {
|
||||
return NextResponse.json({ error: "Registration not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const proposedName = (
|
||||
reg.proposed_name_1 || reg.proposed_name_2 || reg.proposed_name_3 || reg.proposed_name_4 || ""
|
||||
).trim();
|
||||
|
||||
const steps = [];
|
||||
|
||||
// Step 1: Find CIPC reference by proposed name
|
||||
const refInfo = findCipcRefByName(proposedName);
|
||||
if (!refInfo) {
|
||||
return NextResponse.json({
|
||||
ok: false,
|
||||
error: `Could not find a CIPC Name Reservation email in DeltaDet for "${proposedName}". Make sure the name was submitted on BizPortal first.`,
|
||||
registration: { id: regId, reference_name: reg.reference_name, status: reg.status }
|
||||
}, { status: 200 });
|
||||
}
|
||||
steps.push(`Found CIPC ref ${refInfo.ref} (email ${refInfo.envId})`);
|
||||
|
||||
// Step 2: Find COR9.1 email by reference
|
||||
const cor91Info = findCor91ByRef(refInfo.ref);
|
||||
if (!cor91Info) {
|
||||
return NextResponse.json({
|
||||
ok: false,
|
||||
error: `Found CIPC reference ${refInfo.ref} but could not find the COR9.1 approval email. The approval may not have arrived yet.`,
|
||||
registration: { id: regId, reference_name: reg.reference_name, status: reg.status }
|
||||
}, { status: 200 });
|
||||
}
|
||||
steps.push(`Found COR9.1 email ${cor91Info.envId}`);
|
||||
|
||||
// Step 3: Download COR9.1 PDF
|
||||
const tmpDir = `/tmp/name_already_approved_${regId}_${Date.now()}`;
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
|
||||
const pdfPath = downloadCor91Pdf(cor91Info.envId, tmpDir);
|
||||
if (!pdfPath) {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
return NextResponse.json({ ok: false, error: "Could not download COR9.1 PDF attachment", steps }, { status: 200 });
|
||||
}
|
||||
steps.push("Downloaded COR9.1 PDF");
|
||||
|
||||
// Step 4: Parse PDF for decision
|
||||
const decision = parseCor91Decision(pdfPath);
|
||||
if (decision === "unknown") {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
return NextResponse.json({
|
||||
ok: false,
|
||||
error: `Could not determine CIPC decision from the COR9.1 PDF. The document may not be a standard approval/rejection form.`,
|
||||
steps
|
||||
}, { status: 200 });
|
||||
}
|
||||
steps.push(`CIPC decision: ${decision}`);
|
||||
|
||||
// Step 5: Patch status to bizportal_in_progress + save ref
|
||||
await patchReg(regId, { status: "bizportal_in_progress", cipc_reference_no: refInfo.ref });
|
||||
steps.push(`Set status=bizportal_in_progress, cipc_reference_no=${refInfo.ref}`);
|
||||
|
||||
// Step 6: Patch decision
|
||||
const newStatus = decision === "approved" ? "name_approved" : "bizportal_in_progress";
|
||||
await patchReg(regId, {
|
||||
status: newStatus,
|
||||
cipc_decision: decision,
|
||||
cipc_decided_at: new Date().toISOString()
|
||||
});
|
||||
steps.push(`Patched: status=${newStatus}, decision=${decision}`);
|
||||
|
||||
// Step 7: Generate PDFs + upload + send client email (approved only)
|
||||
if (decision === "approved") {
|
||||
const { letterPdf, resPdf } = generatePdfs(regId, tmpDir);
|
||||
|
||||
// Upload all 3 docs in one multipart call
|
||||
const allPdfs = [pdfPath, letterPdf, resPdf].filter(Boolean);
|
||||
if (allPdfs.length > 0) {
|
||||
const fileArgs = allPdfs.map(p => `-F "files=@${p};type=application/pdf"`).join(" ");
|
||||
const uploadOut = runCmd(
|
||||
`curl -s -X POST "http://localhost:6001/api/register/${regId}/upload" ` +
|
||||
`-H "Authorization: Bearer ${PB_TOKEN}" ` +
|
||||
`${fileArgs} 2>&1`
|
||||
);
|
||||
steps.push(`Uploaded ${allPdfs.length} documents to PB`);
|
||||
console.log("Upload result:", uploadOut.slice(0, 300));
|
||||
}
|
||||
|
||||
// Step 8: Send client email with all 3 PDFs attached
|
||||
const emailResult = runCmd(
|
||||
`${PYTHON} "${SCRIPTS}/send_client_email.py" "${regId}" "${pdfPath}" "${letterPdf || ""}" "${resPdf || ""}" 2>&1`
|
||||
);
|
||||
steps.push(`Client email: ${emailResult.includes("Email sent") ? "sent ✅" : "failed — check logs"}`);
|
||||
console.log("Email result:", emailResult);
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
|
||||
const regAfter = await loadReg(regId);
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
decision,
|
||||
registration: {
|
||||
id: regId,
|
||||
reference_name: reg.reference_name,
|
||||
status_before: reg.status,
|
||||
status_after: regAfter?.status,
|
||||
cipc_reference_no: refInfo.ref,
|
||||
cipc_decision: decision
|
||||
},
|
||||
steps,
|
||||
message: decision === "approved"
|
||||
? `Name approved! Status → name_approved. Documents saved to PB.`
|
||||
: `Name rejected by CIPC. Status → bizportal_in_progress. Client needs to propose new names.`,
|
||||
}, { status: 200 });
|
||||
}
|
||||
|
|
@ -27,6 +27,8 @@ const ALLOWED = new Set([
|
|||
"bizportal_done",
|
||||
"client_requested_changes",
|
||||
"revert_new_names",
|
||||
"step3",
|
||||
"back_to_name_approved",
|
||||
]);
|
||||
|
||||
export async function POST(
|
||||
|
|
@ -129,6 +131,22 @@ export async function POST(
|
|||
newStatus = "bizportal_done";
|
||||
break;
|
||||
|
||||
case "step3":
|
||||
// Move to Step 3 after name has been approved by CIPC
|
||||
if (currentStatus !== "name_approved") {
|
||||
return NextResponse.json({ error: `Cannot move to Step 3 from status "${currentStatus}". Must be "name_approved".` }, { status: 409 });
|
||||
}
|
||||
newStatus = "step3";
|
||||
break;
|
||||
|
||||
case "back_to_name_approved":
|
||||
// Roll back to name_approved from step3
|
||||
if (currentStatus !== "step3") {
|
||||
return NextResponse.json({ error: `Cannot revert from "${currentStatus}". Must be "step3".` }, { status: 409 });
|
||||
}
|
||||
newStatus = "name_approved";
|
||||
break;
|
||||
|
||||
case "client_requested_changes":
|
||||
// Roll back to step 1 if client wants to change something after BizPortal submission
|
||||
if (currentStatus !== "bizportal_done" && currentStatus !== "bizportal_in_progress") {
|
||||
|
|
|
|||
|
|
@ -16,14 +16,23 @@ export async function GET(req: NextRequest) {
|
|||
const { searchParams } = new URL(req.url);
|
||||
const perPage = Math.min(Number(searchParams.get("perPage")) || 500, 1000);
|
||||
const q = (searchParams.get("q") || "").trim();
|
||||
const byId = searchParams.get("id") || "";
|
||||
|
||||
// Fetch all — no fields filter so every column is available
|
||||
// NOTE: this collection has no `created` field — sort by id (nanoid = time-sortable)
|
||||
const data = await fetchCollection<RegItem>("ysterbaard_company_registrations", {
|
||||
const fetchOpts: Record<string, unknown> = {
|
||||
page: 1,
|
||||
perPage,
|
||||
sort: "-id",
|
||||
});
|
||||
};
|
||||
|
||||
// If fetching by ID, reduce perPage to 1 and add filter
|
||||
if (byId) {
|
||||
fetchOpts.perPage = 1;
|
||||
fetchOpts.filter = `id="${byId}"`;
|
||||
}
|
||||
|
||||
const data = await fetchCollection<RegItem>("ysterbaard_company_registrations", fetchOpts);
|
||||
|
||||
let items = data.items;
|
||||
|
||||
|
|
|
|||
20
app/page.tsx
20
app/page.tsx
|
|
@ -10,7 +10,6 @@
|
|||
// All PB queries run in parallel (Promise.all) to keep first paint
|
||||
// under a single round-trip.
|
||||
|
||||
import Link from "next/link";
|
||||
import { fetchCollection } from "@/lib/pb";
|
||||
import type {
|
||||
YsterbaardCompany,
|
||||
|
|
@ -131,24 +130,7 @@ export default async function DashboardPage() {
|
|||
eyebrow="Overview"
|
||||
title="Registry dashboard"
|
||||
subtitle="Live counts from PocketBase. Click into a section for the full searchable list."
|
||||
action={
|
||||
<div className="flex gap-2">
|
||||
<Link
|
||||
href="/directors"
|
||||
data-od-id="cta-directors"
|
||||
className="inline-flex items-center px-5 py-3 rounded-md bg-accent text-accent-on font-semibold text-sm no-underline hover:bg-accent-hover transition-colors"
|
||||
>
|
||||
Browse directors
|
||||
</Link>
|
||||
<Link
|
||||
href="/companies"
|
||||
data-od-id="cta-companies"
|
||||
className="inline-flex items-center px-5 py-3 rounded-md border border-border bg-surface text-fg font-semibold text-sm no-underline hover:bg-surface-warm transition-colors"
|
||||
>
|
||||
Browse companies
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
action={null}
|
||||
/>
|
||||
|
||||
<Dashboard {...data} />
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
import { fetchCollection } from "@/lib/pb";
|
||||
import type { PBRecord } from "@/lib/pb";
|
||||
import { notFound } from "next/navigation";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import PageHeader from "@/components/PageHeader";
|
||||
import Link from "next/link";
|
||||
import Step2Actions from "@/components/Step2Actions";
|
||||
|
|
@ -153,6 +153,7 @@ function StatusBanner({ reg, suggestedNames }: { reg: RegMain; suggestedNames: s
|
|||
export default async function Step2Page({ params }: { params: { id: string } }) {
|
||||
const reg = await loadReg(params.id);
|
||||
if (!reg) notFound();
|
||||
if (reg.status === "step3") redirect(`/registrations/${params.id}/step3`);
|
||||
|
||||
const suggestedNames = [reg.proposed_name_1, reg.proposed_name_2, reg.proposed_name_3, reg.proposed_name_4]
|
||||
.filter(n => n && n.trim());
|
||||
|
|
|
|||
143
app/registrations/[id]/step3/page.tsx
Normal file
143
app/registrations/[id]/step3/page.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
// app/registrations/[id]/step3/page.tsx
|
||||
// Step 3 — Client Approval
|
||||
// Status: step3 = "Awaiting client approval"
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import PageHeader from "@/components/PageHeader";
|
||||
import Link from "next/link";
|
||||
|
||||
interface RegItem {
|
||||
id: string;
|
||||
reference_name: string;
|
||||
status: string;
|
||||
cipc_reserved_name: string;
|
||||
proposed_name_1: string;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
export default function Step3Page({ params }: { params: { id: string } }) {
|
||||
const [reg, setReg] = useState<RegItem | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState("");
|
||||
const [msgKind, setMsgKind] = useState<"success" | "error" | "info">("info");
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/register/all?perPage=1&id=${params.id}`)
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
const item = (d.items || []).find((i: RegItem) => i.id === params.id);
|
||||
setReg(item || null);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
}, [params.id]);
|
||||
|
||||
async function handleBackToStep2() {
|
||||
setBusy(true);
|
||||
setMsg("");
|
||||
try {
|
||||
const res = await fetch(`/api/register/${params.id}/status?action=back_to_name_approved`, { method: "POST" });
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setMsgKind("error");
|
||||
setMsg(`❌ ${data.error || "HTTP " + res.status}`);
|
||||
} else {
|
||||
setMsgKind("success");
|
||||
setMsg("✅ Moved back to Step 2.");
|
||||
setTimeout(() => { window.location.href = `/registrations/${params.id}/step2`; }, 1200);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
setMsgKind("error");
|
||||
setMsg(`❌ ${err instanceof Error ? err.message : "Error"}`);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container-x py-10 text-center text-muted">
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!reg) {
|
||||
return (
|
||||
<div className="container-x py-10 text-center text-muted">
|
||||
Registration not found.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const approvedName = reg.cipc_reserved_name || reg.proposed_name_1 || "the approved name";
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow="Workflow · Step 3 of 4"
|
||||
title={reg.reference_name || reg.id}
|
||||
subtitle="Awaiting client approval — client must review and sign the registration documents."
|
||||
action={
|
||||
<Link
|
||||
href="/registrations"
|
||||
className="px-4 py-2.5 rounded-md border border-border bg-surface text-fg text-sm font-semibold no-underline hover:bg-surface-warm transition-colors"
|
||||
>
|
||||
← Back to all registrations
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="container-x py-10 space-y-6">
|
||||
|
||||
{/* Status banner */}
|
||||
<div className="bg-purple-500/10 border border-purple-500/30 rounded-lg p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex-shrink-0 text-2xl">⏳</span>
|
||||
<div className="flex-1">
|
||||
<p className="text-base font-bold text-purple-700">Awaiting client approval</p>
|
||||
<p className="text-sm text-purple-700/80 mt-1">
|
||||
Company name <span className="font-bold">{approvedName}</span> has been reserved.
|
||||
The client must now review and approve the registration documents before we can file with CIPC.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="bg-surface border border-border rounded-lg p-5">
|
||||
<p className="text-xs uppercase tracking-[0.08em] text-muted font-bold mb-3">Step 3 actions</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
onClick={handleBackToStep2}
|
||||
disabled={busy}
|
||||
className="px-4 py-2 rounded-md border border-yellow-500/30 bg-yellow-500/10 text-yellow-700 text-sm font-semibold hover:bg-yellow-500/20 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{busy ? "…" : "← Back to Step 2"}
|
||||
</button>
|
||||
</div>
|
||||
{msg && (
|
||||
<div className={`mt-3 px-4 py-3 rounded-md text-sm border ${
|
||||
msgKind === "success" ? "bg-success/10 border-success/30 text-success" :
|
||||
msgKind === "error" ? "bg-danger/10 border-danger/30 text-danger" :
|
||||
"bg-surface-warm border-border text-fg"
|
||||
}`}>
|
||||
{msg}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Placeholder for Step 3 content — SJ will define */}
|
||||
<div className="bg-surface border border-border rounded-lg p-8 text-center">
|
||||
<p className="text-muted text-sm">
|
||||
Step 3 workflow (document review, client sign-off, filing) — to be built here.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ const STATUS_LABELS: Record<string, string> = {
|
|||
bizportal_in_progress: "Bizportal submission in progress",
|
||||
bizportal_done: "Bizportal received application",
|
||||
name_approved: "Name approved by Bizportal",
|
||||
step3: "Awaiting client approval",
|
||||
name_reserved: "Name reserved (workflow done)",
|
||||
client_requested_changes: "Step 2 — Client requested changes",
|
||||
resignation_sent: "Step 3 — Resignation Sent",
|
||||
|
|
@ -40,6 +41,7 @@ const STATUS_STYLE: Record<string, string> = {
|
|||
bizportal_in_progress: "bg-blue-500/10 text-blue-700 border border-blue-500/30",
|
||||
bizportal_done: "bg-yellow-500/10 text-yellow-700 border border-yellow-500/30",
|
||||
name_approved: "bg-success/10 text-success border border-success/30",
|
||||
step3: "bg-purple-500/10 text-purple-700 border border-purple-500/30",
|
||||
name_reserved: "bg-purple-500/10 text-purple-700 border border-purple-500/30",
|
||||
client_requested_changes: "bg-yellow-500/10 text-yellow-700 border border-yellow-500/30",
|
||||
resignation_sent: "bg-purple-100 text-purple-700 border border-purple-300",
|
||||
|
|
@ -58,7 +60,8 @@ const STEP_NUMBER: Record<string, number> = {
|
|||
bizportal_in_progress: 2,
|
||||
bizportal_done: 2,
|
||||
name_approved: 2,
|
||||
name_reserved: 2,
|
||||
step3: 3,
|
||||
name_reserved: 3,
|
||||
client_requested_changes: 2,
|
||||
resignation_sent: 3,
|
||||
appointment_accepted: 4,
|
||||
|
|
@ -448,6 +451,27 @@ export default function RegistrationsPage() {
|
|||
← Back
|
||||
</button>
|
||||
)}
|
||||
{/* name_approved → Proceed to Step 3 */}
|
||||
{status === "name_approved" && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
setReopeningId(r.id);
|
||||
try {
|
||||
const res = await fetch(`/api/register/${r.id}/status?action=step3`, { method: "POST" });
|
||||
const data = await res.json();
|
||||
if (!res.ok) { alert(`❌ ${data.error || res.status}`); return; }
|
||||
setItems(prev => prev.map(i => i.id === r.id ? { ...i, status: "step3" } : i));
|
||||
} finally {
|
||||
setReopeningId(null);
|
||||
}
|
||||
}}
|
||||
disabled={reopeningId === r.id}
|
||||
title="Move to Step 3"
|
||||
className="px-2.5 py-1 bg-purple-500/10 border border-purple-500/30 rounded text-xs font-semibold text-purple-700 hover:bg-purple-500/20 transition-colors whitespace-nowrap disabled:opacity-50"
|
||||
>
|
||||
{reopeningId === r.id ? "…" : "➡️ Step 3"}
|
||||
</button>
|
||||
)}
|
||||
{/* View Step 2 page — available for all Step 2 statuses */}
|
||||
{(status === "registered" || status === "bizportal_in_progress" || status === "bizportal_done" || status === "client_requested_changes") && (
|
||||
<Link
|
||||
|
|
@ -457,6 +481,15 @@ export default function RegistrationsPage() {
|
|||
Step 2 →
|
||||
</Link>
|
||||
)}
|
||||
{/* View Step 3 page */}
|
||||
{status === "step3" && (
|
||||
<Link
|
||||
href={`/registrations/${r.id}/step3`}
|
||||
className="px-2.5 py-1 bg-purple-500/10 border border-purple-500/30 rounded text-xs font-semibold text-purple-700 no-underline hover:bg-purple-500/20 transition-colors whitespace-nowrap"
|
||||
>
|
||||
Step 3 →
|
||||
</Link>
|
||||
)}
|
||||
{/* Reopen client wizard — works from any non-cancelled state */}
|
||||
{status !== "cancelled" && (
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -145,6 +145,38 @@ export default function Step2Actions({ registrationId, status, proposedNames }:
|
|||
}
|
||||
}
|
||||
|
||||
async function runNameAlreadyApproved() {
|
||||
setBusy("name_already_approved");
|
||||
setMsg("");
|
||||
setMsgKind("info");
|
||||
try {
|
||||
const res = await fetch(`/api/register/${registrationId}/name-already-approved`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.ok) {
|
||||
setMsgKind("error");
|
||||
setMsg(`❌ ${data.error || "Failed to process."}`);
|
||||
} else if (data.decision === "approved") {
|
||||
setMsgKind("success");
|
||||
setMsg(`✅ Name approved! Documents saved to PB. Reloading…`);
|
||||
setTimeout(() => window.location.reload(), 3000);
|
||||
} else if (data.decision === "rejected") {
|
||||
setMsgKind("warn");
|
||||
setMsg(`⚠ Name rejected by CIPC. Client needs to propose new names.`);
|
||||
} else {
|
||||
setMsgKind("info");
|
||||
setMsg(data.message || "Done.");
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
setMsgKind("error");
|
||||
setMsg(`❌ ${err instanceof Error ? err.message : "Error"}`);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
const isRegistered = status === "registered";
|
||||
const isBizportalInProgress = status === "bizportal_in_progress";
|
||||
const isBizportalDone = status === "bizportal_done";
|
||||
|
|
@ -170,6 +202,14 @@ export default function Step2Actions({ registrationId, status, proposedNames }:
|
|||
>
|
||||
{busy === "apply_name_only" ? "⏳ Running…" : "🪪 Name Reservation Only"}
|
||||
</button>
|
||||
<button
|
||||
onClick={runNameAlreadyApproved}
|
||||
disabled={busy === "name_already_approved"}
|
||||
className="px-4 py-2 rounded-md border border-green-500/30 bg-green-500/10 text-green-700 text-sm font-bold hover:bg-green-500/20 disabled:opacity-50 transition-colors"
|
||||
title="Name was already approved on BizPortal by the company secretary — search DeltaDet for existing CIPC emails and complete the workflow"
|
||||
>
|
||||
{busy === "name_already_approved" ? "⏳ Searching emails…" : "✅ Name Already Approved"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => runAction("back_to_step1")}
|
||||
disabled={busy === "back_to_step1"}
|
||||
|
|
@ -244,6 +284,13 @@ export default function Step2Actions({ registrationId, status, proposedNames }:
|
|||
<span className="px-3 py-2 rounded-md border border-success/30 bg-success/10 text-success text-sm font-bold">
|
||||
✓ Name approved by Bizportal
|
||||
</span>
|
||||
<button
|
||||
onClick={() => runAction("step3")}
|
||||
disabled={busy === "step3"}
|
||||
className="px-4 py-2 rounded-md border border-purple-500/30 bg-purple-500/10 text-purple-700 text-sm font-bold hover:bg-purple-500/20 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{busy === "step3" ? "⏳ Moving…" : "➡️ Proceed to Step 3"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowRevertConfirm(true)}
|
||||
disabled={busy === "revert_new_names"}
|
||||
|
|
|
|||
Loading…
Reference in a new issue