feat(clock): use still photos with required document scan review

This commit is contained in:
DEls 2026-11-06 07:03:06 +02:00
parent 2c97f36ff4
commit 87f966fb89
17 changed files with 701 additions and 505 deletions

View file

@ -0,0 +1,67 @@
# Clock document scan quality implementation plan
> Execute inline with superpowers:executing-plans. The user approved the capture proposal on 6 November 2026; proceed without another approval round.
**Goal:** Require boundary confirmation and readable review for every photo, preserve originals, and prevent poor capture quality from silently passing as readable.
**Architecture:** Extend the existing client scanner and crop editor. Package visible scan pages and embedded original-photo attachments in one deterministic PDF, using existing upload/dedup endpoints and source storage. Persist poor-quality override metadata in PDF subject and turn it into a human review question during staged ingestion.
**Tech:** React 19, current Next client/route guidance, canvas, pdf-lib, existing quality/crop helpers, Vitest/Testing Library.
**Spec:** Approved proposal in this chat; investigation at foreman/runs/dn-inbox-review/2026-11-06-06-16-scanner-investigation.md.
## Constraints and decisions
- No inbox matching remediation, live business-data mutations, deployment, schema changes, or new dependencies.
- Retain the current product visual language and large phone controls. Every photo needs crop confirmation and Use scan; poor scans additionally need an explicit reason.
- Native still-photo input only. At 06:55 SAST the user explicitly removed the live video preview and video-stream capture from delivery-note and PO scanning; video stays in QR scanners. Never represent an undetected whole-scene sharpness score as a good document.
- Conservative grayscale illumination correction is optional and reversible. No reconstructed digits, hard thresholding, or generated enhancement.
- Gallery images use the same capture review; PDFs retain the existing document-upload path. Do not mix PDFs and photos in one selection.
- Original attachments are not additional rendered pages and must not become separate inbox documents. Identical reviewed content produces identical PDF bytes for retry dedup.
- Work in the current folder on codex/clock-scan-quality, preserving existing unrelated edits. User authorization to proceed covers routine branching; do not relocate their active workspace.
## Review focus
- Detection fails on a cluttered desk: require boundaries, no green quality reassurance.
- Earlier bad/unconfirmed pages in a bundle cannot be bypassed by Send from camera.
- Still-photo input opens the native camera; delivery/PO screens do not call getUserMedia or mount a video element. QR scanning remains unchanged.
- Crop/enhancement/retake changes invalidate page acceptance and release obsolete URLs without revoking original evidence.
- Upload retry and duplicate detection preserve complete, deterministic packages and quality review decisions.
## Task 1: Document quality and enhancement
Files: src/lib/document-scan-quality.ts(.test.ts), src/lib/document-photo.ts.
- [x] Add failing tests for missing boundaries, small/far pages, insufficient pixels, blank/faint body and preserving faint grayscale detail.
- [x] Implement document geometry/body quality assessment and reversible local illumination correction. Existing image-quality API remains compatible.
- [x] Run focused quality/crop tests.
## Task 2: Evidence package and ingestion review
Files: src/lib/scan-pdf.ts(.test.ts), src/lib/scan-metadata.ts, src/lib/dn-pipeline/normalise-source.ts(.test.ts), src/lib/dn-pipeline/run-staged.ts(.test.ts).
- [x] Add failing tests: original attachments survive byte-for-byte without adding visible pages; identical content produces stable bytes; page-specific override survives normalization and creates a persistent, answerable site question.
- [x] Implement package creation and bounded metadata validation. Preserve up to 3200-pixel scan detail through normalization. Keep answered site decisions on rerun.
- [x] Run package and staged pipeline tests.
## Task 3: Camera, required crop, review and gallery
Files: src/app/clock/document-scanner.tsx(.test.tsx), src/app/clock/document-crop-editor.tsx, src/app/clock/document-scan-review.tsx, src/app/clock/documents-view.tsx.
- [x] Add tests for native still-photo input and absence of video capture, required crop/acceptance, override requirements, gallery/photo routing and all-page send gates.
- [x] Implement native photo capture (no video preview), post-photo boundary editing/guidance, sequential photo review, image zoom, enhancement choice, explicit poor-quality reason, deterministic package upload. Keep file/PDF flow explicit.
- [x] Verify interactions and upload retry with mocked camera/canvas/upload boundaries, without posting real records.
## Task 4: Verification and review
- [x] Run typecheck, targeted lint, focused tests and whole Vitest suite; report unrelated failures by name.
- [x] Exercise phone-size UI with synthetic inputs; validate the actual Chamberlain sample locally when available. Do not upload it again.
- [x] Obtain a final code review, address material findings, and commit only this task's files. Record device/deployment limitations.
## Final verification
- 4,081 tests passed across 461 files; production Next build and typecheck passed. Changed scanner client files lint clean. Existing unused MIME parameter warning remains in normalization.
- Independent reviewer’s three P2 findings were reproduced and fixed, with regression coverage.
- 390 × 844 crop/review/zoom and 320 × 667 no-video/native-photo UI checked in an isolated preview.
- Actual Chamberlain original: no automatic boundary; after manual crop the approximately 879 × 897-pixel slip is flagged for distance/resolution, with Use scan disabled until an office-review reason is chosen. Nothing uploaded.
- No business records, email workflow, QR scanners, or existing inbox edits changed. No deployment or physical-device camera test performed.

View file

