Uncommitted work in progress at the point of starting the Kanban project. Captures: - Registration wizard refactor + multi-step routing - structured physical/postal address fields on companies and directors - appointments flow (sign + verify) - document type manager + upload/panel for companies and directors - status actions, approve button, action/dropdown buttons - step2 review page tsconfig.tsbuildinfo is now ignored (build artefact).
315 lines
11 KiB
TypeScript
315 lines
11 KiB
TypeScript
"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>
|
||
);
|
||
}
|