Files
pn-new-crm/src/components/scan/scan-shell.tsx
Matt 4329db7fc3 fix(compiler): React Compiler safety triage — 5 categories cleared
Cleared 4 rule buckets (37 violations, including 5 real bugs) and
silenced 1 informational bucket from the Next 16 / react-hooks v7
upgrade. Cleared rules promoted from `warn` back to `error` so new
regressions block CI.

Real bug fixes:
- `interest-contact-log-tab.tsx`: `useMemo` used for side effects
  (5 setState calls inside a memo body); converted to `useEffect`.
- `PieChart.tsx`: cumulative `let angle` mutation in a render-phase
  `map`; converted to `reduce` so the slice array is built without
  re-assignment.
- `documents-hub.tsx`: `useMemo(() => ({ count: 0 }))` used as a
  mutable drag counter; converted to `useRef`.
- `notes-list.tsx`: `Date.now()` read during render for note-edit
  countdown (impure) → pinned to a `now` state ticked every 30s.
- `onboarding-checklist.tsx` / `user-profile.tsx` /
  `user-settings.tsx`: `useEffect(() => void load(), [])` with the
  `load` function declared AFTER the effect — relied on hoisting,
  trips Compiler's "access before declared" rule. Declared inside
  the effect.

Pattern fixes (intentional cache-via-ref → state or layout-effect):
- 6 `ref.current = x` writes during render moved into layout
  effects (`use-realtime-invalidation`, `settings-form-card`,
  `inbox`).
- 3 `ref.current` reads during render (search totals cache,
  scanner file ref) rewritten to backed-by-state.
- `use-is-mobile.ts` rewritten on `useSyncExternalStore` to avoid
  the SSR-then-rehydrate setState dance.
- `use-notifications.ts` rewritten to write socket pushes directly
  into the React Query cache via `setQueryData`, removing a local
  state mirror.

Rule config (`eslint.config.mjs`):
- `react-hooks/purity` → error (was warn, cleared)
- `react-hooks/set-state-in-render` → error (was warn, cleared)
- `react-hooks/immutability` → error (was warn, cleared)
- `react-hooks/refs` → error (was warn, cleared)
- `react-hooks/incompatible-library` → off (informational only)
- `react-hooks/set-state-in-effect` → warn (51 remaining, all the
  useEffect→fetch→setState data-fetch pattern; migration to
  useQuery tracked in BACKLOG)

Verified: tsc clean, eslint 0 errors / 69 warnings (down from 105),
vitest 1315/1315, next build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 23:14:16 +02:00

623 lines
22 KiB
TypeScript