@ -25,7 +25,7 @@ export function DocumentCropEditor({ url, initialCorners, busy, error, onApply,
<Dialog open onOpenChange={open => { if (!open && !busy) onClose(); }}>
<DialogContent className="max-w-lg" onEscapeKeyDown={event => { if (busy) event.preventDefault(); }} onPointerDownOutside={event => { if (busy) event.preventDefault(); }}>
<DialogHeader>
<DialogTitle>Adjust paper edges</DialogTitle>
<DialogTitle>Confirm paper edges</DialogTitle>
<DialogDescription>Drag each corner onto the paper&apos;s corner. Include the heading and totals. You can also select a corner and use the arrow keys.</DialogDescription>
</DialogHeader>
<div className="relative mx-auto max-h-[52dvh] max-w-full" style={{ width: `min(100%, ${52 * size.width / size.height}dvh)`, aspectRatio: `${size.width} / ${size.height}` }}>

View file

@ -0,0 +1,55 @@
"use client";
import * as React from "react";
import { ZoomIn } from "lucide-react";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import type { PaperCorners } from "@/lib/document-crop";
import type { ScanQuality } from "@/lib/document-scan-quality";
export interface ScanPage {
id: string; blob: Blob; url: string; original: Blob; originalUrl: string; originalName: string;
corners: PaperCorners | null; cropConfirmed: boolean; accepted: boolean;
quality: ScanQuality; mode: "colour" | "print"; qualityOverride: string;
}
export function DocumentScanReview({ page, busy, onAdjust, onMode, onOverride }: {
page: ScanPage; busy: boolean; onAdjust: () => void; onMode: (mode: ScanPage["mode"]) => void; onOverride: (reason: string) => void;
}) {
const [original, setOriginal] = React.useState(false);
const [zoom, setZoom] = React.useState(false);
const [scale, setScale] = React.useState(1);
const url = original ? page.originalUrl : page.url;
return <div className="flex min-h-[22rem] flex-1 flex-col">
<button type="button" aria-label="Zoom document preview" onClick={() => { setZoom(true); setScale(1); }} className="relative mx-4 min-h-40 flex-1 overflow-hidden rounded-lg bg-zinc-900 focus-visible:outline-2 focus-visible:outline-cyan-300">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={url} alt={original ? "Unedited original photo" : "Cropped document scan"} className="h-full w-full object-contain" />
<span className="absolute bottom-2 right-2 flex items-center gap-1 rounded-md bg-zinc-950/90 px-2 py-1 text-xs text-white"><ZoomIn className="h-4 w-4" />Check small print</span>
</button>
<div className="space-y-2 px-4 pt-2 text-sm text-white">
<div className="flex items-center justify-between gap-2">
<button type="button" disabled={busy} onClick={onAdjust} className="min-h-11 px-1 font-semibold text-cyan-300 disabled:opacity-40">{page.cropConfirmed ? "Adjust edges" : "Set paper edges"}</button>
<button type="button" disabled={busy} onClick={() => setOriginal(!original)} className="min-h-11 px-1 text-white/80">{original ? "Show scan" : "Compare original"}</button>
</div>
<div role="group" aria-label="Scan appearance" className="flex gap-1 rounded-lg bg-zinc-900 p-1">
{([['colour', 'Colour'], ['print', 'Clearer print']] as const).map(([mode, label]) => <button key={mode} type="button" aria-pressed={page.mode === mode} disabled={busy || !page.cropConfirmed} onClick={() => { setOriginal(false); onMode(mode); }} className={`min-h-10 flex-1 rounded-md px-2 text-sm font-medium disabled:opacity-40 ${page.mode === mode ? "bg-white text-zinc-950" : "text-white/75"}`}>{label}</button>)}
</div>
<p role="status" className={`text-xs leading-relaxed ${!page.cropConfirmed || !page.quality.ok ? "text-amber-200" : "text-white/80"}`}>{!page.cropConfirmed ? "Confirm all four paper edges before using this scan." : page.quality.message}</p>
{page.cropConfirmed && !page.quality.ok && <label className="block space-y-1 text-xs text-amber-100">
<span>Cannot get a clearer photo? Send for office review:</span>
<select aria-label="Reason for sending a poor scan" value={page.qualityOverride} disabled={busy} onChange={event => onOverride(event.target.value)} className="h-11 w-full rounded-lg border border-amber-300/40 bg-zinc-900 px-2 text-base text-white">
<option value="">Choose a reason, or retake the photo</option>
<option value="Original paper is faint or damaged">Original paper is faint or damaged</option>
<option value="Cannot retake now; office must verify the original">Cannot retake now; office must verify the original</option>
</select>
</label>}
</div>
<Dialog open={zoom} onOpenChange={setZoom}>
<DialogContent className="flex max-h-[94dvh] max-w-4xl flex-col p-4">
<DialogHeader><DialogTitle>Check the small print</DialogTitle><DialogDescription>Zoom and scroll to check the supplier, document number, quantities and totals.</DialogDescription></DialogHeader>
<label className="flex items-center gap-3 text-sm">Zoom<input aria-label="Document zoom" type="range" min="1" max="4" step=".25" value={scale} onChange={event => setScale(Number(event.target.value))} className="flex-1" /><span>{Math.round(scale * 100)}%</span></label>
<div className="min-h-0 flex-1 overflow-auto rounded-lg bg-zinc-900" style={{ height: "65dvh" }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={url} alt="Document detail" style={{ width: `${scale * 100}%`, maxWidth: "none" }} />
</div>
</DialogContent>
</Dialog>
</div>;
}

View file

@ -0,0 +1,118 @@
// @vitest-environment jsdom
import * as React from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { DocumentScanner } from "./document-scanner";
const { upload, quality, loadPhoto } = vi.hoisted(() => ({ upload: vi.fn<(...args: unknown[]) => Promise<{ duplicate: boolean }>>(async () => ({ duplicate: false })), quality: vi.fn(() => ({ ok: true, issues: [] as string[], message: "Check the print" })), loadPhoto: vi.fn(async () => ({ width: 2200, height: 3000 })) }));
vi.mock("./clock-documents", async original => ({ ...await original<typeof import("./clock-documents")>(), uploadClockDocument: upload }));
vi.mock("@/lib/document-photo", () => ({
loadDocumentPhoto: loadPhoto,
findDocumentCorners: () => [{ x: .02, y: .02 }, { x: .98, y: .02 }, { x: .98, y: .98 }, { x: .02, y: .98 }],
cropDocumentPhoto: (source: unknown) => source,
measureDocumentPhoto: () => ({ sharpness: 300, brightness: 220, verdict: "good", message: "Looks sharp" }),
inspectDocumentPhoto: quality,
enhanceDocumentPhoto: (source: unknown) => source,
photoBlob: async () => new Blob(["jpeg"], { type: "image/jpeg" }),
}));
vi.mock("@/lib/scan-pdf", () => ({ scansToPdf: async () => new Uint8Array([37, 80, 68, 70]) }));
beforeEach(() => {
upload.mockClear();
quality.mockReturnValue({ ok: true, issues: [], message: "Check the print" });
loadPhoto.mockReset().mockResolvedValue({ width: 2200, height: 3000 });
URL.createObjectURL = vi.fn(() => `blob:${Math.random()}`); URL.revokeObjectURL = vi.fn();
vi.stubGlobal("ResizeObserver", class { observe() {} disconnect() {} });
vi.stubGlobal("matchMedia", () => ({ matches: false, addEventListener() {}, removeEventListener() {} }));
Object.defineProperty(navigator, "mediaDevices", { configurable: true, value: { getUserMedia: vi.fn(async () => { throw new Error("No camera"); }) } });
});
afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
const sites = [{ id: "site1", name: "Test site", projectCode: "TEST", latitude: 0, longitude: 0 }];
async function pickPhoto(label = "Take document photo") {
fireEvent.change(screen.getByLabelText(label), { target: { files: [new File(["original"], "phone.jpg", { type: "image/jpeg" })] } });
await screen.findByRole("dialog", { name: "Confirm paper edges" });
}
describe("reviewing Clock document photos", () => {
it("uses only the native still-photo input and never opens a video stream", () => {
const { container } = render(<DocumentScanner sites={sites} />);
const input = screen.getByLabelText("Take document photo") as HTMLInputElement;
expect(input.getAttribute("capture")).toBe("environment");
expect(input.accept).not.toContain("video");
expect(container.querySelector("video")).toBeNull();
expect(navigator.mediaDevices.getUserMedia).not.toHaveBeenCalled();
});
it("can retry a failed middle gallery photo and continue the remaining selection", async () => {
render(<DocumentScanner sites={sites} />);
const files = ["A.jpg", "B.jpg", "C.jpg"].map(name => new File([name], name, { type: "image/jpeg" }));
fireEvent.change(screen.getByLabelText("Choose document photos or files"), { target: { files } });
await screen.findByRole("dialog", { name: "Confirm paper edges" });
fireEvent.click(screen.getByRole("button", { name: "Apply crop" }));
const accept = await screen.findByRole("button", { name: "Use scan" });
loadPhoto.mockRejectedValueOnce(new Error("Photo B cannot be decoded"));
fireEvent.click(accept);
const retry = await screen.findByRole("button", { name: /Retry photo/ });
fireEvent.click(retry);
await screen.findByRole("dialog", { name: "Confirm paper edges" });
fireEvent.click(screen.getByRole("button", { name: "Apply crop" }));
fireEvent.click(await screen.findByRole("button", { name: "Use scan" }));
await screen.findByRole("dialog", { name: "Confirm paper edges" });
fireEvent.click(screen.getByRole("button", { name: "Apply crop" }));
fireEvent.click(await screen.findByRole("button", { name: "Use scan" }));
expect((screen.getByRole("button", { name: "Send 3 checked pages" }) as HTMLButtonElement).disabled).toBe(false);
});
it("requires paper edges and explicit scan acceptance before upload", async () => {
render(<DocumentScanner sites={sites} />);
await pickPhoto();
expect(upload).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Apply crop" }));
await screen.findByRole("button", { name: "Use scan" });
expect(screen.queryByRole("button", { name: /Send/ })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Use scan" }));
fireEvent.click(screen.getByRole("button", { name: /Send/ }));
await waitFor(() => expect(upload).toHaveBeenCalledTimes(1));
expect(upload.mock.calls[0]).toEqual(expect.arrayContaining(["delivery", "site1", expect.any(Blob), expect.stringMatching(/\.pdf$/)]));
});
it("puts selected gallery photos through the same required crop flow", async () => {
render(<DocumentScanner sites={sites} />);
await pickPhoto("Choose document photos or files");
expect(upload).not.toHaveBeenCalled();
expect(screen.queryByRole("button", { name: "Upload" })).toBeNull();
});
it("requires a reason for a poor photo and invalidates acceptance after editing", async () => {
quality.mockReturnValue({ ok: false, issues: ["faint"], message: "The print is faint" });
render(<DocumentScanner sites={sites} />);
await pickPhoto();
fireEvent.click(screen.getByRole("button", { name: "Apply crop" }));
const use = await screen.findByRole("button", { name: "Use scan for office review" });
expect((use as HTMLButtonElement).disabled).toBe(true);
fireEvent.change(screen.getByLabelText("Reason for sending a poor scan"), { target: { value: "Original paper is faint or damaged" } });
fireEvent.click(use);
expect((screen.getByRole("button", { name: /Send/ }) as HTMLButtonElement).disabled).toBe(false);
fireEvent.click(screen.getByRole("button", { name: "Colour" }));
await waitFor(() => expect(screen.queryByRole("button", { name: /Send/ })).toBeNull());
expect((screen.getByLabelText("Reason for sending a poor scan") as HTMLSelectElement).value).toBe("");
});
it("cannot send an unchecked later page from the camera screen", async () => {
render(<DocumentScanner sites={sites} />);
await pickPhoto();
fireEvent.click(screen.getByRole("button", { name: "Apply crop" }));
fireEvent.click(await screen.findByRole("button", { name: "Use scan" }));
fireEvent.click(screen.getByRole("button", { name: "Add page" }));
await pickPhoto();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
fireEvent.click(screen.getByRole("button", { name: "Add page" }));
expect((screen.getByRole("button", { name: /Send/ }) as HTMLButtonElement).disabled).toBe(true);
expect(upload).not.toHaveBeenCalled();
});
it("keeps the accepted scan on an upload failure for retry", async () => {
upload.mockRejectedValueOnce(new Error("No connection"));
render(<DocumentScanner sites={sites} />);
await pickPhoto();
fireEvent.click(screen.getByRole("button", { name: "Apply crop" }));
fireEvent.click(await screen.findByRole("button", { name: "Use scan" }));
fireEvent.click(screen.getByRole("button", { name: /Send/ }));
await screen.findByRole("alert");
expect(screen.getByRole("alert").textContent).toBe("No connection");
fireEvent.click(screen.getByRole("button", { name: /Send/ }));
await waitFor(() => expect(upload).toHaveBeenCalledTimes(2));
expect(upload.mock.calls[1][3]).toBe(upload.mock.calls[0][3]);
});
});

View file

@ -1,536 +1,198 @@
"use client";
import * as React from "react";
import { Camera, Check, Loader2, MapPin, Plus, RotateCcw, Send, X, Upload, Trash2 } from "lucide-react";
import {
ANALYSIS_WIDTH,
assessGray,
toGrayscale,
isAcceptable,
type QualityReport,
} from "@/lib/image-quality";
import {
formatDistance,
nearestSite,
type SitePoint,
} from "@/lib/nearest-site";
import { imagesToPdf } from "@/lib/photo-pdf";
import { ScannerFrame } from "./scanner-chrome";
import { Camera, Check, FileText, Loader2, MapPin, Plus, RotateCcw, Send, Trash2, Upload, X } from "lucide-react";
import { formatDistance, nearestSite, type SitePoint } from "@/lib/nearest-site";
import { fullImageCorners, type PaperCorners } from "@/lib/document-crop";
import { cropDocumentPhoto, enhanceDocumentPhoto, findDocumentCorners, inspectDocumentPhoto, loadDocumentPhoto, photoBlob } from "@/lib/document-photo";
import { scansToPdf } from "@/lib/scan-pdf";
import { DocumentFileDialog } from "./document-file-dialog";
import { DOCUMENT_ACCEPT, DOCUMENT_LABELS, uploadClockDocument, type DocumentKind } from "./clock-documents";
import { cn } from "@/lib/utils";
import { fullImageCorners, type PaperCorners } from "@/lib/document-crop";
import { cropDocumentPhoto, findDocumentCorners, loadDocumentPhoto, measureDocumentPhoto, photoBlob } from "@/lib/document-photo";
import { DocumentCropEditor } from "./document-crop-editor";
import { DocumentScanReview, type ScanPage } from "./document-scan-review";
/**
* Photograph a delivery note or purchase order 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 = 750;
/**
* 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;
const MAX_UPLOAD_BYTES = 20 * 1024 * 1024;
type Phase = "camera" | "review" | "sending" | "sent";
interface CapturedPage {
blob: Blob;
url: string;
report: QualityReport;
original: Blob;
originalUrl: string;
corners: PaperCorners | null;
cropStatus: "auto" | "manual" | "original" | "unclear";
}
const releasePage = (page: ScanPage) => { URL.revokeObjectURL(page.url); URL.revokeObjectURL(page.originalUrl); };
export function DocumentScanner({ sites, kind = "delivery", initialSiteId = "", onSiteChange, onBusyChange }: {
sites: SitePoint[]; kind?: DocumentKind; initialSiteId?: string; onSiteChange?: (siteId: string) => void; onBusyChange?: (busy: boolean) => void;
}) {
const siteChosenRef = React.useRef(Boolean(initialSiteId));
const sendBusyRef = React.useRef(false);
const [capturing, setCapturing] = React.useState(false);
const captureBusyRef = React.useRef(false);
const mountedRef = React.useRef(false);
const [editingPage, setEditingPage] = React.useState<CapturedPage | null>(null);
const [cropBusy, setCropBusy] = React.useState(false);
const cropBusyRef = React.useRef(false);
const [cropError, setCropError] = React.useState<string | null>(null);
const fileInputRef = React.useRef<HTMLInputElement>(null);
const cameraInputRef = React.useRef<HTMLInputElement>(null);
const resetTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const [files, setFiles] = React.useState<File[]>([]);
const [sentDuplicate, setSentDuplicate] = React.useState(false);
const label = DOCUMENT_LABELS[kind];
const color = kind === "delivery" ? "#22d3ee" : "#a78bfa";
const videoRef = React.useRef<HTMLVideoElement>(null);
const workRef = React.useRef<HTMLCanvasElement>(null);
const fullRef = React.useRef<HTMLCanvasElement>(null);
const cameraInputRef = React.useRef<HTMLInputElement>(null);
const fileInputRef = React.useRef<HTMLInputElement>(null);
const mountedRef = React.useRef(false);
const busyRef = React.useRef(false);
const sendBusyRef = React.useRef(false);
const siteChosenRef = React.useRef(Boolean(initialSiteId));
const resetTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const pagesRef = React.useRef<ScanPage[]>([]);
const [pages, setPages] = React.useState<ScanPage[]>([]);
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 [reviewIndex, setReviewIndex] = React.useState<number | null>(null);
const [retakeIndex, setRetakeIndex] = React.useState<number | null>(null);
const [siteId, setSiteId] = React.useState<string>(() => initialSiteId || (sites.length === 1 ? sites[0].id : ""));
const [siteHint, setSiteHint] = React.useState<string>(() => sites.find(site => site.id === initialSiteId)?.name ?? (sites.length === 1 ? sites[0].name : "Finding your site…"));
const [busy, setBusy] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [selectedId, setSelectedId] = React.useState<string | null>(null);
const [retakeId, setRetakeId] = React.useState<string | null>(null);
const [editingId, setEditingId] = React.useState<string | null>(null);
const [photoQueue, setPhotoQueue] = React.useState<File[]>([]);
const [queueFailed, setQueueFailed] = React.useState(false);
const [pdfFiles, setPdfFiles] = React.useState<File[]>([]);
const [sentDuplicate, setSentDuplicate] = React.useState(false);
const [siteId, setSiteId] = React.useState(initialSiteId || (sites.length === 1 ? sites[0].id : ""));
const [siteHint, setSiteHint] = React.useState(sites.find(s => s.id === initialSiteId)?.name ?? (sites.length === 1 ? sites[0].name : "Finding your site…"));
const selected = pages.find(page => page.id === selectedId) ?? pages[pages.length - 1];
const editing = pages.find(page => page.id === editingId);
const canSend = pages.length > 0 && pages.every(page => page.cropConfirmed && page.accepted && (page.quality.ok || Boolean(page.qualityOverride)));
const locked = busy || phase === "sending" || phase === "sent";
function updatePages(change: (previous: ScanPage[]) => ScanPage[]) { pagesRef.current = change(pagesRef.current); setPages(pagesRef.current); }
function patchPage(id: string, patch: Partial<ScanPage>) { updatePages(previous => previous.map(page => page.id === id ? { ...page, ...patch } : page)); }
React.useEffect(() => {
onBusyChange?.(capturing || pages.length > 0 || files.length > 0 || phase === "sending");
}, [capturing, pages.length, files.length, phase, onBusyChange]);
React.useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
if (resetTimerRef.current) clearTimeout(resetTimerRef.current);
};
return () => { mountedRef.current = false; if (resetTimer.current) clearTimeout(resetTimer.current); pagesRef.current.forEach(releasePage); };
}, []);
React.useEffect(() => { onSiteChange?.(siteId); }, [siteId, onSiteChange]);
// 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[]>([]);
React.useLayoutEffect(() => { pagesRef.current = pages; }, [pages]);
const releaseAll = React.useCallback((list: CapturedPage[]) => {
for (const p of list) { URL.revokeObjectURL(p.url); URL.revokeObjectURL(p.originalUrl); }
}, []);
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(() => { onBusyChange?.(busy || pages.length > 0 || pdfFiles.length > 0 || phase === "sending"); }, [busy, pages.length, pdfFiles.length, phase, onBusyChange]);
React.useEffect(() => {
if (siteChosenRef.current || sites.length === 1) {
return;
}
if (!navigator.geolocation) return;
navigator.geolocation.getCurrentPosition(
(pos) => {
if (siteChosenRef.current) return;
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`,
);
},
() => { if (!siteChosenRef.current) setSiteHint("Location off — pick your site"); },
{ enableHighAccuracy: true, timeout: 10_000, maximumAge: 60_000 },
);
if (siteChosenRef.current || sites.length === 1 || !navigator.geolocation) return;
navigator.geolocation.getCurrentPosition(position => {
if (siteChosenRef.current || !mountedRef.current) return;
const near = nearestSite(sites, { latitude: position.coords.latitude, longitude: position.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}. Check this is right.`);
}, () => { if (mountedRef.current && !siteChosenRef.current) setSiteHint("Location off. Pick your site."); }, { enableHighAccuracy: true, timeout: 10_000, maximumAge: 60_000 });
}, [sites]);
// --- Live camera + focus sampling ------------------------------------
React.useEffect(() => {
if (phase !== "camera" || files.length > 0) 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;
// Find the page across the full camera image, including outside the guide.
work.width = 320;
work.height = Math.max(2, Math.round(video.videoHeight * 320 / video.videoWidth));
const ctx = work.getContext("2d", { willReadFrequently: true });
if (!ctx) return;
ctx.drawImage(video, 0, 0, work.width, work.height);
const corners = findDocumentCorners(work);
// Sample the detected page directly from the full-resolution video.
// Enlarging the 320px detection image would invent a blur warning.
const left = corners ? Math.min(...corners.map(p => p.x)) : 0;
const top = corners ? Math.min(...corners.map(p => p.y)) : 0;
const width = (corners ? Math.max(...corners.map(p => p.x)) - left : 1) * video.videoWidth;
const height = (corners ? Math.max(...corners.map(p => p.y)) - top : 1) * video.videoHeight;
work.width = ANALYSIS_WIDTH;
work.height = Math.max(2, Math.round(height * ANALYSIS_WIDTH / width));
ctx.drawImage(video, left * video.videoWidth, top * video.videoHeight, width, height, 0, 0, work.width, work.height);
setLive(assessGray(toGrayscale(ctx.getImageData(0, 0, work.width, work.height).data, work.width, work.height), work.width, work.height));
}
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, files.length]);
async function capture() {
const video = videoRef.current;
const full = fullRef.current;
if (!video || !full || captureBusyRef.current || (pages.length >= MAX_PAGES && retakeIndex === null) || video.readyState < video.HAVE_CURRENT_DATA) return;
const fctx = full.getContext("2d", { willReadFrequently: true });
if (!fctx) return;
captureBusyRef.current = true;
setCapturing(true);
setError(null);
async function addPhoto(original: Blob, originalName: string, replacingId: string | null = null) {
if (busyRef.current || sendBusyRef.current) return false;
busyRef.current = true; setBusy(true); setError(null);
try {
// Freeze the WHOLE camera image. The guide never limits detection.
full.width = video.videoWidth;
full.height = video.videoHeight;
fctx.drawImage(video, 0, 0);
await keepCapturedPhoto(full);
} catch (captureError) {
setError(captureError instanceof Error ? captureError.message : "Could not capture the photo — try again.");
} finally {
captureBusyRef.current = false;
setCapturing(false);
}
if (original.size > MAX_UPLOAD_BYTES) throw new Error("Choose a photo smaller than 20 MB.");
const source = await loadDocumentPhoto(original);
const corners = findDocumentCorners(source);
const cropped = corners ? cropDocumentPhoto(source, corners) : source;
const blob = await photoBlob(cropped);
if (!mountedRef.current) return;
const quality = inspectDocumentPhoto(source, corners, cropped);
const page: ScanPage = { id: crypto.randomUUID(), original, originalName, originalUrl: URL.createObjectURL(original), blob, url: URL.createObjectURL(blob), corners, cropConfirmed: false, accepted: false, mode: "print", qualityOverride: "", quality };
const replacing = pagesRef.current.find(item => item.id === replacingId);
updatePages(previous => replacing ? previous.map(item => item.id === replacingId ? page : item) : [...previous, page]);
if (replacing) releasePage(replacing);
setSelectedId(page.id); setEditingId(page.id); setRetakeId(null); setPhase("review");
navigator.vibrate?.(30);
return true;
} catch (failure) { if (mountedRef.current) setError(`Could not open ${originalName}. ${failure instanceof Error ? failure.message : "Try again."}`); return false; }
finally { busyRef.current = false; if (mountedRef.current) setBusy(false); }
}
async function keepCapturedPhoto(source: HTMLCanvasElement) {
const corners = findDocumentCorners(source);
const output = corners ? cropDocumentPhoto(source, corners) : source;
const report = measureDocumentPhoto(output);
const original = await photoBlob(source);
const blob = corners ? await photoBlob(output) : original;
if (!mountedRef.current) return;
const page: CapturedPage = {
blob, url: URL.createObjectURL(blob), report, original,
originalUrl: URL.createObjectURL(original), corners, cropStatus: corners ? "auto" : "unclear",
};
const replacing = retakeIndex === null ? null : pagesRef.current[retakeIndex];
if (replacing) releaseAll([replacing]);
setPages(previous => replacing ? previous.map(item => item === replacing ? page : item) : [...previous, page]);
setReviewIndex(replacing ? retakeIndex : null);
setRetakeIndex(null);
setPhase("review");
navigator.vibrate?.(40);
}
async function captureNativePhoto(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = "";
if (!file || captureBusyRef.current || (pages.length >= MAX_PAGES && retakeIndex === null)) return;
captureBusyRef.current = true;
setCapturing(true);
setError(null);
try {
await keepCapturedPhoto(await loadDocumentPhoto(file));
} catch (failure) {
setError(failure instanceof Error ? failure.message : "Could not open the photo — try again.");
} finally {
captureBusyRef.current = false;
setCapturing(false);
}
}
async function updateCrop(page: CapturedPage, corners: PaperCorners | null) {
if (cropBusyRef.current) return;
cropBusyRef.current = true;
setCropBusy(true);
setCropError(null);
async function renderPage(page: ScanPage, corners: PaperCorners, mode: ScanPage["mode"]) {
if (busyRef.current || sendBusyRef.current) return;
busyRef.current = true; setBusy(true); setError(null);
try {
const source = await loadDocumentPhoto(page.original);
const output = corners ? cropDocumentPhoto(source, corners) : source;
const report = measureDocumentPhoto(output);
const blob = corners ? await photoBlob(output) : page.original;
if (!mountedRef.current || !pagesRef.current.includes(page)) return;
const replacement: CapturedPage = { ...page, blob, url: URL.createObjectURL(blob), report, corners, cropStatus: corners ? "manual" : "original" };
setPages(previous => previous.map(item => item === page ? replacement : item));
URL.revokeObjectURL(page.url);
setEditingPage(null);
setError(null);
} catch (cropFailure) {
const message = cropFailure instanceof Error ? cropFailure.message : "Could not crop the photo — try again.";
setCropError(message);
setError(message);
} finally {
cropBusyRef.current = false;
setCropBusy(false);
}
const cropped = cropDocumentPhoto(source, corners);
const quality = inspectDocumentPhoto(source, corners, cropped);
const output = mode === "print" ? enhanceDocumentPhoto(cropped) : cropped;
const blob = await photoBlob(output);
if (!mountedRef.current || !pagesRef.current.some(item => item.id === page.id)) return;
const url = URL.createObjectURL(blob);
patchPage(page.id, { blob, url, corners, mode, quality, cropConfirmed: true, accepted: false, qualityOverride: "" });
URL.revokeObjectURL(page.url); setEditingId(null);
} catch (failure) { if (mountedRef.current) setError(failure instanceof Error ? failure.message : "Could not process the scan. Try again."); }
finally { busyRef.current = false; if (mountedRef.current) setBusy(false); }
}
/** 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); URL.revokeObjectURL(target.originalUrl); }
return prev.filter((_, i) => i !== index);
});
setError(null);
setReviewIndex(null);
setRetakeIndex(null);
if (pages.length === 1) setPhase("camera");
function useScan() {
if (!selected?.cropConfirmed || (!selected.quality.ok && !selected.qualityOverride) || locked) return;
patchPage(selected.id, { accepted: true });
if (photoQueue.length) void importQueued(photoQueue);
}
async function send() {
if (pages.length === 0 || !siteId || sendBusyRef.current || cropBusyRef.current || captureBusyRef.current || editingPage) return;
sendBusyRef.current = true;
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 {
sendBusyRef.current = false;
setError("Could not put the pages together — try sending fewer");
setPhase("review");
return;
}
if (file.size > MAX_UPLOAD_BYTES) {
sendBusyRef.current = false;
setError(
`Too big to send (${Math.round(file.size / 1024 / 1024)} MB). Send these pages, then start a new one.`,
);
setPhase("review");
return;
}
try {
const result = await uploadClockDocument(kind, siteId, file, filename);
setSentDuplicate(result.duplicate);
setPhase("sent");
navigator.vibrate?.([80, 60, 80]);
const sentPages = pages;
resetTimerRef.current = setTimeout(() => {
releaseAll(sentPages);
setPages([]);
setLive(null);
sendBusyRef.current = false;
setPhase("camera");
}, 2200);
} catch (uploadError) {
sendBusyRef.current = false;
setError(uploadError instanceof Error ? uploadError.message : "No signal — try again when you have a connection");
setPhase("review");
}
async function importQueued(queue: File[]) {
if (!queue.length || busyRef.current || sendBusyRef.current) return;
setPhotoQueue(queue); setQueueFailed(false);
const [next, ...rest] = queue;
const added = await addPhoto(next, next.name);
if (!mountedRef.current) return;
setPhotoQueue(added ? rest : queue); setQueueFailed(!added);
}
const reviewingIndex = reviewIndex ?? pages.length - 1;
const justTaken = phase === "review" ? pages[reviewingIndex] : null;
const activeReport = phase === "camera" ? live : justTaken?.report ?? null;
const atPageLimit = pages.length >= MAX_PAGES && retakeIndex === null;
function skipQueuedPhoto() {
const [, ...rest] = photoQueue;
setPhotoQueue(rest); setQueueFailed(false); setError(null);
if (rest.length) void importQueued(rest);
}
function removePage(id: string) {
if (locked) return;
const page = pagesRef.current.find(item => item.id === id);
if (page) releasePage(page);
updatePages(previous => previous.filter(item => item.id !== id));
setSelectedId(null); setEditingId(null); setRetakeId(null); setPhotoQueue([]); setQueueFailed(false); setError(null);
if (!pagesRef.current.length) setPhase("camera");
}
function discard() { pagesRef.current.forEach(releasePage); updatePages(() => []); setSelectedId(null); setEditingId(null); setRetakeId(null); setPhotoQueue([]); setQueueFailed(false); setError(null); setPhase("camera"); }
function pickFiles(event: React.ChangeEvent<HTMLInputElement>) {
const picked = Array.from(event.target.files ?? []);
if (picked.length > 20) {
setError("Choose up to 20 files at a time.");
} else {
setFiles(picked);
setError(null);
const picked = Array.from(event.target.files ?? []); event.target.value = "";
if (!picked.length || locked) return;
const pdfs = picked.filter(file => file.type === "application/pdf");
if (pdfs.length && pdfs.length !== picked.length) { setError("Choose photos together, or PDFs separately."); return; }
if (pdfs.length) {
if (pages.length) { setError("Send or discard your scan before choosing a PDF."); return; }
if (pdfs.length > 20) { setError("Choose up to 20 PDFs at a time."); return; }
setPdfFiles(pdfs); return;
}
event.target.value = "";
if (picked.some(file => !["image/jpeg", "image/png", "image/webp"].includes(file.type))) { setError("Choose JPG, PNG or WebP photos, or a PDF."); return; }
if (pages.length + picked.length > MAX_PAGES) { setError("A scan can have up to 12 pages. Send this document first."); return; }
void importQueued(picked);
}
function discard() {
releaseAll(pages);
setPages([]); setReviewIndex(null); setRetakeIndex(null); setError(null); setPhase("camera");
async function send() {
// Recheck every page here; neither camera mode nor an earlier page can bypass review.
if (busyRef.current || sendBusyRef.current || editingId || photoQueue.length || !siteId || !pagesRef.current.length || pagesRef.current.some(page => !page.cropConfirmed || !page.accepted || (!page.quality.ok && !page.qualityOverride))) return;
sendBusyRef.current = true; setPhase("sending"); setError(null);
try {
const packaged = await scansToPdf(await Promise.all(pagesRef.current.map(async page => ({ jpeg: new Uint8Array(await page.blob.arrayBuffer()), original: new Uint8Array(await page.original.arrayBuffer()), originalName: page.originalName, originalMime: page.original.type || "image/jpeg", qualityOverride: page.quality.ok ? "" : `${page.qualityOverride}. Warnings: ${page.quality.issues.join(", ")}` }))));
if (packaged.byteLength > MAX_UPLOAD_BYTES) throw new Error("The scan and original photos exceed 20 MB. Remove some pages and send them in another document.");
const result = await uploadClockDocument(kind, siteId, new Blob([packaged as BlobPart], { type: "application/pdf" }), `site-scan-${pagesRef.current[0].id}.pdf`);
if (!mountedRef.current) return;
setSentDuplicate(result.duplicate); setPhase("sent"); navigator.vibrate?.([80, 60, 80]);
resetTimer.current = setTimeout(() => { discard(); sendBusyRef.current = false; }, 2200);
} catch (failure) { sendBusyRef.current = false; if (mountedRef.current) { setPhase("review"); setError(failure instanceof Error ? failure.message : "Could not send. Your scan is kept here; try again."); } }
}
return (
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden bg-black">
<video ref={videoRef} muted playsInline className={cn("absolute inset-0 h-full w-full object-cover", phase !== "camera" && "invisible")} />
<canvas ref={workRef} className="hidden" /><canvas ref={fullRef} className="hidden" />
<input ref={fileInputRef} type="file" accept={DOCUMENT_ACCEPT} multiple onChange={pickFiles} className="hidden" aria-label="Choose document photos or files" />
<input ref={cameraInputRef} type="file" accept="image/jpeg,image/png,image/webp" capture="environment" onChange={event => void captureNativePhoto(event)} className="hidden" aria-label="Take document photo" />
<div className="relative flex min-h-0 flex-1 flex-col">
<SiteBar sites={sites} siteId={siteId} hint={siteHint} label={label} disabled={phase === "sending" || phase === "sent"} onChange={id => {
siteChosenRef.current = true; setSiteId(id); const site = sites.find(item => item.id === id); if (site) setSiteHint(site.name);
}} />
<div className="shrink-0 px-5 pb-2 text-center">
<p className="text-xs font-semibold uppercase tracking-wide" style={{ color }}>{label}{pages.length > 0 ? ` · ${pages.length} page${pages.length === 1 ? "" : "s"}` : ""}</p>
<p className="mt-1 text-sm leading-relaxed text-white/85">{phase === "camera" ? retakeIndex !== null ? `Photograph page ${retakeIndex + 1} again` : pages.length ? `Photograph page ${pages.length + 1} of the same document` : "Keep all paper edges visible. The guide helps you aim; we crop to the paper." : "Check the crop includes the whole page, heading and totals"}</p>
</div>
<div className="relative min-h-0 flex-1 px-5">
{phase === "camera" ? <ScannerFrame color={live && !cameraError ? isAcceptable(live) ? "#34d399" : "#f87171" : color} shape="document" error={cameraError ? `${cameraError} Take a photo with your phone camera or choose a file below.` : null} /> : justTaken && (
// eslint-disable-next-line @next/next/no-img-element
<img src={justTaken.url} alt={`Page ${reviewingIndex + 1} preview`} className="h-full w-full rounded-xl object-contain" />
)}
</div>
<div className="shrink-0 space-y-2 bg-black/75 px-5 pb-4 pt-3">
{pages.length > 0 && phase !== "sent" && <PageStrip pages={pages} onSelect={index => { setReviewIndex(index); setRetakeIndex(null); setPhase("review"); }} onRemove={removePage} busy={phase === "sending" || cropBusy || capturing} />}
{activeReport && phase !== "sent" && <p className={cn("text-center text-xs font-medium", isAcceptable(activeReport) ? "text-emerald-300" : "text-red-300")}>{activeReport.message}</p>}
{error && <p role="alert" className="text-center text-sm text-red-300">{error}</p>}
{phase === "camera" && <>
<div className="flex gap-2">
<button type="button" onClick={() => cameraError ? cameraInputRef.current?.click() : void capture()} disabled={!siteId || atPageLimit || files.length > 0 || capturing} className="flex h-12 flex-1 items-center justify-center gap-2 rounded-xl bg-white px-3 text-sm font-semibold text-black disabled:opacity-40">
<Camera className="h-4 w-4 shrink-0" />{capturing ? "Finding paper edges…" : retakeIndex !== null ? `Retake page ${retakeIndex + 1}` : pages.length ? `Take page ${pages.length + 1}` : "Take photo"}
</button>
{pages.length > 0 ? <button type="button" onClick={() => { if (retakeIndex !== null) { setReviewIndex(retakeIndex); setRetakeIndex(null); setPhase("review"); } else void send(); }} disabled={!siteId || capturing} className="flex h-12 flex-1 items-center justify-center gap-2 rounded-xl bg-emerald-500 px-3 text-sm font-semibold text-white disabled:opacity-40"><Send className="h-4 w-4" />{retakeIndex !== null ? "Cancel retake" : `Send ${pages.length} pages`}</button> : <button type="button" onClick={() => fileInputRef.current?.click()} disabled={capturing} className="flex h-12 flex-1 items-center justify-center gap-2 rounded-xl bg-white/10 px-3 text-sm font-semibold text-white ring-1 ring-white/25"><Upload className="h-4 w-4 shrink-0" />Choose files</button>}
</div>
{!siteId && <p className="text-center text-xs text-white/65">Choose a site above to take a photo.</p>}
</>}
{(phase === "review" || phase === "sending") && <>
{justTaken && <>
<p className="text-center text-xs text-white/75">{justTaken.cropStatus === "original" ? "Original photo restored" : justTaken.corners ? "Cropped to paper — check all edges" : "Paper edges unclear — adjust crop or keep the original"}</p>
<div className="flex justify-center gap-4">
<button type="button" disabled={phase === "sending" || cropBusy} onClick={() => { setCropError(null); setEditingPage(justTaken); }} className="min-h-11 text-sm font-medium text-cyan-300 disabled:opacity-40">Adjust crop</button>
{justTaken.corners && <button type="button" disabled={phase === "sending" || cropBusy} onClick={() => void updateCrop(justTaken, null)} className="min-h-11 text-sm text-white/75 disabled:opacity-40">Use original</button>}
</div>
</>}
<div className="flex gap-2">
<button type="button" onClick={() => { setRetakeIndex(reviewingIndex); setPhase("camera"); }} disabled={phase === "sending" || cropBusy} className="flex h-11 flex-1 items-center justify-center gap-2 rounded-xl bg-white/10 text-sm font-semibold text-white disabled:opacity-40"><RotateCcw className="h-4 w-4" />Retake</button>
<button type="button" onClick={() => setPhase("camera")} disabled={phase === "sending" || cropBusy || atPageLimit} className="flex h-11 flex-1 items-center justify-center gap-2 rounded-xl bg-white/10 text-sm font-semibold text-white disabled:opacity-40"><Plus className="h-4 w-4" />Add page</button>
</div>
<button type="button" onClick={() => void send()} disabled={phase === "sending" || cropBusy || !pages.length || !siteId} className="flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-emerald-500 px-3 text-sm font-semibold text-white disabled:opacity-40">
{phase === "sending" ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}{phase === "sending" ? "Sending…" : justTaken && !isAcceptable(justTaken.report) ? "Send anyway" : `Send ${pages.length === 1 ? label.toLowerCase() : `all ${pages.length} pages`}`}
</button>
</>}
{pages.length > 0 && phase !== "sending" && phase !== "sent" && <button type="button" disabled={cropBusy || capturing} onClick={discard} className="flex min-h-9 w-full items-center justify-center gap-1.5 text-xs text-white/60"><Trash2 className="h-3.5 w-3.5" />Discard document</button>}
</div>
{phase === "sent" && <div role="status" className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-emerald-700/95 p-6 text-center text-white">
<Check className="h-12 w-12" /><p className="text-xl font-bold">{sentDuplicate ? "Already uploaded" : `${label} sent to the office`}</p><p className="text-sm">{sentDuplicate ? "The existing document will be used." : "Queued for processing. Ready for the next document."}</p>
</div>}
function changeSite(id: string) { siteChosenRef.current = true; setSiteId(id); setSiteHint(sites.find(site => site.id === id)?.name ?? "Pick your site"); }
const sendButton = <button type="button" onClick={() => void send()} disabled={locked || !canSend || !siteId || photoQueue.length > 0} className="flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-emerald-500 px-3 text-sm font-semibold text-zinc-950 disabled:opacity-40">{phase === "sending" ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}{phase === "sending" ? "Sending…" : `Send ${pages.length} checked page${pages.length === 1 ? "" : "s"}`}</button>;
return <div className="relative flex min-h-0 flex-1 flex-col overflow-x-hidden overflow-y-auto bg-zinc-950 text-white">
<input ref={fileInputRef} type="file" accept={DOCUMENT_ACCEPT} multiple onChange={pickFiles} className="hidden" aria-label="Choose document photos or files" />
<input ref={cameraInputRef} type="file" accept="image/jpeg,image/png,image/webp" capture="environment" onChange={event => { const file = event.target.files?.[0]; event.target.value = ""; if (file && !locked && (pages.length < MAX_PAGES || retakeId)) void addPhoto(file, file.name, retakeId); }} className="hidden" aria-label="Take document photo" />
{pages.length > 0 ? <div className="flex min-h-10 shrink-0 items-center justify-center gap-2 px-4 text-xs text-white/65"><MapPin className="h-3.5 w-3.5" />{sites.find(site => site.id === siteId)?.name ?? siteHint}</div> : <SiteBar sites={sites} siteId={siteId} hint={siteHint} label={label} disabled={locked} onChange={changeSite} />}
<div className="shrink-0 px-4 pb-2 text-center"><p className="text-xs font-semibold uppercase tracking-wide text-cyan-300">{label}{pages.length ? ` · page ${Math.max(0, pages.indexOf(selected)) + 1} of ${pages.length}` : ""}</p><p className="mt-1 text-sm text-white/85">{phase === "camera" ? "One page at a time. Move close and keep every edge visible." : "Check the print. Tap the scan to zoom."}</p></div>
{phase === "camera" ? <div className="flex min-h-52 flex-1 flex-col items-center justify-center gap-4 px-6 py-4 text-center">
<div aria-hidden className="relative flex aspect-[3/4] max-h-56 w-36 items-center justify-center rounded-lg border border-white/20 bg-zinc-900">
<FileText className="h-16 w-16 text-white/45" strokeWidth={1} />
<span className="absolute -left-px -top-px h-6 w-6 rounded-tl-lg border-l-2 border-t-2 border-cyan-300" /><span className="absolute -right-px -top-px h-6 w-6 rounded-tr-lg border-r-2 border-t-2 border-cyan-300" />
<span className="absolute -bottom-px -left-px h-6 w-6 rounded-bl-lg border-b-2 border-l-2 border-cyan-300" /><span className="absolute -bottom-px -right-px h-6 w-6 rounded-br-lg border-b-2 border-r-2 border-cyan-300" />
</div>
{editingPage && <DocumentCropEditor url={editingPage.originalUrl} initialCorners={editingPage.corners ?? fullImageCorners()} busy={cropBusy} error={cropError} onApply={corners => void updateCrop(editingPage, corners)} onClose={() => setEditingPage(null)} />}
{files.length > 0 && <DocumentFileDialog files={files} kind={kind} sites={sites} siteId={siteId} onSiteChange={id => { siteChosenRef.current = true; setSiteId(id); setSiteHint(sites.find(site => site.id === id)?.name ?? "Choose a site"); }} onClose={() => setFiles([])} />}
<p className="max-w-xs text-sm leading-relaxed text-white/75">Lay the paper flat in even light. Fill the photo with one page, keeping all four edges visible.</p>
<p className="max-w-xs text-xs leading-relaxed text-cyan-200">After the photo, adjust the paper edges and check the small print.</p>
</div> : selected && <DocumentScanReview key={`review-${selected.id}`} page={selected} busy={locked} onAdjust={() => { setError(null); setEditingId(selected.id); }} onMode={mode => selected.corners && void renderPage(selected, selected.corners, mode)} onOverride={qualityOverride => patchPage(selected.id, { qualityOverride, accepted: false })} />}
<div className="shrink-0 space-y-2 bg-zinc-950 px-4 pb-3 pt-2">
{pages.length > 1 && phase !== "sent" && <div className="flex gap-2 overflow-x-auto pb-1" aria-label="Document pages">{pages.map((page, index) => <div key={page.id} className="relative shrink-0"><button type="button" aria-label={`Review page ${index + 1}${page.accepted ? ", checked" : ", needs checking"}`} disabled={locked} onClick={() => { setSelectedId(page.id); setRetakeId(null); setPhase("review"); }} className={`h-11 rounded-lg border px-3 text-sm ${page.accepted ? "border-emerald-400 text-emerald-200" : "border-amber-300 text-amber-200"}`}>{index + 1} {page.accepted ? "✓" : "Check"}</button><button type="button" aria-label={`Remove page ${index + 1}`} disabled={locked} onClick={() => removePage(page.id)} className="ml-1 inline-flex h-11 w-8 items-center justify-center text-white/60"><X className="h-4 w-4" /></button></div>)}</div>}
{error && <p role="alert" className="text-sm leading-relaxed text-red-200">{error}</p>}
{queueFailed && photoQueue.length > 0 && <div className="flex gap-2"><button type="button" disabled={locked} onClick={() => void importQueued(photoQueue)} className="min-h-11 flex-1 rounded-lg bg-amber-200 px-2 text-sm font-semibold text-zinc-950 disabled:opacity-40">Retry photo</button><button type="button" disabled={locked} onClick={skipQueuedPhoto} className="min-h-11 flex-1 rounded-lg border border-white/30 px-2 text-sm disabled:opacity-40">Skip this photo</button></div>}
{phase === "camera" ? <>
<button type="button" disabled={locked || !siteId || (pages.length >= MAX_PAGES && !retakeId)} onClick={() => cameraInputRef.current?.click()} className="flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-white text-sm font-semibold text-zinc-950 disabled:opacity-40"><Camera className="h-4 w-4" />{busy ? "Opening photo…" : retakeId ? "Retake page" : "Take photo"}</button>
<button type="button" disabled={locked} onClick={() => fileInputRef.current?.click()} className="flex min-h-11 w-full items-center justify-center gap-2 rounded-lg bg-white/10 text-sm disabled:opacity-40"><Upload className="h-4 w-4" />Choose photos or PDFs</button>
{!siteId && <p className="text-center text-xs text-white/65">Choose your site above to take a photo.</p>}
{pages.length > 0 && <>{sendButton}<button type="button" disabled={locked} onClick={() => { setRetakeId(null); setPhase("review"); }} className="min-h-10 w-full text-sm text-white/75">Back to scan</button></>}
{!pages.length && <p className="text-center text-xs text-white/55">Selected photos become pages of one document. Choose PDFs separately.</p>}
</> : phase !== "sent" && <>
{selected && !selected.accepted && <button type="button" disabled={locked || !selected.cropConfirmed || (!selected.quality.ok && !selected.qualityOverride)} onClick={useScan} className="h-12 w-full rounded-xl bg-white px-3 text-sm font-semibold text-zinc-950 disabled:opacity-40">{busy ? "Processing scan…" : selected.quality.ok ? "Use scan" : "Use scan for office review"}</button>}
<div className="flex gap-2"><button type="button" disabled={locked} onClick={() => { setRetakeId(selected?.id ?? null); setPhase("camera"); }} className="flex min-h-11 flex-1 items-center justify-center gap-2 rounded-lg bg-white/10 text-sm disabled:opacity-40"><RotateCcw className="h-4 w-4" />Retake</button><button type="button" disabled={locked || pages.length >= MAX_PAGES || photoQueue.length > 0} onClick={() => { setRetakeId(null); setPhase("camera"); }} className="flex min-h-11 flex-1 items-center justify-center gap-2 rounded-lg bg-white/10 text-sm disabled:opacity-40"><Plus className="h-4 w-4" />Add page</button></div>
{selected?.accepted && sendButton}
</>}
{pages.length > 0 && phase !== "sent" && <button type="button" disabled={locked} onClick={discard} className="flex min-h-9 w-full items-center justify-center gap-2 text-xs text-white/60 disabled:opacity-40"><Trash2 className="h-3.5 w-3.5" />Discard document</button>}
</div>
);
{phase === "sent" && <div role="status" className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-emerald-800 p-6 text-center text-white"><Check className="h-12 w-12" /><p className="text-xl font-bold">{sentDuplicate ? "Already uploaded" : `${label} sent to the office`}</p><p className="text-sm">{sentDuplicate ? "The existing document will be used." : "Scan and original photos received. Ready for the next document."}</p></div>}
{editing && <DocumentCropEditor key={`crop-${editing.id}`} url={editing.originalUrl} initialCorners={editing.corners ?? fullImageCorners()} busy={busy} error={error} onApply={corners => void renderPage(editing, corners, editing.mode)} onClose={() => setEditingId(null)} />}
{pdfFiles.length > 0 && <DocumentFileDialog files={pdfFiles} kind={kind} sites={sites} siteId={siteId} onSiteChange={changeSite} onClose={() => setPdfFiles([])} />}
</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,
onSelect,
busy,
}: {
pages: CapturedPage[];
onRemove: (index: number) => void;
onSelect: (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">
<button type="button" aria-label={`Review page ${i + 1}`} disabled={busy} onClick={() => onSelect(i)} className="block rounded-md focus-visible:outline-2 focus-visible:outline-white">
{/* 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",
)}
/>
</button>
<span className="pointer-events-none 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>
);
}
function SiteBar({
sites,
siteId,

View file

@ -8,6 +8,6 @@ it("opens Documents on delivery notes with both photo and file actions", () => {
const selected = html.match(/<button[^>]*aria-pressed="true"[^>]*>[\s\S]*?<\/button>/)?.[0];
expect(selected).toContain("Delivery notes");
expect(html).toContain("Take photo");
expect(html).toContain("Choose files");
expect(html).toContain("Choose photos or PDFs");
expect(html).toContain("Site for this delivery note");
});

View file

@ -21,7 +21,7 @@ export function DocumentsView({ sites }: { sites: SitePoint[] }) {
</button>
))}
</div>
{busy && <p className="mt-2 text-center text-xs text-white/60">Send or discard the current document before changing its type.</p>}
{busy && <p className="sr-only" role="status">Send or discard the current document before changing its type.</p>}
</div>
<DocumentScanner key={kind} sites={sites} kind={kind} initialSiteId={siteId} onSiteChange={setSiteId} onBusyChange={setBusy} />
</div>

View file

@ -2,8 +2,23 @@ import { describe, expect, it } from "vitest";
import sharp from "sharp";
import { PDFDocument } from "pdf-lib";
import { normaliseSource } from "./normalise-source";
import { scansToPdf } from "../scan-pdf";
import { createRequire } from "node:module";
describe("normaliseSource", () => {
it("still renders owner-protected PDFs that the metadata parser cannot open", async () => {
const PDFKit = createRequire(import.meta.url)("@foliojs-fork/pdfkit");
const input = await new Promise<Buffer>((resolve, reject) => {
const doc = new PDFKit({ size: [100, 100], margin: 5, ownerPassword: "owner-only", userPassword: "" });
const chunks: Buffer[] = [];
doc.on("data", (chunk: Buffer) => chunks.push(chunk));
doc.on("end", () => resolve(Buffer.concat(chunks))); doc.on("error", reject);
doc.fontSize(8).text("Readable PDF", 5, 10); doc.end();
});
const pages = await normaliseSource(input, "application/pdf");
expect(pages).toHaveLength(1);
expect(pages[0].width).toBeGreaterThan(100);
});
it("returns a real PNG for JPEG input regardless of declared MIME", async () => {
const jpeg = await sharp({
create: { width: 80, height: 120, channels: 3, background: "white" },
@ -49,12 +64,20 @@ describe("normaliseSource", () => {
normaliseSource(large, "image/png"),
normaliseSource(small, "image/png"),
]);
expect(largePage.width).toBe(2200);
expect(largePage.height).toBe(1320);
expect(largePage.width).toBe(3200);
expect(largePage.height).toBe(1920);
expect(smallPage.width).toBe(80);
expect(smallPage.height).toBe(120);
});
it("keeps scan pixels and per-page quality warnings without rendering original attachments", async () => {
const jpeg = await sharp({ create: { width: 800, height: 1600, channels: 3, background: "white" } }).jpeg().toBuffer();
const pdf = await scansToPdf([{ jpeg, original: jpeg, originalName: "original.jpg", originalMime: "image/jpeg", qualityOverride: "Paper too faint to retake" }]);
const pages = await normaliseSource(Buffer.from(pdf), "application/pdf");
expect(pages).toHaveLength(1);
expect(pages[0]).toMatchObject({ width: 800, height: 1600, captureReview: "Paper too faint to retake" });
});
it("applies EXIF orientation and reports the applied rotation", async () => {
const jpeg = await sharp({
create: { width: 80, height: 120, channels: 3, background: "white" },

View file

@ -4,9 +4,11 @@ import { join } from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import sharp, { type Metadata } from "sharp";
import { PDFDocument } from "pdf-lib";
import { parseScanMetadata, type ScanMetadata } from "../scan-metadata";
const run = promisify(execFile);
const MAX_DIMENSION = 2200;
const MAX_DIMENSION = 3200;
const PDF_MAGIC = Buffer.from("%PDF-");
export interface NormalisedPage {
@ -16,6 +18,8 @@ export interface NormalisedPage {
width: number;
height: number;
rotation: number;
/** Explicit photographer override, retained for an answerable site review. */
captureReview?: string;
}
function isPdf(buffer: Buffer): boolean {
@ -87,13 +91,23 @@ async function renderPdf(buffer: Buffer): Promise<NormalisedPage[]> {
try {
const path = join(dir, "in.pdf");
await writeFile(path, buffer);
await run("pdftoppm", ["-r", "150", "-png", path, join(dir, "pg")]);
let scan: ScanMetadata | null = null;
try {
const pdf = await PDFDocument.load(buffer, { updateMetadata: false });
const metadata = parseScanMetadata(pdf.getSubject());
if (metadata?.pages.length === pdf.getPageCount()) scan = metadata;
} catch { /* Metadata is optional; Poppler can render PDFs this parser cannot read. */ }
await run("pdftoppm", ["-r", scan ? "300" : "150", "-png", path, join(dir, "pg")]);
const files = (await readdir(dir))
.filter((file) => /^pg-\d+\.png$/.test(file))
.sort((a, b) => Number(a.match(/\d+/)?.[0]) - Number(b.match(/\d+/)?.[0]));
if (files.length === 0) throw new Error("pdftoppm produced no pages");
return Promise.all(
files.map((file, index) => readFile(join(dir, file)).then((page) => rasterise(page, index + 1))),
files.map(async (file, index) => {
const page = await rasterise(await readFile(join(dir, file)), index + 1);
const captureReview = scan?.pages[index]?.qualityOverride;
return captureReview ? { ...page, captureReview } : page;
}),
);
} catch (error) {
if (error instanceof Error && error.message === "unsupported_image_codec") throw error;

View file

@ -154,6 +154,43 @@ beforeEach(() => {
afterEach(() => vi.unstubAllEnvs());
describe("runStagedBatch", () => {
it("turns a poor-scan override into an answerable review question and keeps its answer on rerun", async () => {
const fixture = stagedPb();
normaliseSourceMock.mockResolvedValue([{ sourcePage: 1, png: Buffer.from("scan"), mime: "image/png", width: 800, height: 1600, rotation: 0, captureReview: "Original paper is faint" }]);
extractPageMock.mockResolvedValue(read(1, "DN-SCAN", "PO-1"));
await runStagedBatch(fixture.pb as never, BATCH_ID);
const candidate = [...fixture.candidates.values()][0];
const question = (candidate.checks as Array<{ id: string; passed: boolean; detail?: { source?: string } }>).find(check => check.id === "site_decision" && check.detail?.source === "clock-scan");
expect(question).toMatchObject({ passed: false });
expect(candidate.stage).toBe("needs_review");
question!.passed = true;
await runStagedBatch(fixture.pb as never, BATCH_ID);
const decisions = (candidate.checks as Array<{ id: string; passed: boolean }>).filter(check => check.id === "site_decision");
expect(decisions).toHaveLength(1);
expect(decisions[0].passed).toBe(true);
});
it("asks about a newly recovered poor page even when the first page warning was answered", async () => {
const fixture = stagedPb();
const firstImage = { sourcePage: 1, png: Buffer.from("scan1"), mime: "image/png", width: 800, height: 1600, rotation: 0, captureReview: "Faint paper" };
normaliseSourceMock.mockResolvedValue([firstImage]);
extractPageMock.mockImplementation(async (_image: Buffer, page: number) => ({ ...read(page, "DN-SCAN", "PO-1"), printed_page_number: page, printed_page_count: 2, is_continuation: page === 2 }));
await runStagedBatch(fixture.pb as never, BATCH_ID);
const candidate = [...fixture.candidates.values()][0];
const firstQuestion = (candidate.checks as Array<{ id: string; passed: boolean }>).find(check => check.id === "site_decision")!;
firstQuestion.passed = true;
normaliseSourceMock.mockResolvedValue([firstImage, { ...firstImage, sourcePage: 2, png: Buffer.from("scan2"), captureReview: "Cannot retake page 2" }]);
await runStagedBatch(fixture.pb as never, BATCH_ID);
const questions = (candidate.checks as Array<{ id: string; passed: boolean; detail?: { pages?: number[] } }>).filter(check => check.id === "site_decision");
expect(questions.some(check => check.passed === true)).toBe(true);
expect(questions.some(check => check.passed === false && check.detail?.pages?.includes(2))).toBe(true);
// The answer API replaces earlier site questions with the latest answer.
// Its accumulated source-page coverage must still settle the first page.
const latest = questions.at(-1)!;
latest.passed = true;
candidate.checks = [...(candidate.checks as Array<{ id: string }>).filter(check => check.id !== "site_decision"), latest];
await runStagedBatch(fixture.pb as never, BATCH_ID);
expect((candidate.checks as Array<{ id: string; passed: boolean }>).filter(check => check.id === "site_decision")).toEqual([expect.objectContaining({ passed: true })]);
});
it.each(["product_matched", "units_convertible"])("never treats a null required check as ready: %s", (id) => {
expect(evaluateRequiredChecks([
...["po_resolved", "supplier_matches_po", "product_matched", "units_convertible", "not_duplicate"].map((checkId) => ({ id: checkId, passed: checkId === id ? null : true })),

View file

@ -94,6 +94,8 @@ async function runClaimedStagedBatch(pb: PocketBase, batchId: string, ownership:
const reads: PageRead[] = [];
const failedPages: Array<{ page: number; reason: string }> = [];
const pageDeliverySessionIds: Record<number, string> = {};
const captureReviews = new Map<number, string>();
const captureReviewKeys = new Map<number, string>();
const persistedPages = new Map<number, PersistedPage>();
let pageCount = 0;
let pageOffset = 0;
@ -116,6 +118,7 @@ async function runClaimedStagedBatch(pb: PocketBase, batchId: string, ownership:
for (const image of normalised) {
assertStagedDeadline(options.deadlineAt);
const page = pageOffset + image.sourcePage;
if (image.captureReview) { captureReviews.set(page, image.captureReview); captureReviewKeys.set(page, `${source.id}:${image.sourcePage}`); }
pageCount += 1;
if (source.delivery_session_id) pageDeliverySessionIds[page] = source.delivery_session_id;
try {
@ -259,6 +262,18 @@ async function runClaimedStagedBatch(pb: PocketBase, batchId: string, ownership:
supplierGuardOverride: supplierGuardOverride(matched?.manual_overrides),
};
const noteChecks = runNoteChecks(note, checkContext);
const poorPages = group.pages.filter(page => captureReviews.has(page));
const coveredSourcePages = new Set<string>(siteDecisionChecks(matched?.checks).flatMap(check => {
const detail = check.detail as { source?: string; sourcePages?: unknown } | undefined;
return detail?.source === "clock-scan" && Array.isArray(detail.sourcePages) ? detail.sourcePages.filter((key): key is string => typeof key === "string") : [];
}));
const newPoorPages = poorPages.filter(page => !coveredSourcePages.has(captureReviewKeys.get(page)!));
if (newPoorPages.length) {
const pendingQuestion = openSiteDecision(matched?.checks)?.question;
const warning = `This scan was sent with a photo-quality warning: ${newPoorPages.map(page => `page ${page}: ${captureReviews.get(page)}`).join("; ")}. Please compare the scan and original attachment, confirm the supplier, document reference and delivered quantities, or upload a clearer copy.`;
const question = pendingQuestion ? `${pendingQuestion}\nAlso check the newly added scan evidence: ${warning}` : warning;
noteChecks.push({ id: "site_decision", passed: false, reason: question, detail: { source: "clock-scan", question, askedAt: new Date().toISOString(), askedBy: "Clock scanner", pages: newPoorPages, sourcePages: [...new Set([...coveredSourcePages, ...poorPages.map(page => captureReviewKeys.get(page)!)])] } });
}
if (proposalNeedsReview) noteChecks.push({id:"page_grouping_confirmed",passed:false,reason:"Confirm this batch’s proposed grouping before processing or posting its documents."});
if (poReviewQuestion) { const check = noteChecks.find(check=>check.id==="po_resolved"); if (check && check.passed===false) check.reason=poReviewQuestion; }
if (note.doc_type === "unknown") noteChecks.push({id:"document_type",passed:false,
@ -658,7 +673,7 @@ async function upsertCandidate(
purchase_order: resolution.purchaseOrderId ?? "",
supplier: resolution.supplierId ?? "",
items: noteItems,
checks: [...checks, ...siteDecisions],
checks: [...checks.filter(check => check.id !== "site_decision"), ...siteDecisions.filter(check => !checks.some(next => next.id === "site_decision") || check.passed === true), ...checks.filter(check => check.id === "site_decision")],
...(checks.some(check=>check.id === "page_grouping_confirmed" && check.passed===false) ? { reconciliation_preview: null } : {}),
assembly_revision: assemblyRevision,
match_method: resolution.method,

View file

@ -1,5 +1,6 @@
import { detectPaperCorners, rectifyDocument, type PaperCorners } from "./document-crop";
import { ANALYSIS_WIDTH, assessGray, toGrayscale } from "./image-quality";
import { assessDocumentScan, documentBodyQuality, enhanceDocumentPixels } from "./document-scan-quality";
function canvas(width: number, height: number) {
const result = document.createElement("canvas");
@ -32,6 +33,23 @@ export function cropDocumentPhoto(source: HTMLCanvasElement, corners: PaperCorne
target.putImageData(pixels, 0, 0);
return output;
}
export function inspectDocumentPhoto(source: HTMLCanvasElement, corners: PaperCorners | null, cropped = source) {
const work = canvas(ANALYSIS_WIDTH, Math.max(2, Math.round(cropped.height * ANALYSIS_WIDTH / cropped.width)));
const ctx = work.getContext("2d", { willReadFrequently: true });
if (!ctx) throw new Error("Could not check the document. Try again.");
ctx.drawImage(cropped, 0, 0, work.width, work.height);
const gray = toGrayscale(ctx.getImageData(0, 0, work.width, work.height).data, work.width, work.height);
return assessDocumentScan({ report: assessGray(gray, work.width, work.height), corners, width: source.width, height: source.height, body: documentBodyQuality(gray, work.width, work.height) });
}
export function enhanceDocumentPhoto(source: HTMLCanvasElement) {
const ctx = source.getContext("2d", { willReadFrequently: true });
if (!ctx) throw new Error("Could not enhance the document. Try again.");
const pixels = ctx.getImageData(0, 0, source.width, source.height);
pixels.data.set(enhanceDocumentPixels(pixels.data, source.width, source.height));
const output = canvas(source.width, source.height);
output.getContext("2d")!.putImageData(pixels, 0, 0);
return output;
}
export function photoBlob(source: HTMLCanvasElement): Promise<Blob> {
return new Promise((resolve, reject) => source.toBlob(blob => {
if (blob) resolve(blob); else reject(new Error("Could not capture the photo — try again."));

View file

@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { assessDocumentScan, documentBodyQuality, enhanceDocumentPixels } from "./document-scan-quality";
const report = { sharpness: 175, brightness: 117, verdict: "good" as const, message: "Looks sharp" };
const corners = [{ x: .05, y: .05 }, { x: .95, y: .05 }, { x: .95, y: .95 }, { x: .05, y: .95 }] as const;
describe("document-specific scan quality", () => {
it("does not call a sharp desk scene readable when no paper was found", () => {
const result = assessDocumentScan({ report, corners: null, width: 1440, height: 2560 });
expect(result.ok).toBe(false);
expect(result.issues).toContain("edges");
});
it("asks the photographer to move closer when a small document occupies a large frame", () => {
const result = assessDocumentScan({ report, width: 3000, height: 4000, corners: corners.map(p => ({ x: p.x * .4, y: p.y * .4 })) });
expect(result.ok).toBe(false);
expect(result.issues).toContain("distance");
});
it("rejects too few document pixels even in a tightly framed image", () => {
expect(assessDocumentScan({ report, corners, width: 500, height: 750 }).issues).toContain("resolution");
});
it("permits a sufficiently large sharp document", () => {
expect(assessDocumentScan({ report, corners, width: 2200, height: 3000, body: { sharpness: 200, contrast: 90 } }).ok).toBe(true);
});
it("does not let a sharp logo conceal a faded document body", () => {
const result = assessDocumentScan({ report, corners, width: 2200, height: 3000, body: { sharpness: 10, contrast: 8 } });
expect(result.ok).toBe(false);
expect(result.issues).toContain("faint");
});
it("measures the body without treating a dark border as text", () => {
const gray = new Uint8ClampedArray(100 * 100).fill(240);
gray.fill(0, 0, 1000);
expect(documentBodyQuality(gray, 100, 100).contrast).toBe(0);
});
});
describe("conservative print enhancement", () => {
it("lifts shaded paper while preserving relative faint marks and alpha", () => {
const pixels = new Uint8ClampedArray(64 * 64 * 4);
for (let i = 0; i < pixels.length; i += 4) pixels.set([180, 180, 180, 255], i);
pixels.set([150, 150, 150, 255], (32 * 64 + 32) * 4);
const result = enhanceDocumentPixels(pixels, 64, 64);
expect(result[0]).toBeGreaterThan(180);
const mark = (32 * 64 + 32) * 4;
expect(result[mark]).toBeLessThan(result[0] - 20);
expect(result[mark]).toBeGreaterThan(0);
expect(result[mark + 3]).toBe(255);
expect(pixels[0]).toBe(180);
});
});

View file

@ -0,0 +1,76 @@
import { laplacianVariance, type QualityReport } from "./image-quality";
import type { PaperPoint } from "./document-crop";
export interface ScanQuality { ok: boolean; message: string; issues: string[] }
export interface BodyQuality { sharpness: number; contrast: number }
/** Never interpret sharp background furniture as proof of readable paper. */
export function assessDocumentScan({ report, corners, width, height, body }: {
report: QualityReport; corners: readonly PaperPoint[] | null; width: number; height: number; body?: BodyQuality;
}): ScanQuality {
const issues: string[] = [];
const messages: string[] = [];
const add = (id: string, message: string) => { issues.push(id); messages.push(message); };
if (!corners || corners.length !== 4) return { ok: false, issues: ["edges"], message: "Find the paper edges, or set them after taking the photo." };
const area = Math.abs(corners.reduce((sum, p, i) => { const q = corners[(i + 1) % 4]; return sum + p.x * q.y - q.x * p.y; }, 0)) / 2;
if (area < .45) add("distance", "Move closer so the paper fills the picture.");
const edge = (a: number, b: number) => Math.hypot((corners[a].x - corners[b].x) * width, (corners[a].y - corners[b].y) * height);
const paperWidth = Math.min(edge(0, 1), edge(3, 2));
const paperHeight = Math.min(edge(0, 3), edge(1, 2));
if (Math.min(paperWidth, paperHeight) < 700 || Math.max(paperWidth, paperHeight) < 1400) add("resolution", "The print may be too small. Take a closer photo.");
if (report.verdict === "dark") add("dark", "Find more even light and move out of your shadow.");
if (report.verdict === "blurred" || (body && body.sharpness < 45)) add("blur", "Hold still and let the camera focus on the print.");
if (body && body.contrast < 22) add("faint", "The print looks faint. Check it closely or try better light.");
return { ok: issues.length === 0, issues, message: messages[0] ?? "Check the heading, quantities and totals before using this scan." };
}
/** Ignore headers and page borders; assess the body where the small print lives. */
export function documentBodyQuality(gray: Uint8ClampedArray, width: number, height: number): BodyQuality {
const left = Math.floor(width * .12), top = Math.floor(height * .28);
const w = Math.max(1, Math.floor(width * .76)), h = Math.max(1, Math.floor(height * .48));
const body = new Uint8ClampedArray(w * h);
const histogram = new Uint32Array(256);
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
const value = gray[(top + y) * width + left + x];
body[y * w + x] = value; histogram[value]++;
}
const percentile = (fraction: number) => {
let count = 0;
for (let i = 0; i < 256; i++) { count += histogram[i]; if (count >= body.length * fraction) return i; }
return 255;
};
return { sharpness: laplacianVariance(body, w, h), contrast: percentile(.95) - percentile(.05) };
}
/** Gentle grayscale illumination correction, not binary thresholding or reconstruction. */
export function enhanceDocumentPixels(rgba: Uint8ClampedArray, width: number, height: number): Uint8ClampedArray {
if (rgba.length !== width * height * 4 || !width || !height) throw new Error("Invalid document image");
const step = 32, cols = Math.ceil(width / step), rows = Math.ceil(height / step);
const sums = new Float64Array(cols * rows), counts = new Uint32Array(cols * rows);
for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) {
const i = (y * width + x) * 4, cell = Math.floor(y / step) * cols + Math.floor(x / step);
sums[cell] += (rgba[i] * 299 + rgba[i + 1] * 587 + rgba[i + 2] * 114) / 1000; counts[cell]++;
}
const levels = new Float32Array(cols * rows);
for (let y = 0; y < rows; y++) for (let x = 0; x < cols; x++) {
let sum = 0, count = 0;
for (let dy = -2; dy <= 2; dy++) for (let dx = -2; dx <= 2; dx++) {
if (x + dx < 0 || x + dx >= cols || y + dy < 0 || y + dy >= rows) continue;
const cell = (y + dy) * cols + x + dx; sum += sums[cell]; count += counts[cell];
}
levels[y * cols + x] = Math.max(120, sum / count);
}
const output = new Uint8ClampedArray(rgba.length);
for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) {
const i = (y * width + x) * 4;
// Interpolate the illumination grid to avoid artificial tile boundaries.
const gx = Math.max(0, Math.min(cols - 1, x / step - .5)), gy = Math.max(0, Math.min(rows - 1, y / step - .5));
const x0 = Math.floor(gx), y0 = Math.floor(gy), x1 = Math.min(cols - 1, x0 + 1), y1 = Math.min(rows - 1, y0 + 1);
const fx = gx - x0, fy = gy - y0;
const background = (levels[y0 * cols + x0] * (1 - fx) + levels[y0 * cols + x1] * fx) * (1 - fy) + (levels[y1 * cols + x0] * (1 - fx) + levels[y1 * cols + x1] * fx) * fy;
const gray = (rgba[i] * 299 + rgba[i + 1] * 587 + rgba[i + 2] * 114) / 1000;
const value = (gray * 245 / background - 240) * 1.15 + 240;
output[i] = output[i + 1] = output[i + 2] = value; output[i + 3] = rgba[i + 3];
}
return output;
}

11
src/lib/scan-metadata.ts Normal file
View file

@ -0,0 +1,11 @@
export const SCAN_METADATA_PREFIX = "MEGA_SCAN_V1:";
export interface ScanMetadata { version: 1; pages: { qualityOverride: string }[] }
export function parseScanMetadata(subject?: string): ScanMetadata | null {
if (!subject?.startsWith(SCAN_METADATA_PREFIX) || subject.length > 12000) return null;
try {
const value = JSON.parse(subject.slice(SCAN_METADATA_PREFIX.length));
if (value.version !== 1 || !Array.isArray(value.pages) || !value.pages.length || value.pages.length > 12 ||
value.pages.some((page: { qualityOverride?: unknown } | null) => !page || typeof page.qualityOverride !== "string" || page.qualityOverride.length > 500)) return null;
return { version: 1, pages: value.pages.map((page: { qualityOverride: string }) => ({ qualityOverride: page.qualityOverride })) };
} catch { return null; }
}

29
src/lib/scan-pdf.test.ts Normal file
View file

@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import sharp from "sharp";
import { PDFArray, PDFDict, PDFDocument, PDFName, PDFRawStream, decodePDFRawStream } from "pdf-lib";
import { scansToPdf } from "./scan-pdf";
import { parseScanMetadata } from "./scan-metadata";
describe("scan evidence PDF", () => {
it("keeps exact original bytes as attachments without adding OCR pages", async () => {
const jpeg = await sharp({ create: { width: 60, height: 90, channels: 3, background: "white" } }).jpeg().toBuffer();
const original = new Uint8Array([1, 2, 3, 4, 5]);
const bytes = await scansToPdf([{ jpeg, original, originalName: "phone.jpg", originalMime: "image/jpeg", qualityOverride: "Original paper is faint" }]);
const doc = await PDFDocument.load(bytes);
expect(doc.getPageCount()).toBe(1);
const names = doc.catalog.lookup(PDFName.of("Names"), PDFDict).lookup(PDFName.of("EmbeddedFiles"), PDFDict).lookup(PDFName.of("Names"), PDFArray);
const spec = names.lookup(1, PDFDict);
const stream = spec.lookup(PDFName.of("EF"), PDFDict).lookup(PDFName.of("F")) as PDFRawStream;
expect(decodePDFRawStream(stream).decode()).toEqual(original);
expect(parseScanMetadata(doc.getSubject())?.pages[0].qualityOverride).toBe("Original paper is faint");
});
it("produces identical bytes on retry so file hash dedup still works", async () => {
const jpeg = await sharp({ create: { width: 20, height: 30, channels: 3, background: "white" } }).jpeg().toBuffer();
const pages = [{ jpeg, original: jpeg, originalName: "photo.jpg", originalMime: "image/jpeg", qualityOverride: "" }];
expect(await scansToPdf(pages)).toEqual(await scansToPdf(pages));
});
it("ignores unrelated or malformed PDF metadata", () => {
expect(parseScanMetadata("Some supplier's document")).toBeNull();
expect(parseScanMetadata('MEGA_SCAN_V1:{"pages":[{"qualityOverride":42}]}')).toBeNull();
});
});

25
src/lib/scan-pdf.ts Normal file
View file

@ -0,0 +1,25 @@
import { SCAN_METADATA_PREFIX } from "./scan-metadata";
export interface ScanPdfPage {
jpeg: Uint8Array; original: Uint8Array; originalName: string; originalMime: string; qualityOverride: string;
}
/** Original files are attachments, never extra pages for OCR or receiving. */
export async function scansToPdf(pages: readonly ScanPdfPage[]): Promise<Uint8Array> {
if (!pages.length || pages.length > 12) throw new Error("Choose between 1 and 12 scan pages");
const { PDFDocument } = await import("pdf-lib");
const doc = await PDFDocument.create();
// Stable metadata makes a retry of unchanged content the same file/hash.
const epoch = new Date("2026-01-01T00:00:00Z");
doc.setCreationDate(epoch); doc.setModificationDate(epoch);
doc.setProducer("MEGA Clock document scanner"); doc.setCreator("MEGA Clock");
doc.setSubject(SCAN_METADATA_PREFIX + JSON.stringify({ version: 1, pages: pages.map(p => ({ qualityOverride: p.qualityOverride.slice(0, 500) })) }));
for (const [index, page] of pages.entries()) {
const image = await doc.embedJpg(page.jpeg);
// 300 DPI paper coordinates avoid oversampling image pixels during rendering.
const visible = doc.addPage([image.width * 72 / 300, image.height * 72 / 300]);
visible.drawImage(image, { x: 0, y: 0, width: visible.getWidth(), height: visible.getHeight() });
const safeName = page.originalName.replace(/[^a-zA-Z0-9._-]/g, "_").slice(-100) || "photo.jpg";
await doc.attach(page.original, `original-${index + 1}-${safeName}`, { mimeType: page.originalMime, description: `Unedited original photo for scan page ${index + 1}`, creationDate: epoch, modificationDate: epoch });
}
return doc.save();
}