MEGAINFRA-WEBAPP/src/app/clock/document-scanner.tsx
DEls 068f622c0a feat(clock): photograph several pages, send them as one document
A delivery note that runs to two or three sheets has to arrive as ONE
batch. Sent a page at a time it became separate batches, and then
nothing could tell that page 2's line items belonged to page 1's note —
the totals sat on one batch and the items on another, with no way back.

Pages now accumulate. Take one, then Retake, Add page, or Send all.
A thumbnail strip shows what has been taken so far, ringed green or red
by how each page measured, so a mis-shot can be dropped without starting
the document over and nobody has to remember whether they got page 2.

Several pages are bundled into a single PDF and enter the pipeline
exactly like a scanned multi-page document: split.ts rasterises each
page, extract.ts reads each in isolation, and segment.ts decides from
the printed document numbers whether it is one note over three pages or
three separate notes. The person holding the phone does not have to know
which, which matters, because standing next to a truck they often can't.

The bundle is verified against the pipeline's own tools rather than
assumed compatible: the test writes one and runs pdfinfo and pdftoppm
over it — the exact binaries split.ts shells out to — checking the page
count, that every page rasterises, that portrait stays portrait, and
that page order survives. A bundle the pipeline could not open would
otherwise fail at the far end of an upload, on site, after the truck had
gone.

One page still uploads as its own JPEG: smaller, and it never loads the
PDF library at all. pdf-lib is imported dynamically and webpack keeps it
in a 412K chunk of its own, leaving the clock page's bundle at 24K —
checked in the build output, not hoped for.

Size is checked before sending, mirroring MAX_FILE_BYTES on the upload
route, so an oversized bundle is caught while the document is still in
front of the person rather than as a 400 afterwards.

1027 tests, typecheck clean, production build clean. The capture path
itself still needs a real phone — the browser pane here has no camera
and cannot reach localhost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 19:10:16 +02:00

636 lines
23 KiB
TypeScript

