loopable / web /src /customer-grid /Documents.tsx
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
092334a verified
Raw
History Blame Contribute Delete
7.15 kB
// ---------------------------------------------------------------------------
// 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>
);
}