316 lines
11 KiB
TypeScript
316 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>
|
|||
|
|
);
|
|||
|
|
}
|