"use client";
import * as React from "react";
import { Camera, Check, Loader2, MapPin, Plus, RotateCcw, Send, X } from "lucide-react";
import {
ANALYSIS_WIDTH,
assessGray,
isAcceptable,
toGrayscale,
type QualityReport,
} from "@/lib/image-quality";
import {
formatDistance,
nearestSite,
type SitePoint,
} from "@/lib/nearest-site";
import { imagesToPdf } from "@/lib/photo-pdf";
import { cn } from "@/lib/utils";
/**
* Photograph a delivery note or invoice and send it to the inbox.
*
* Deliberately not the badge scanner. That one decodes QR continuously
* and never keeps a frame; this one holds a still, measures whether it
* is readable, and only then uploads.
*
* A document can run to several pages, so pages accumulate and are sent
* together as ONE batch. Sent separately they become separate batches,
* and nothing could then tell that page 2's line items belong to page
* 1's note — the totals would sit on one batch and the items on
* another. Bundled, the batch enters the pipeline exactly like a scanned
* multi-page PDF, and segment.ts decides from the printed document
* numbers whether it is one note over three pages or three separate
* notes. The person holding the phone does not have to know which.
*/
/**
* Asked of the camera. Phones cap this at whatever they have — iOS
* tends to cap video at 1920 wide — and we take what we are given. At
* 1920 an A4 page is around 160 dpi, which print reads fine at.
*/
const IDEAL_WIDTH = 2560;
const IDEAL_HEIGHT = 1440;
/** How often the live preview re-measures focus. */
const SAMPLE_EVERY_MS = 250;
/**
* Mirrors MAX_FILE_BYTES on /api/delivery-notes/upload. Checked here so
* an oversized bundle is caught while the document is still in front of
* the person, rather than as a 400 after the upload.
*/
const MAX_UPLOAD_BYTES = 20 * 1024 * 1024;
/** Well past any real delivery note; a guard against a pocket-pressed button. */
const MAX_PAGES = 12;
type Phase = "camera" | "review" | "sending" | "sent";
interface CapturedPage {
blob: Blob;
url: string;
report: QualityReport;
}
export function DocumentScanner({ sites }: { sites: SitePoint[] }) {
const videoRef = React.useRef<HTMLVideoElement>(null);
const workRef = React.useRef<HTMLCanvasElement>(null);
const fullRef = React.useRef<HTMLCanvasElement>(null);
const [phase, setPhase] = React.useState<Phase>("camera");
const [cameraError, setCameraError] = React.useState<string | null>(null);
const [live, setLive] = React.useState<QualityReport | null>(null);
const [pages, setPages] = React.useState<CapturedPage[]>([]);
const [siteId, setSiteId] = React.useState<string>("");
const [siteHint, setSiteHint] = React.useState<string>("Finding your site…");
const [error, setError] = React.useState<string | null>(null);
// Object URLs are revoked by hand rather than from an effect keyed on
// `pages`: that effect's cleanup runs on every change to the array,
// which would revoke the URL of a page still on screen. Removal and
// a completed send each release their own; the ref exists so unmount
// can release whatever is left without re-running on every capture.
const pagesRef = React.useRef<CapturedPage[]>([]);
pagesRef.current = pages;
const releaseAll = React.useCallback((list: CapturedPage[]) => {
for (const p of list) URL.revokeObjectURL(p.url);
}, []);
React.useEffect(() => () => releaseAll(pagesRef.current), [releaseAll]);
// --- Which site are we on? ------------------------------------------
// Geolocation proposes, the dropdown disposes. A delivery filed
// against the wrong site lands on another site's purchase orders, so
// the guess is never submitted without being visible.
React.useEffect(() => {
if (sites.length === 1) {
setSiteId(sites[0].id);
setSiteHint(sites[0].name);
return;
}
if (!navigator.geolocation) {
setSiteHint("Pick your site");
return;
}
navigator.geolocation.getCurrentPosition(
(pos) => {
const near = nearestSite(sites, {
latitude: pos.coords.latitude,
longitude: pos.coords.longitude,
});
if (!near) {
setSiteHint("Pick your site");
return;
}
setSiteId(near.site.id);
setSiteHint(
near.confident
? `${near.site.name} · ${formatDistance(near.distanceM)}`
: `Nearest is ${near.site.name} · ${formatDistance(near.distanceM)} — check this is right`,
);
},
() => setSiteHint("Location off — pick your site"),
{ enableHighAccuracy: true, timeout: 10_000, maximumAge: 60_000 },
);
}, [sites]);
// --- Live camera + focus sampling ------------------------------------
React.useEffect(() => {
if (phase !== "camera") return;
let stream: MediaStream | null = null;
let timer: ReturnType<typeof setInterval> | undefined;
let cancelled = false;
let wakeLock: WakeLockSentinel | null = null;
function sample() {
const video = videoRef.current;
const work = workRef.current;
if (!video || !work || video.readyState < video.HAVE_CURRENT_DATA) return;
// Measure only what is inside the guide. A sharp gravel yard
// behind a blurred page would otherwise read as a good photo.
const side = Math.min(video.videoWidth, video.videoHeight) * 0.8;
const sx = (video.videoWidth - side) / 2;
const sy = (video.videoHeight - side) / 2;
work.width = ANALYSIS_WIDTH;
work.height = ANALYSIS_WIDTH;
const ctx = work.getContext("2d", { willReadFrequently: true });
if (!ctx) return;
// Scaling to ANALYSIS_WIDTH is not cosmetic: the sharpness
// thresholds are only meaningful at the width they were measured
// at. See lib/image-quality.ts.
ctx.drawImage(video, sx, sy, side, side, 0, 0, ANALYSIS_WIDTH, ANALYSIS_WIDTH);
const { data } = ctx.getImageData(0, 0, ANALYSIS_WIDTH, ANALYSIS_WIDTH);
setLive(
assessGray(
toGrayscale(data, ANALYSIS_WIDTH, ANALYSIS_WIDTH),
ANALYSIS_WIDTH,
ANALYSIS_WIDTH,
),
);
}
async function start() {
if (!navigator.mediaDevices?.getUserMedia) {
setCameraError("This browser cannot open the camera.");
return;
}
try {
stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: { ideal: "environment" },
width: { ideal: IDEAL_WIDTH },
height: { ideal: IDEAL_HEIGHT },
},
});
} catch {
setCameraError("Camera access denied or unavailable.");
return;
}
if (cancelled) {
stream.getTracks().forEach((t) => t.stop());
return;
}
if (videoRef.current) {
videoRef.current.srcObject = stream;
await videoRef.current.play().catch(() => undefined);
}
try {
wakeLock = (await navigator.wakeLock?.request("screen")) ?? null;
} catch {
// Not fatal — the screen may simply dim.
}
timer = setInterval(sample, SAMPLE_EVERY_MS);
}
start();
return () => {
cancelled = true;
if (timer) clearInterval(timer);
wakeLock?.release().catch(() => undefined);
stream?.getTracks().forEach((t) => t.stop());
};
}, [phase]);
function capture() {
const video = videoRef.current;
const full = fullRef.current;
const work = workRef.current;
if (!video || !full || !work) return;
// Keep the whole frame, not the guide crop. The guide is an aiming
// aid; cropping to it would throw away a letterhead or a total that
// sat slightly outside, and the page-level extractor wants the lot.
full.width = video.videoWidth;
full.height = video.videoHeight;
const fctx = full.getContext("2d");
if (!fctx) return;
fctx.drawImage(video, 0, 0);
// Judge the still itself rather than reusing the last live sample —
// the shutter frame can differ from the one measured 250 ms ago,
// and this is the frame that gets uploaded.
const wctx = work.getContext("2d", { willReadFrequently: true });
if (!wctx) return;
const side = Math.min(full.width, full.height) * 0.8;
work.width = ANALYSIS_WIDTH;
work.height = ANALYSIS_WIDTH;
wctx.drawImage(
full,
(full.width - side) / 2, (full.height - side) / 2, side, side,
0, 0, ANALYSIS_WIDTH, ANALYSIS_WIDTH,
);
const { data } = wctx.getImageData(0, 0, ANALYSIS_WIDTH, ANALYSIS_WIDTH);
const report = assessGray(
toGrayscale(data, ANALYSIS_WIDTH, ANALYSIS_WIDTH),
ANALYSIS_WIDTH,
ANALYSIS_WIDTH,
);
full.toBlob(
(blob) => {
if (!blob) return;
setPages((prev) => [...prev, { blob, url: URL.createObjectURL(blob), report }]);
setPhase("review");
navigator.vibrate?.(40);
},
"image/jpeg",
0.92,
);
}
/** Drop a page — a mis-shot, or one taken twice. */
function removePage(index: number) {
setPages((prev) => {
const target = prev[index];
if (target) URL.revokeObjectURL(target.url);
return prev.filter((_, i) => i !== index);
});
setError(null);
}
async function send() {
if (pages.length === 0 || !siteId) return;
setPhase("sending");
setError(null);
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
let file: Blob;
let filename: string;
try {
if (pages.length === 1) {
// One page goes up as its own JPEG: smaller, and it never loads
// the PDF library at all.
file = pages[0].blob;
filename = `site-capture-${stamp}.jpg`;
} else {
const jpegs = await Promise.all(
pages.map(async (p) => new Uint8Array(await p.blob.arrayBuffer())),
);
const { bytes } = await imagesToPdf(jpegs);
file = new Blob([bytes as BlobPart], { type: "application/pdf" });
filename = `site-capture-${stamp}-${pages.length}pages.pdf`;
}
} catch {
setError("Could not put the pages together — try sending fewer");
setPhase("review");
return;
}
if (file.size > MAX_UPLOAD_BYTES) {
setError(
`Too big to send (${Math.round(file.size / 1024 / 1024)} MB). Send these pages, then start a new one.`,
);
setPhase("review");
return;
}
const form = new FormData();
form.append("site", siteId);
form.append("file", file, filename);
try {
const res = await fetch("/api/delivery-notes/upload", { method: "POST", body: form });
const body = await res.json().catch(() => ({}));
if (!res.ok) {
setError(body.error ?? `Upload failed (${res.status})`);
setPhase("review");
return;
}
setPhase("sent");
navigator.vibrate?.([80, 60, 80]);
const sentPages = pages;
setTimeout(() => {
releaseAll(sentPages);
setPages([]);
setLive(null);
setPhase("camera");
}, 2200);
} catch {
setError("No signal — try again when you have a connection");
setPhase("review");
}
}
const justTaken = phase === "review" ? pages[pages.length - 1] : null;
const activeReport = phase === "camera" ? live : justTaken?.report ?? null;
const atPageLimit = pages.length >= MAX_PAGES;
return (
<div className="relative flex flex-1 flex-col overflow-hidden bg-black">
<video
ref={videoRef}
muted
playsInline
className={cn("h-full w-full object-cover", phase !== "camera" && "invisible")}
/>
<canvas ref={workRef} className="hidden" />
<canvas ref={fullRef} className="hidden" />
{phase !== "camera" && justTaken && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={justTaken.url}
alt={`Page ${pages.length} you just photographed`}
className="absolute inset-0 h-full w-full bg-black object-contain"
/>
)}
{cameraError ? (
<div className="absolute inset-0 flex items-center justify-center bg-black/85 p-6 text-center text-white">
{cameraError}
</div>
) : (
<>
{phase === "camera" && <Guide report={live} pageNumber={pages.length + 1} />}
<SiteBar
sites={sites}
siteId={siteId}
hint={siteHint}
onChange={(id) => {
setSiteId(id);
const s = sites.find((x) => x.id === id);
if (s) setSiteHint(s.name);
}}
/>
<div className="absolute inset-x-0 bottom-0 space-y-3 bg-gradient-to-t from-black/90 to-transparent p-5 pt-10">
{pages.length > 0 && phase !== "sent" && (
<PageStrip pages={pages} onRemove={removePage} busy={phase === "sending"} />
)}
{activeReport && phase !== "sent" && (
<p
className={cn(
"text-center text-base font-semibold drop-shadow",
isAcceptable(activeReport) ? "text-emerald-300" : "text-red-300",
)}
>
{activeReport.message}
</p>
)}
{error && (
<p className="text-center text-base font-semibold text-red-300">{error}</p>
)}
{phase === "camera" && (
<div className="flex gap-3">
<button
type="button"
onClick={capture}
disabled={!siteId || atPageLimit}
className="flex h-16 flex-[2] items-center justify-center gap-2 rounded-2xl bg-white text-lg font-bold text-black disabled:opacity-40"
>
<Camera className="h-6 w-6" />
{!siteId
? "Pick your site first"
: atPageLimit
? `${MAX_PAGES} pages is the limit`
: pages.length === 0
? "Take photo"
: `Take page ${pages.length + 1}`}
</button>
{/* Reachable without taking another page — someone who
pressed "Add another page" and then thought better of
it should not have to. */}
{pages.length > 0 && (
<button
type="button"
onClick={send}
className="flex h-16 flex-1 items-center justify-center gap-2 rounded-2xl bg-emerald-500 text-lg font-bold text-white"
>
<Send className="h-5 w-5" />
Send {pages.length}
</button>
)}
</div>
)}
{(phase === "review" || phase === "sending") && (
<div className="space-y-3">
<div className="flex gap-3">
<button
type="button"
onClick={() => {
removePage(pages.length - 1);
setPhase("camera");
}}
disabled={phase === "sending"}
className="flex h-14 flex-1 items-center justify-center gap-2 rounded-2xl bg-white/15 text-base font-bold text-white ring-1 ring-white/25 disabled:opacity-40"
>
<RotateCcw className="h-5 w-5" />
Retake
</button>
<button
type="button"
onClick={() => setPhase("camera")}
disabled={phase === "sending" || atPageLimit}
className="flex h-14 flex-[1.4] items-center justify-center gap-2 rounded-2xl bg-white/15 text-base font-bold text-white ring-1 ring-white/25 disabled:opacity-40"
>
<Plus className="h-5 w-5" />
Add page
</button>
</div>
<button
type="button"
onClick={send}
disabled={phase === "sending"}
className={cn(
"flex h-16 w-full items-center justify-center gap-2 rounded-2xl text-lg font-bold disabled:opacity-60",
// A poor photo can still be sent. The truck does not
// wait, and a soft record of a real delivery beats no
// record of it — the button just stops looking like
// the obvious thing to press.
justTaken && !isAcceptable(justTaken.report)
? "bg-white/25 text-white ring-1 ring-white/30"
: "bg-emerald-500 text-white",
)}
>
{phase === "sending" ? (
<>
<Loader2 className="h-5 w-5 animate-spin" />
Sending {pages.length === 1 ? "photo" : `${pages.length} pages`}
</>
) : (
<>
<Send className="h-5 w-5" />
{justTaken && !isAcceptable(justTaken.report)
? "Send anyway"
: pages.length === 1
? "Send"
: `Send all ${pages.length} pages`}
</>
)}
</button>
</div>
)}
</div>
{phase === "sent" && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-emerald-600/90 p-6 text-center text-white">
<Check className="h-16 w-16" />
<p className="text-2xl font-bold">Sent to the office</p>
<p className="text-base opacity-90">Ready for the next one</p>
</div>
)}
</>
)}
</div>
);
}
/**
* The pages taken so far, oldest first.
*
* Present so nobody has to remember whether they got page 2, and so a
* mis-shot can be thrown away without starting the document again.
* A page that measured poorly is ringed in red, which is the only place
* an earlier page's quality is visible once it scrolls out of review.
*/
function PageStrip({
pages,
onRemove,
busy,
}: {
pages: CapturedPage[];
onRemove: (index: number) => void;
busy: boolean;
}) {
return (
<div className="flex items-center gap-2 overflow-x-auto pb-1">
{pages.map((p, i) => (
<div key={p.url} className="relative shrink-0">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={p.url}
alt={`Page ${i + 1}`}
className={cn(
"h-16 w-12 rounded-md object-cover ring-2",
isAcceptable(p.report) ? "ring-emerald-400/70" : "ring-red-400/80",
)}
/>
<span className="absolute bottom-0 left-0 rounded-br-md rounded-tl-md bg-black/70 px-1 text-[10px] font-bold text-white">
{i + 1}
</span>
{!busy && (
<button
type="button"
onClick={() => onRemove(i)}
aria-label={`Remove page ${i + 1}`}
className="absolute -right-1.5 -top-1.5 flex h-6 w-6 items-center justify-center rounded-full bg-black text-white ring-1 ring-white/40"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
))}
</div>
);
}
/**
* The aiming frame.
*
* Cyan at rest rather than the app's orange: a delivery note is warm
* white, and orange on cream is the one combination that disappears
* exactly where it needs to be seen. Cyan is its opposite on the wheel,
* so it stays visible against paper, cardboard and a dusty bakkie
* bonnet alike.
*
* It then earns red and green by meaning something — red only when the
* photo genuinely would not read, green when it will. A frame that is
* red all the time teaches people that red is just the colour of the
* frame.
*/
function Guide({ report, pageNumber }: { report: QualityReport | null; pageNumber: number }) {
const state = !report ? "idle" : isAcceptable(report) ? "good" : "bad";
const stroke =
state === "good" ? "#22c55e" : state === "bad" ? "#ef4444" : "#22d3ee";
return (
<div aria-hidden className="pointer-events-none absolute inset-0">
{/* Dim everything outside the frame, so the eye goes to the middle.
The huge box-shadow punches a rounded hole without SVG masks —
the same trick the badge scanner uses. */}
<div
className="absolute left-1/2 top-1/2 h-[64vh] max-h-[460px] w-[86vw] max-w-[380px] -translate-x-1/2 -translate-y-1/2 rounded-xl"
style={{ boxShadow: "0 0 0 9999px rgba(0,0,0,0.5)" }}
/>
<div
className="absolute left-1/2 top-1/2 h-[64vh] max-h-[460px] w-[86vw] max-w-[380px] -translate-x-1/2 -translate-y-1/2"
style={{ filter: `drop-shadow(0 0 6px ${stroke}80)` }}
>
{/* Corners only. A full rectangle invites people to line the page
up with it; corners say "inside here" without demanding the
page be a particular shape. */}
{(
[
"-left-1 -top-1 rounded-tl-xl border-l-[6px] border-t-[6px]",
"-right-1 -top-1 rounded-tr-xl border-r-[6px] border-t-[6px]",
"-bottom-1 -left-1 rounded-bl-xl border-b-[6px] border-l-[6px]",
"-bottom-1 -right-1 rounded-br-xl border-b-[6px] border-r-[6px]",
] as const
).map((pos) => (
<div
key={pos}
className={cn("absolute h-12 w-12 transition-colors duration-200", pos)}
style={{ borderColor: stroke }}
/>
))}
</div>
<div className="absolute inset-x-0 top-24 px-6 text-center">
<p className="text-sm font-medium text-white/90 drop-shadow">
{pageNumber === 1
? "Fill the frame with the note — flat, and in good light"
: `Page ${pageNumber} — same document, next sheet`}
</p>
</div>
</div>
);
}
function SiteBar({
sites,
siteId,
hint,
onChange,
}: {
sites: SitePoint[];
siteId: string;
hint: string;
onChange: (id: string) => void;
}) {
return (
<div className="absolute inset-x-0 top-0 flex items-center gap-2 bg-gradient-to-b from-black/80 to-transparent p-4 pb-10">
<MapPin className="h-5 w-5 shrink-0 text-cyan-300" />
<div className="min-w-0 flex-1">
<select
value={siteId}
onChange={(e) => onChange(e.target.value)}
aria-label="Site this delivery arrived at"
className="w-full rounded-lg border border-white/25 bg-black/50 px-2 py-2 text-base font-semibold text-white"
>
<option value="">Pick your site…</option>
{sites.map((s) => (
<option key={s.id} value={s.id} className="text-black">
{s.name}
</option>
))}
</select>
<p className="mt-1 truncate text-xs text-white/70">{hint}</p>
</div>
</div>
);
}