'use client';
import { useRef, useState, useEffect } from 'react';
import Image from 'next/image';
import { useRouter } from 'next/navigation';
import { Camera, Loader2, RotateCcw, AlertTriangle, CheckCircle2, Save } from 'lucide-react';
const LOGO_URL =
'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useUIStore } from '@/stores/ui-store';
import { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils';
import { EXPENSE_CATEGORIES, PAYMENT_METHODS } from '@/lib/constants';
import { runTesseract } from '@/lib/ocr/tesseract-client';
// Lazy-loaded compression — the worker bundle isn't on the critical path,
// and most users won't reach this code without first granting camera/file
// access, by which point the module is already paged in.
async function compressReceiptIfHeavy(file: File): Promise<File> {
// Only compress raster images > ~1 MB. PDFs, SVGs, and small files pass
// through untouched. Magic-byte check via mime type — the caller is the
// file picker which trusts the picker output already.
if (!file.type.startsWith('image/') || file.type === 'image/svg+xml') return file;
if (file.size < 1 * 1024 * 1024) return file;
const { default: imageCompression } = await import('browser-image-compression');
try {
const compressed = await imageCompression(file, {
maxSizeMB: 0.5, // ~500 KB target — plenty of resolution for OCR
maxWidthOrHeight: 2000, // tesseract.js's sweet spot for receipt text
useWebWorker: true, // off the main thread; UI stays responsive
// Auto-rotate to EXIF orientation, strip metadata. Phones often
// store the rotation as EXIF rather than rotating pixels; without
// this the receipt comes out sideways and OCR confidence tanks.
preserveExif: false,
fileType: file.type === 'image/png' ? 'image/jpeg' : file.type,
initialQuality: 0.85,
});
// Browser-image-compression typings always say `File`, but in some
// runtimes the value comes through as a plain Blob. Belt-and-suspenders:
// wrap in a File so downstream FormData uses the original filename.
const blob = compressed as unknown as Blob;
if (typeof File !== 'undefined' && blob instanceof File) return blob;
return new File([blob], file.name, { type: blob.type });
} catch {
// Fall back to the original — we don't want a corner-case compression
// bug to block the user from saving an expense.
return file;
}
}
// ─── Types ────────────────────────────────────────────────────────────────────
interface ParsedReceipt {
establishment: string | null;
date: string | null;
amount: number | null;
currency: string | null;
lineItems: Array<{ description: string; amount: number }>;
confidence: number;
}
type ScanState =
| { kind: 'idle' }
| { kind: 'processing'; engine: 'tesseract' | 'ai' }
| {
kind: 'verify';
parsed: ParsedReceipt;
source: 'ai' | 'tesseract' | 'manual';
reason?: string;
providerError?: string;
}
| { kind: 'saving' }
| { kind: 'saved'; expenseId: string }
| { kind: 'error'; message: string };
interface ScanResp {
data: {
parsed: ParsedReceipt;
source: 'ai' | 'manual';
reason?: string;
provider?: string;
model?: string;
providerError?: string;
};
}
// ─── Form ─────────────────────────────────────────────────────────────────────
interface VerifyFormProps {
parsed: ParsedReceipt;
imagePreview: string;
imageFile: File;
source: 'ai' | 'tesseract' | 'manual';
reason?: string;
providerError?: string;
onSubmit: (input: {
establishmentName: string;
amount: string;
currency: string;
expenseDate: string;
category: string;
paymentMethod: string;
description: string;
file: File;
}) => void;
onRetake: () => void;
saving: boolean;
}
const TODAY = () => new Date().toISOString().slice(0, 10);
function VerifyForm({
parsed,
imagePreview,
imageFile,
source,
reason: _reason,
providerError,
onSubmit,
onRetake,
saving,
}: VerifyFormProps) {
const [establishmentName, setEstablishment] = useState(parsed.establishment ?? '');
const [amount, setAmount] = useState(parsed.amount != null ? String(parsed.amount) : '');
const [currency, setCurrency] = useState((parsed.currency ?? 'USD').toUpperCase());
const [expenseDate, setExpenseDate] = useState(parsed.date ?? TODAY());
const [category, setCategory] = useState<string>('other');
const [paymentMethod, setPaymentMethod] = useState<string>('credit_card');
const [description, setDescription] = useState('');
const lowConfidence = source !== 'manual' && parsed.confidence < 0.6;
const noOcr = source === 'manual';
const engineLabel = source === 'ai' ? 'AI' : source === 'tesseract' ? 'on-device OCR' : 'manual';
const banner = noOcr ? (
<div className="flex items-start gap-2 rounded-lg border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-900">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<div>
<p className="font-medium">Manual entry mode</p>
<p className="text-xs mt-0.5">
{providerError
? `We couldn't read the receipt automatically: ${providerError}.`
: "We couldn't read the receipt automatically."}{' '}
Fill in the details below to save the expense with the photo attached.
</p>
</div>
</div>
) : lowConfidence ? (
<div className="flex items-start gap-2 rounded-lg border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-900">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<div>
<p className="font-medium">Low-confidence read - please double-check the fields</p>
<p className="text-xs mt-0.5">
{engineLabel} returned {Math.round(parsed.confidence * 100)}% confidence.
</p>
</div>
</div>
) : (
<div className="flex items-start gap-2 rounded-lg border border-emerald-300 bg-emerald-50 px-3 py-2 text-sm text-emerald-900">
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" />
<div>
<p className="font-medium">Receipt parsed - confirm the fields and save</p>
<p className="text-xs mt-0.5">
{engineLabel} · {Math.round(parsed.confidence * 100)}% confidence.
</p>
</div>
</div>
);
return (
<form
className="flex flex-col gap-4"
onSubmit={(e) => {
e.preventDefault();
onSubmit({
establishmentName,
amount,
currency,
expenseDate,
category,
paymentMethod,
description,
file: imageFile,
});
}}
>
{banner}
<div className="overflow-hidden rounded-lg border border-border">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={imagePreview}
alt="Receipt preview"
className="block w-full max-h-[40vh] object-contain bg-muted"
/>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="establishmentName">Vendor / establishment</Label>
<Input
id="establishmentName"
value={establishmentName}
onChange={(e) => setEstablishment(e.target.value)}
placeholder="e.g. Marina Fuel Station"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="amount">Amount</Label>
<Input
id="amount"
type="number"
step="0.01"
min="0"
inputMode="decimal"
value={amount}
onChange={(e) => setAmount(e.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="currency">Currency</Label>
<Input
id="currency"
value={currency}
onChange={(e) => setCurrency(e.target.value.toUpperCase())}
maxLength={3}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="expenseDate">Date</Label>
<Input
id="expenseDate"
type="date"
value={expenseDate}
onChange={(e) => setExpenseDate(e.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="category">Category</Label>
<Select value={category} onValueChange={setCategory}>
<SelectTrigger id="category">
<SelectValue />
</SelectTrigger>
<SelectContent>
{EXPENSE_CATEGORIES.map((c) => (
<SelectItem key={c} value={c} className="capitalize">
{c.replace(/_/g, ' ')}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="paymentMethod">Payment method</Label>
<Select value={paymentMethod} onValueChange={setPaymentMethod}>
<SelectTrigger id="paymentMethod">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAYMENT_METHODS.map((p) => (
<SelectItem key={p} value={p} className="capitalize">
{p.replace(/_/g, ' ')}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="description">Notes (optional)</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
/>
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Button
type="submit"
disabled={saving || !amount}
className="h-12 text-base sm:flex-1"
data-testid="scan-save"
>
{saving ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save expense
</Button>
<Button
type="button"
variant="outline"
onClick={onRetake}
disabled={saving}
className="h-12 text-base"
>
<RotateCcw className="mr-2 h-4 w-4" />
Retake
</Button>
</div>
</form>
);
}
// ─── Shell ────────────────────────────────────────────────────────────────────
export function ScanShell() {
const router = useRouter();
const portSlug = useUIStore((s) => s.currentPortSlug);
const fileRef = useRef<HTMLInputElement>(null);
const [state, setState] = useState<ScanState>({ kind: 'idle' });
const [imagePreview, setImagePreview] = useState<string | null>(null);
// Track the (possibly compressed) File alongside its preview URL.
// Reading fileRef.current.files[0] during render trips the React
// Compiler's ref-purity rule + would point at the *raw* uploaded
// bytes (pre-compression), not the resized bytes we OCR'd.
const [currentFile, setCurrentFile] = useState<File | null>(null);
// Revoke blob URL on unmount.
useEffect(() => {
return () => {
if (imagePreview) URL.revokeObjectURL(imagePreview);
};
}, [imagePreview]);
async function handleFile(rawFile: File) {
// Compress oversized phone photos to ~500 KB in a WebWorker BEFORE
// we hand the bytes to tesseract or the server. Receipts from 12MP
// cameras are usually 4-8 MB; this drops them to ~250-500 KB without
// visible quality loss for text OCR. Mobile bandwidth + the server's
// sharp pipeline both benefit.
const file = await compressReceiptIfHeavy(rawFile);
if (imagePreview) URL.revokeObjectURL(imagePreview);
setImagePreview(URL.createObjectURL(file));
setCurrentFile(file);
setState({ kind: 'processing', engine: 'tesseract' });
// Always run Tesseract first - it's free, on-device, and gives us a
// baseline parse we can fall back to if the optional AI pass is off
// or fails. The WASM bundle dynamic-imports inside `runTesseract`.
let tesseract: Awaited<ReturnType<typeof runTesseract>> | null = null;
try {
tesseract = await runTesseract(file);
} catch (err) {
// Tesseract.js itself failed (corrupt image, OOM, etc). Don't bail -
// give the user the manual form so they can still save the expense.
setState({
kind: 'verify',
parsed: {
establishment: null,
date: null,
amount: null,
currency: null,
lineItems: [],
confidence: 0,
},
source: 'manual',
reason: 'tesseract-error',
providerError: err instanceof Error ? err.message : 'On-device OCR failed',
});
return;
}
// Now ask the server whether AI is enabled for this port. If it is,
// the server runs the configured provider and returns a richer parse;
// otherwise we keep the Tesseract result.
setState({ kind: 'processing', engine: 'ai' });
try {
const fd = new FormData();
fd.append('file', file);
const portId = useUIStore.getState().currentPortId;
const headers = new Headers();
if (portId) headers.set('X-Port-Id', portId);
const res = await fetch('/api/v1/expenses/scan-receipt', {
method: 'POST',
body: fd,
credentials: 'include',
headers,
});
if (!res.ok) throw new Error(`Server returned ${res.status}`);
const body = (await res.json()) as ScanResp;
if (body.data.source === 'ai' && body.data.parsed.confidence >= tesseract.parsed.confidence) {
// AI did at least as well as Tesseract - prefer its result.
setState({
kind: 'verify',
parsed: body.data.parsed,
source: 'ai',
reason: body.data.reason,
providerError: body.data.providerError,
});
return;
}
// Either AI is disabled (`source: 'manual', reason: 'ai-disabled'`),
// not configured, or it underperformed - fall back to Tesseract.
setState({
kind: 'verify',
parsed: tesseract.parsed,
source: 'tesseract',
reason: body.data.reason,
providerError: body.data.providerError,
});
} catch {
// Server unreachable - still let the user verify with the Tesseract
// result and save the expense. We don't surface the network error
// because the local parse is usable.
setState({
kind: 'verify',
parsed: tesseract.parsed,
source: 'tesseract',
});
}
}
async function handleSubmit(input: {
establishmentName: string;
amount: string;
currency: string;
expenseDate: string;
category: string;
paymentMethod: string;
description: string;
file: File;
}) {
setState({ kind: 'saving' });
try {
// Upload the image (multipart - apiFetch wraps JSON, so use raw fetch).
const fd = new FormData();
fd.append('file', input.file);
fd.append('category', 'receipt');
const portId = useUIStore.getState().currentPortId;
const headers = new Headers();
if (portId) headers.set('X-Port-Id', portId);
const upRes = await fetch('/api/v1/files/upload', {
method: 'POST',
body: fd,
credentials: 'include',
headers,
});
if (!upRes.ok) throw new Error(`Upload failed: ${upRes.status}`);
const upJson = (await upRes.json()) as { data: { id: string } };
const expense = await apiFetch<{ data: { id: string } }>(`/api/v1/expenses`, {
method: 'POST',
body: {
establishmentName: input.establishmentName || undefined,
amount: input.amount,
currency: input.currency,
expenseDate: input.expenseDate,
category: input.category,
paymentMethod: input.paymentMethod,
description: input.description || undefined,
receiptFileIds: [upJson.data.id],
paymentStatus: 'unpaid',
},
});
setState({ kind: 'saved', expenseId: expense.data.id });
} catch (err) {
setState({
kind: 'error',
message: err instanceof Error ? err.message : 'Save failed',
});
}
}
function reset() {
if (imagePreview) {
URL.revokeObjectURL(imagePreview);
setImagePreview(null);
}
setCurrentFile(null);
setState({ kind: 'idle' });
if (fileRef.current) fileRef.current.value = '';
}
return (
<main
// pb-[max(1.5rem,env(safe-area-inset-bottom))] — mobile-pwa-auditor
// caught that the "Save expense" button was sitting flush against
// the home indicator on iPhone 14/15 in standalone PWA mode
// (viewportFit:cover + statusBarStyle:default exposes the safe-
// area inset, but the original `py-6` ignored it).
className="mx-auto flex min-h-dvh w-full max-w-xl flex-col gap-4 px-4 py-6 pb-[max(1.5rem,env(safe-area-inset-bottom))] sm:py-10"
>
{/* Brand header - logo centered, page title underneath. Establishes
the standalone identity (this is the PWA home for the scanner). */}
<header className="flex flex-col items-center gap-3">
<Image
src={LOGO_URL}
alt="Port Nimara"
width={64}
height={64}
className="rounded-full shadow-md"
priority
unoptimized
/>
<div className="flex w-full items-center justify-between">
<h1 className="text-xl font-semibold">Scan a receipt</h1>
{state.kind !== 'idle' ? (
<Button variant="ghost" size="sm" onClick={reset}>
Start over
</Button>
) : null}
</div>
</header>
{state.kind === 'idle' ? (
<section className="flex flex-1 flex-col items-center justify-center gap-4 rounded-2xl border-2 border-dashed border-border bg-muted/20 p-8 text-center">
<Camera className="h-12 w-12 text-muted-foreground/60" aria-hidden />
<div>
<p className="text-base font-medium">Tap to capture a receipt</p>
<p className="mt-1 text-xs text-muted-foreground">
Use your camera or pick an image from your library. We&apos;ll read it and pre-fill
the form for you to confirm.
</p>
</div>
<Button
type="button"
className="h-12 px-6 text-base"
onClick={() => fileRef.current?.click()}
data-testid="scan-capture"
>
<Camera className="mr-2 h-5 w-5" />
Capture receipt
</Button>
<input
ref={fileRef}
type="file"
accept="image/*"
capture="environment"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void handleFile(f);
}}
/>
</section>
) : null}
{state.kind === 'processing' ? (
<section className="flex flex-1 flex-col items-center justify-center gap-3 py-12">
<Loader2 className="h-10 w-10 animate-spin text-brand" />
<p className="text-sm text-muted-foreground">
{state.engine === 'tesseract' ? 'Reading on-device…' : 'Refining with AI…'}
</p>
</section>
) : null}
{state.kind === 'verify' && imagePreview ? (
<VerifyForm
parsed={state.parsed}
imagePreview={imagePreview}
imageFile={currentFile as File}
source={state.source}
reason={state.reason}
providerError={state.providerError}
onSubmit={handleSubmit}
onRetake={reset}
saving={false}
/>
) : null}
{state.kind === 'saving' ? (
<section className="flex flex-1 flex-col items-center justify-center gap-3 py-12">
<Loader2 className="h-10 w-10 animate-spin text-brand" />
<p className="text-sm text-muted-foreground">Saving expense</p>
</section>
) : null}
{state.kind === 'saved' ? (
<section className="flex flex-1 flex-col items-center justify-center gap-3 rounded-2xl border border-emerald-200 bg-emerald-50 p-8 text-center">
<CheckCircle2 className="h-12 w-12 text-emerald-600" />
<p className="text-base font-semibold text-emerald-900">Expense saved</p>
<div className="flex gap-2">
<Button onClick={reset} variant="outline" data-testid="scan-another">
Scan another
</Button>
<Button
onClick={() => router.push(`/${portSlug}/expenses/${state.expenseId}` as never)}
>
View expense
</Button>
</div>
</section>
) : null}
{state.kind === 'error' ? (
<section
className={cn(
'flex flex-col items-center gap-3 rounded-2xl border border-destructive/30 bg-destructive/5 p-6 text-center',
)}
>
<AlertTriangle className="h-10 w-10 text-destructive" />
<p className="text-base font-medium text-destructive">{state.message}</p>
<Button onClick={reset} variant="outline">
Try again
</Button>
</section>
) : null}
</main>
);
}