Workflow-App/Workflow/components/UploadDocumentModal.tsx

316 lines
11 KiB
TypeScript
Raw Normal View History

"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>
);
}