File size: 7,152 Bytes
092334a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 | // ---------------------------------------------------------------------------
// 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<HTMLInputElement>(null);
const [error, setError] = useState<string | null>(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<string | null>(null);
const [confirmId, setConfirmId] = useState<string | null>(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 (
<section className="cg-detail-section cg-docs">
<div className="cg-detail-section-title">Documents</div>
{docs.length === 0 ? (
<div className="cg-docs-empty">No documents on this customer yet.</div>
) : (
<ul className="cg-docs-list">
{docs.map((d) => (
<li key={d.id} className="cg-docs-row">
<button
type="button"
className="cg-link-btn cg-docs-name"
title={`Download ${d.name}`}
onClick={() => {
setError(null);
wantRef.current = d.id;
onFetch(d.id);
}}
>
{d.name}
</button>
<span className="cg-docs-meta">
{sizeText(d.size)}
{byLine(d) ? ` · ${byLine(d)}` : ""}
</span>
{d.canDelete !== false && (
<button
type="button"
className={"cg-docs-del" + (confirmId === d.id ? " is-armed" : "")}
aria-label={confirmId === d.id ? `Delete ${d.name}?` : `Delete ${d.name}`}
onClick={() => {
if (confirmId !== d.id) {
setConfirmId(d.id);
return;
}
setConfirmId(null);
onDelete(d.id);
}}
>
{confirmId === d.id ? "Delete?" : "×"}
</button>
)}
</li>
))}
</ul>
)}
<div className="cg-docs-add">
<input
ref={fileRef}
type="file"
className="cg-docs-file"
aria-label="Add a document"
disabled={busy}
onChange={(e) => pick(e.target.files?.[0])}
/>
<span className="cg-docs-cap">Up to {sizeText(MAX_DOC_BYTES)} per file.</span>
</div>
{error && <div className="cg-docs-error">{error}</div>}
</section>
);
}
|