232 lines
8.9 KiB
TypeScript
232 lines
8.9 KiB
TypeScript
|
|
"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>
|
|||
|
|
)}
|
|||
|
|
</>
|
|||
|
|
);
|
|||
|
|
}
|