// --------------------------------------------------------------------------- // customer-grid / Documents.tsx // Wave-8 I12c (contract C5) — the Documents section of the record drawer: // upload, list, download, delete, per customer. // // Shape notes that are not obvious from the contract: // // - The bytes NEVER ride the payload. Metadata (`payload.docs[pid]`) renders // the list; the file itself is fetched one at a time via `doc_fetch`, whose // answer arrives on the NEXT render in `payload.docPayload`. The host bridge // has no request/response channel — events are a fire-and-forget log // (hostBridge.ts) — so a download is: emit, remember what we asked for, // recognise the answer, build a Blob, revoke the URL. // // - The 5 MB cap is enforced BEFORE the read, on `file.size`. Reading a 400 MB // file into memory to discover it is too big is its own bug, and base64 // inflates by 4/3 on top of that. The limit is also stated in the UI before // anyone picks a file, rather than only in the rejection. // // - Delete is host-enforced (uploader or admin). `canDelete` only decides // whether the control RENDERS — a hidden button is courtesy, never the wall. // --------------------------------------------------------------------------- import { useEffect, useRef, useState } from "react"; import { MAX_DOC_BYTES } from "./types"; import type { CustomerDoc } from "./types"; function sizeText(bytes: number): string { if (!Number.isFinite(bytes) || bytes < 0) return "—"; if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } /** Uploaded-by / when, as one quiet line. `ts` is host-supplied; it is rendered, * never computed from the browser clock. */ function byLine(doc: CustomerDoc): string { const parts: string[] = []; if (doc.by) parts.push(doc.by); if (doc.ts) parts.push(doc.ts.slice(0, 16).replace("T", " ")); return parts.join(" · "); } export function Documents({ pid, docs, docPayload, onAdd, onFetch, onDelete, }: { pid: number; docs: CustomerDoc[]; /** The host's answer to the most recent `doc_fetch`, if one has arrived. */ docPayload?: { pid: number; docId: string; name: string; mime: string; data_b64: string }; onAdd: (file: { name: string; mime: string; size: number; data_b64: string }) => void; onFetch: (docId: string) => void; onDelete: (docId: string) => void; }) { const fileRef = useRef(null); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); /** What we asked the host for, so an arriving docPayload is only consumed when * it is the answer to OUR request (and only once). */ const wantRef = useRef(null); const [confirmId, setConfirmId] = useState(null); // The download half: recognise our answer, save it, forget it. useEffect(() => { if (!docPayload || wantRef.current == null) return; if (docPayload.pid !== pid || docPayload.docId !== wantRef.current) return; wantRef.current = null; try { const bin = atob(docPayload.data_b64); const bytes = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i += 1) bytes[i] = bin.charCodeAt(i); const url = URL.createObjectURL( new Blob([bytes], { type: docPayload.mime || "application/octet-stream" }) ); const a = document.createElement("a"); a.href = url; a.download = docPayload.name || "document"; document.body.appendChild(a); a.click(); a.remove(); // Revoked on the next tick: revoking synchronously can beat the click in // some browsers and the download silently does nothing. window.setTimeout(() => URL.revokeObjectURL(url), 0); } catch { setError("That file could not be decoded. Nothing was downloaded."); } }, [docPayload, pid]); const pick = (file: File | undefined) => { setError(null); if (!file) return; // Checked on file.size, BEFORE reading — see the header note. if (file.size > MAX_DOC_BYTES) { setError( `${file.name} is ${sizeText(file.size)}. The limit is ${sizeText(MAX_DOC_BYTES)}, so it was not uploaded.` ); if (fileRef.current) fileRef.current.value = ""; return; } setBusy(true); const reader = new FileReader(); reader.onerror = () => { setBusy(false); setError(`${file.name} could not be read.`); }; reader.onload = () => { setBusy(false); const result = String(reader.result ?? ""); const comma = result.indexOf(","); if (comma < 0) { setError(`${file.name} could not be encoded.`); return; } onAdd({ name: file.name, mime: file.type || "application/octet-stream", size: file.size, data_b64: result.slice(comma + 1), }); if (fileRef.current) fileRef.current.value = ""; }; reader.readAsDataURL(file); }; return (
Documents
{docs.length === 0 ? (
No documents on this customer yet.
) : (
    {docs.map((d) => (
  • {sizeText(d.size)} {byLine(d) ? ` · ${byLine(d)}` : ""} {d.canDelete !== false && ( )}
  • ))}
)}
pick(e.target.files?.[0])} /> Up to {sizeText(MAX_DOC_BYTES)} per file.
{error &&
{error}
}
); }