Workflow-App/Workflow/components/DocumentsPanel.tsx
sjcoetzeesj-coder 11d0178ab1 feat: commit Workflow app source; move CIPC credentials to env
Adds the full Next.js app (app/, components/, lib/, scripts/, assets/,
docs/, Dockerfile, compose, configs) to version control.

Security:
- CIPC BizPortal customer-code logins (Login 1 + Login 3) now read
  CIPC_LOGIN1_CODE / CIPC_LOGIN1_PASSWORD / CIPC_LOGIN3_CODE /
  CIPC_LOGIN3_PASSWORD from .env.local instead of source.
- BizPortal personal login in apply-bizportal route reads
  BIZPORTAL_ID_NO / BIZPORTAL_PASSWORD.
- Added .env.example as the committed template.

.gitignore excludes .env*, node_modules, .next, logs, sqlite, Playwright
storage_state.json (live CIPC cookies), scratch dirs, client PDFs and
debug screenshots.
2026-10-18 15:21:45 +02:00

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