WORKFLOW-APP/components/DocumentsPanel.tsx
Dave da504c45a0 wip: registration wizard, address splits, document panel, appointments
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).
2026-08-17 18:33:56 +02:00

231 lines
8.9 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/DocumentsPanel.tsx
// Shared document panel for directors and companies.
// Lists documents, provides download + delete, and opens the upload modal.
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { pbList } from "@/lib/api";
import { deleteDirectorDocument, deleteCompanyDocument } from "@/lib/api";
import { useQueryClient } from "@tanstack/react-query";
import UploadDocumentModal from "./UploadDocumentModal";
import type { YsterbaardDirectorDocument, YsterbaardCompanyDocument } from "@/lib/types";
interface Props {
context: "director" | "company";
entityId: string;
}
const PB_URL = process.env.NEXT_PUBLIC_PB_URL ?? "https://pb.agent.apphub.co.za";
type DocItem = YsterbaardDirectorDocument | YsterbaardCompanyDocument;
function DocType({ doc }: { doc: DocItem }) {
const dt = doc.expand?.document_type;
if (!dt) return <span className="text-muted">—</span>;
return (
<span>
<span className="font-semibold text-fg">{dt.name}</span>
{dt.description ? <span className="text-muted text-xs ml-1">— {dt.description}</span> : null}
</span>
);
}
function FileIcon({ filename }: { filename: string }) {
const ext = filename?.split(".").pop()?.toLowerCase();
const isPdf = ext === "pdf";
return (
<span className={`inline-flex items-center justify-center w-8 h-8 rounded ${isPdf ? "bg-red-500/10 text-red-500" : "bg-blue-500/10 text-blue-500"} text-xs font-bold shrink-0`}>
{ext?.toUpperCase()}
</span>
);
}
export default function DocumentsPanel({ context, entityId }: Props) {
const qc = useQueryClient();
const [showUpload, setShowUpload] = useState(false);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const collection =
context === "director"
? "ysterbaard_director_documents"
: "ysterbaard_company_documents";
const { data, isLoading } = useQuery({
queryKey: ["documents", context, entityId],
queryFn: () =>
pbList<DocItem>({
collection,
filter: context === "director" ? `director = '${entityId}'` : `company = '${entityId}'`,
sort: "-uploaded_at",
perPage: 100,
expand: "document_type",
fields: "*,expand.document_type.id,expand.document_type.name,expand.document_type.description",
}),
});
const docs = data?.items ?? [];
const handleDelete = async () => {
if (!deleteId) return;
setDeleting(true);
setDeleteError(null);
try {
if (context === "director") {
await deleteDirectorDocument(deleteId);
} else {
await deleteCompanyDocument(deleteId);
}
await qc.invalidateQueries({ queryKey: ["documents", context, entityId] });
setDeleteId(null);
} catch (err) {
setDeleteError(err instanceof Error ? err.message : "Delete failed");
} finally {
setDeleting(false);
}
};
return (
<>
<div className="bg-surface border border-border rounded-lg overflow-hidden">
{/* Header */}
<div className="px-5 py-4 border-b border-border flex items-center justify-between gap-3">
<div>
<h3 className="text-base font-bold text-fg">Documents</h3>
<p className="text-xs text-muted mt-0.5">
{docs.length} file{docs.length !== 1 ? "s" : ""} · PDF or JPEG, max 10MB
</p>
</div>
<button
onClick={() => setShowUpload(true)}
className="shrink-0 px-4 py-2 rounded-lg bg-accent text-accent-on text-sm font-bold hover:bg-accent-hover transition-colors"
>
+ Upload
</button>
</div>
{/* List */}
{isLoading ? (
<div className="p-8 text-center text-muted text-sm">Loading…</div>
) : docs.length === 0 ? (
<div className="p-8 text-center">
<p className="text-muted text-sm">No documents yet.</p>
<button
onClick={() => setShowUpload(true)}
className="mt-2 text-accent text-sm font-semibold hover:underline"
>
Upload the first document →
</button>
</div>
) : (
<ul className="divide-y divide-border">
{docs.map((doc) => {
const fileUrl = `${PB_URL}/api/files/${collection}/${doc.id}/${doc.file}`;
const uploadedAt = (doc as DocItem).uploaded_at ?? doc.created;
const ext = (doc.file as string)?.split(".").pop()?.toLowerCase() ?? "pdf";
return (
<li key={doc.id} className="px-5 py-4 flex items-start gap-3 hover:bg-surface-warm transition-colors">
{/* File type icon */}
<span className={`inline-flex items-center justify-center w-9 h-9 rounded shrink-0 mt-0.5 text-xs font-bold ${ext === "pdf" ? "bg-red-500/10 text-red-500" : "bg-blue-500/10 text-blue-500"}`}>
{ext.toUpperCase()}
</span>
{/* Info */}
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-fg truncate">
{(doc as DocItem).file ?? "Uploaded file"}
</p>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-muted">
<DocType doc={doc} />
<span>·</span>
<span>{uploadedAt ? new Date(uploadedAt).toLocaleDateString("en-ZA") : "—"}</span>
{(doc as DocItem).notes ? (
<>
<span>·</span>
<span className="text-fg-2 italic">{(doc as DocItem).notes}</span>
</>
) : null}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2 shrink-0">
<a
href={fileUrl}
target="_blank"
rel="noopener noreferrer"
className="px-3 py-1.5 rounded border border-border bg-surface-warm text-xs font-semibold text-fg hover:border-accent hover:text-accent transition-colors"
>
↓ Download
</a>
<button
onClick={() => setDeleteId(doc.id)}
className="px-3 py-1.5 rounded border border-border bg-surface-warm text-xs font-semibold text-muted hover:border-red-500 hover:text-red-500 transition-colors"
title="Delete document"
>
Delete
</button>
</div>
</li>
);
})}
</ul>
)}
</div>
{/* Upload modal */}
<UploadDocumentModal
context={context}
entityId={entityId}
open={showUpload}
onClose={() => setShowUpload(false)}
onUploaded={() => {
qc.invalidateQueries({ queryKey: ["documents", context, entityId] });
setShowUpload(false);
}}
/>
{/* Delete confirmation */}
{deleteId && (
<div
onClick={(e) => { if (e.target === e.currentTarget) setDeleteId(null); }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
role="dialog"
aria-modal="true"
>
<div className="bg-surface border border-border rounded-xl shadow-2xl w-full max-w-sm">
<div className="px-6 pt-6 pb-4">
<h2 className="text-lg font-bold text-fg">Delete this document?</h2>
<p className="text-sm text-muted mt-1">
This will permanently delete the file from PocketBase. This action cannot be undone.
</p>
</div>
{deleteError && (
<div className="mx-6 mb-4 bg-red-500/10 border border-red-500/30 rounded-lg px-4 py-3 text-sm text-red-400">
{deleteError}
</div>
)}
<div className="px-6 pb-6 flex gap-3">
<button
onClick={() => setDeleteId(null)}
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"
>
Cancel
</button>
<button
onClick={handleDelete}
disabled={deleting}
className="flex-1 px-4 py-2.5 rounded-lg bg-red-600 text-white text-sm font-bold hover:bg-red-700 disabled:opacity-60 disabled:cursor-not-allowed"
>
{deleting ? "Deleting..." : "Yes, delete"}
</button>
</div>
</div>
</div>
)}
</>
);
}