Adds the full Next.js app (app/, components/, lib/, scripts/, assets/, docs/, Dockerfile, compose, configs) to version control. Security: - CIPC BizPortal customer-code logins (Login 1 + Login 3) now read CIPC_LOGIN1_CODE / CIPC_LOGIN1_PASSWORD / CIPC_LOGIN3_CODE / CIPC_LOGIN3_PASSWORD from .env.local instead of source. - BizPortal personal login in apply-bizportal route reads BIZPORTAL_ID_NO / BIZPORTAL_PASSWORD. - Added .env.example as the committed template. .gitignore excludes .env*, node_modules, .next, logs, sqlite, Playwright storage_state.json (live CIPC cookies), scratch dirs, client PDFs and debug screenshots.
187 lines
6.7 KiB
TypeScript
187 lines
6.7 KiB
TypeScript
// components/RegistrationDocuments.tsx
|
|
// Client-side panel for managing a registration's `documents` field:
|
|
// - Lists current files with download links
|
|
// - Provides an upload form for the 5 post-wizard document types:
|
|
// Cor9.1 (name reserved letter), Cor9.4, Director Acceptance, Resolution,
|
|
// CIPC Registration, MOI
|
|
//
|
|
// All uploads go to /api/register/[id]/upload, which renames the file
|
|
// server-side per the type and appends it to the reg's `documents` array.
|
|
|
|
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
|
|
type DocType =
|
|
| "cipc_reservation"
|
|
| "cipc_approval"
|
|
| "director_acceptance"
|
|
| "resolution"
|
|
| "cipc_registration"
|
|
| "moi";
|
|
|
|
const TYPE_LABELS: Record<DocType, string> = {
|
|
cipc_reservation: "Cor9.1 — Name Reservation letter",
|
|
cipc_approval: "Cor9.4 — Name Approval letter",
|
|
director_acceptance: "Director Acceptance letter",
|
|
resolution: "Resolution to start company",
|
|
cipc_registration: "CIPC Registration document",
|
|
moi: "MOI from CIPC",
|
|
};
|
|
|
|
const TYPE_PREFIX: Record<DocType, string> = {
|
|
cipc_reservation: "Cor9.1",
|
|
cipc_approval: "Cor9.4",
|
|
director_acceptance: "Director Acceptance",
|
|
resolution: "Resolution",
|
|
cipc_registration: "CIPC Registration",
|
|
moi: "MOI",
|
|
};
|
|
|
|
interface PBFile {
|
|
name: string;
|
|
originalName?: string;
|
|
}
|
|
|
|
function extractFiles(documents: unknown): PBFile[] {
|
|
if (!documents) return [];
|
|
if (Array.isArray(documents)) {
|
|
return documents
|
|
.map((d) => {
|
|
if (typeof d === "string") return { name: d };
|
|
if (d && typeof d === "object" && "name" in (d as Record<string, unknown>)) {
|
|
return { name: String((d as Record<string, unknown>).name) };
|
|
}
|
|
return null;
|
|
})
|
|
.filter((x): x is PBFile => !!x);
|
|
}
|
|
return [];
|
|
}
|
|
|
|
export default function RegistrationDocuments({
|
|
registrationId,
|
|
initialDocuments,
|
|
cipcReservedName,
|
|
}: {
|
|
registrationId: string;
|
|
initialDocuments: unknown;
|
|
cipcReservedName?: string;
|
|
}) {
|
|
const [docs, setDocs] = useState<PBFile[]>(extractFiles(initialDocuments));
|
|
const [type, setType] = useState<DocType>("cipc_reservation");
|
|
const [file, setFile] = useState<File | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
|
|
|
|
// Refresh the file list every time the component remounts
|
|
useEffect(() => {
|
|
setDocs(extractFiles(initialDocuments));
|
|
}, [initialDocuments]);
|
|
|
|
async function handleUpload(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
if (!file) {
|
|
setMsg({ kind: "err", text: "Pick a file first." });
|
|
return;
|
|
}
|
|
setBusy(true);
|
|
setMsg(null);
|
|
try {
|
|
const fd = new FormData();
|
|
fd.append("type", type);
|
|
if (cipcReservedName) fd.append("reserved_name", cipcReservedName);
|
|
fd.append("files", file);
|
|
const res = await fetch(`/api/register/${registrationId}/upload`, {
|
|
method: "POST",
|
|
body: fd,
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
|
// Append the new file to local state (server didn't return per-file names
|
|
// in a stable shape, so we just re-fetch by triggering a page refresh).
|
|
setFile(null);
|
|
setMsg({ kind: "ok", text: `Uploaded ${data.filesUploaded || 1} file(s). Reloading…` });
|
|
setTimeout(() => window.location.reload(), 800);
|
|
} catch (err: unknown) {
|
|
setMsg({ kind: "err", text: err instanceof Error ? err.message : "Upload failed" });
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="bg-surface border border-border rounded-lg p-5 space-y-4">
|
|
<div>
|
|
<p className="text-xs uppercase tracking-[0.08em] text-muted font-bold mb-2">Documents</p>
|
|
{docs.length === 0 ? (
|
|
<p className="text-xs text-muted italic">No documents uploaded yet.</p>
|
|
) : (
|
|
<ul className="space-y-1.5">
|
|
{docs.map((d, i) => (
|
|
<li key={i} className="flex items-center justify-between gap-3 text-sm">
|
|
<span className="font-mono text-fg truncate">{d.name}</span>
|
|
<a
|
|
href={`/api/register/${registrationId}/file?name=${encodeURIComponent(d.name)}`}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="text-xs text-accent hover:underline"
|
|
>
|
|
Download
|
|
</a>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
|
|
<form onSubmit={handleUpload} className="border-t border-border pt-4 space-y-3">
|
|
<p className="text-xs uppercase tracking-[0.08em] text-muted font-bold">Upload document</p>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="text-xs font-semibold text-muted mb-1 block">Type</label>
|
|
<select
|
|
value={type}
|
|
onChange={(e) => setType(e.target.value as DocType)}
|
|
className="w-full bg-surface border border-border rounded px-3 py-2 text-sm text-fg focus:outline-none focus:border-accent"
|
|
>
|
|
{(Object.keys(TYPE_LABELS) as DocType[]).map((k) => (
|
|
<option key={k} value={k}>
|
|
{TYPE_LABELS[k]} → {TYPE_PREFIX[k]} - {cipcReservedName || "..."}
|
|
</option>
|
|
))}
|
|
</select>
|
|
{cipcReservedName && (
|
|
<p className="text-[10px] text-muted mt-1 font-mono">
|
|
Will save as: {TYPE_PREFIX[type]} - {cipcReservedName}.<ext>
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<label className="text-xs font-semibold text-muted mb-1 block">File (PDF / JPG / PNG, ≤ 10 MB)</label>
|
|
<input
|
|
type="file"
|
|
accept="application/pdf,image/jpeg,image/png"
|
|
onChange={(e) => setFile(e.target.files?.[0] || null)}
|
|
className="block w-full text-sm text-fg file:mr-3 file:py-1.5 file:px-3 file:rounded file:border file:border-border file:bg-surface-warm file:text-fg file:text-xs file:font-semibold file:cursor-pointer hover:file:bg-accent/10"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
type="submit"
|
|
disabled={busy || !file}
|
|
className="px-4 py-2 rounded-md border border-accent bg-accent text-accent-on text-sm font-bold disabled:opacity-40 hover:opacity-90"
|
|
>
|
|
{busy ? "Uploading…" : "Upload"}
|
|
</button>
|
|
{msg && (
|
|
<span className={msg.kind === "ok" ? "text-xs text-success" : "text-xs text-danger"}>
|
|
{msg.text}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|