Workflow-App/Workflow/components/UploadDocumentModal.tsx
sjcoetzeesj-coder 11d0178ab1 feat: commit Workflow app source; move CIPC credentials to env
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.
2026-10-18 15:21:45 +02:00

315 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

"use client";
// components/UploadDocumentModal.tsx
// Shared upload modal for director and company documents.
// Has an inline "+ New Type" flow — no need to leave PB to add a type.
import { useState, useEffect, useRef } from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
fetchDocumentTypes,
createDocumentType,
uploadDirectorDocument,
uploadCompanyDocument,
type DocType,
} from "@/lib/api";
interface Props {
context: "director" | "company";
entityId: string;
open: boolean;
onClose: () => void;
onUploaded: () => void;
}
export default function UploadDocumentModal({
context,
entityId,
open,
onClose,
onUploaded,
}: Props) {
const qc = useQueryClient();
const overlayRef = useRef<HTMLDivElement>(null);
const [docTypes, setDocTypes] = useState<DocType[]>([]);
const [selectedTypeId, setSelectedTypeId] = useState("");
const [notes, setNotes] = useState("");
const [file, setFile] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// New type creation state
const [showNewType, setShowNewType] = useState(false);
const [newTypeName, setNewTypeName] = useState("");
const [newTypeDesc, setNewTypeDesc] = useState("");
const [creatingType, setCreatingType] = useState(false);
// Load document types whenever modal opens
useEffect(() => {
if (!open) return;
fetchDocumentTypes(context)
.then((r) => setDocTypes(r.items))
.catch(() => setDocTypes([]));
}, [open, context]);
// Reset form on open
useEffect(() => {
if (open) {
setSelectedTypeId("");
setNotes("");
setFile(null);
setError(null);
setShowNewType(false);
setNewTypeName("");
setNewTypeDesc("");
}
}, [open]);
if (!open) return null;
const handleOverlayClick = (e: React.MouseEvent) => {
if (e.target === overlayRef.current) onClose();
};
const handleCreateType = async (e: React.FormEvent) => {
e.preventDefault();
if (!newTypeName.trim()) return;
setCreatingType(true);
setError(null);
try {
const created = await createDocumentType({
name: newTypeName.trim(),
context,
description: newTypeDesc.trim() || undefined,
});
setDocTypes((prev) => [...prev, created]);
setSelectedTypeId(created.id);
setShowNewType(false);
setNewTypeName("");
setNewTypeDesc("");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create type");
} finally {
setCreatingType(false);
}
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0] ?? null;
if (f) {
const allowed = ["application/pdf", "image/jpeg", "image/jpg"];
if (!allowed.includes(f.type)) {
setError("Only PDF and JPEG files are allowed");
e.target.value = "";
return;
}
if (f.size > 10 * 1024 * 1024) {
setError("File must be smaller than 10MB");
e.target.value = "";
return;
}
setFile(f);
setError(null);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedTypeId) {
setError("Please select a document type");
return;
}
if (!file) {
setError("Please select a file to upload");
return;
}
setLoading(true);
setError(null);
const formData = new FormData();
formData.append(context === "director" ? "director_id" : "company_id", entityId);
formData.append("document_type_id", selectedTypeId);
formData.append("notes", notes);
formData.append("file", file);
formData.append("original_filename", file.name);
try {
if (context === "director") {
await uploadDirectorDocument(formData);
} else {
await uploadCompanyDocument(formData);
}
await qc.invalidateQueries({ queryKey: ["documents", context, entityId] });
onUploaded();
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : "Upload failed. Please try again.");
} finally {
setLoading(false);
}
};
return (
<div
ref={overlayRef}
onClick={handleOverlayClick}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
role="dialog"
aria-modal="true"
aria-label="Upload document"
>
<div className="bg-surface border border-border rounded-xl shadow-2xl w-full max-w-md 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">Upload document</h2>
<p className="text-sm text-muted mt-0.5">
PDF or JPEG, max 10MB
</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 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>
)}
{/* Document type */}
<div>
<label className="block text-sm font-semibold text-fg mb-1.5" htmlFor="docType">
Document type <span className="text-red-400">*</span>
</label>
{showNewType ? (
/* Inline new type form */
<form onSubmit={handleCreateType} className="border border-accent/40 bg-accent/5 rounded-lg p-3 space-y-3">
<p className="text-xs font-semibold text-accent uppercase tracking-wide">New type</p>
<input
type="text"
value={newTypeName}
onChange={(e) => setNewTypeName(e.target.value)}
placeholder="e.g. SA ID, Tax Clearance"
className="w-full rounded border border-border bg-surface-warm px-3 py-2 text-sm text-fg placeholder:text-muted focus:outline-none focus:border-accent"
required
/>
<input
type="text"
value={newTypeDesc}
onChange={(e) => setNewTypeDesc(e.target.value)}
placeholder="Description (optional)"
className="w-full rounded border border-border bg-surface-warm px-3 py-2 text-sm text-fg placeholder:text-muted focus:outline-none focus:border-accent"
/>
<div className="flex gap-2">
<button
type="button"
onClick={() => setShowNewType(false)}
className="flex-1 px-3 py-2 rounded border border-border bg-surface-warm text-xs font-semibold text-fg hover:bg-surface"
>
Cancel
</button>
<button
type="submit"
disabled={creatingType}
className="flex-1 px-3 py-2 rounded bg-accent text-accent-on text-xs font-bold hover:bg-accent-hover disabled:opacity-60"
>
{creatingType ? "Creating..." : "Create type"}
</button>
</div>
</form>
) : (
<div className="flex items-center gap-2">
<select
id="docType"
value={selectedTypeId}
onChange={(e) => setSelectedTypeId(e.target.value)}
className="flex-1 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
>
<option value="">— Select a type —</option>
{docTypes.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
<button
type="button"
onClick={() => setShowNewType(true)}
title="Create a new document type"
className="shrink-0 px-3 py-2.5 rounded-lg border border-dashed border-accent/50 text-accent text-sm font-bold hover:bg-accent/10 transition-colors"
>
+ New
</button>
</div>
)}
</div>
{/* File */}
<div>
<label className="block text-sm font-semibold text-fg mb-1.5" htmlFor="docFile">
File <span className="text-red-400">*</span>
</label>
<input
id="docFile"
type="file"
accept=".pdf,.jpg,.jpeg"
onChange={handleFileChange}
className="w-full rounded-lg border border-border bg-surface-warm px-3 py-2.5 text-sm text-fg file:mr-3 file:py-1 file:px-3 file:rounded file:border-0 file:bg-accent/10 file:text-accent file:text-xs file:font-bold file:cursor-pointer"
required
/>
{file && (
<p className="mt-1.5 text-xs text-muted">
{file.name} ({(file.size / 1024 / 1024).toFixed(2)} MB)
</p>
)}
</div>
{/* Notes */}
<div>
<label className="block text-sm font-semibold text-fg mb-1.5" htmlFor="docNotes">
Notes <span className="text-xs text-muted font-normal">(optional)</span>
</label>
<textarea
id="docNotes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
className="w-full rounded-lg border border-border bg-surface-warm px-3 py-2.5 text-sm text-fg placeholder:text-muted focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent resize-none"
placeholder="Any relevant details..."
/>
</div>
{/* Actions */}
<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 || !selectedTypeId || !file}
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 ? "Uploading..." : "Upload"}
</button>
</div>
</form>
</div>
</div>
);
}