'use client'; import { useEffect, useRef, useState } from 'react'; import { useParams, useRouter } from 'next/navigation'; import { useMutation } from '@tanstack/react-query'; import { Camera, Loader2, ScanLine, Upload, X } from 'lucide-react'; import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { apiFetch } from '@/lib/api/client'; import { EXPENSE_CATEGORIES } from '@/lib/constants'; interface ScanResult { establishment: string | null; date: string | null; amount: number | null; currency: string | null; lineItems: Array<{ description: string; amount: number }>; confidence: number; } interface UploadedFileMeta { id: string; filename: string; } export default function ScanReceiptPage() { const params = useParams<{ portSlug: string }>(); const router = useRouter(); const fileInputRef = useRef(null); const cameraInputRef = useRef(null); const [scanResult, setScanResult] = useState(null); const [previewUrl, setPreviewUrl] = useState(null); // After OCR succeeds we also upload the receipt to /api/v1/files/upload // so the expense links to the actual image. The legacy scanner skipped // this step and saved expenses without their receipt — which silently // disqualified them from parent-company reimbursement (the warning the // PDF export now surfaces). const [uploadedFile, setUploadedFile] = useState(null); const [pendingFile, setPendingFile] = useState(null); const { setChrome } = useMobileChrome(); useEffect(() => { setChrome({ title: 'Scan Receipt', showBackButton: true }); return () => setChrome({ title: null, showBackButton: false }); }, [setChrome]); // Editable fields from scan const [establishment, setEstablishment] = useState(''); const [amount, setAmount] = useState(''); const [currency, setCurrency] = useState('USD'); const [date, setDate] = useState(''); const [category, setCategory] = useState(''); const scanMutation = useMutation({ mutationFn: async (file: File) => { const formData = new FormData(); formData.append('file', file); const res = await fetch('/api/v1/expenses/scan-receipt', { method: 'POST', body: formData, credentials: 'include', }); if (!res.ok) throw new Error('Scan failed'); return res.json() as Promise<{ data: ScanResult }>; }, onSuccess: (response) => { const result = response.data; setScanResult(result); if (result.establishment) setEstablishment(result.establishment); if (result.amount) setAmount(String(result.amount)); if (result.currency) setCurrency(result.currency); if (result.date) setDate(result.date.split('T')[0] ?? result.date); }, }); // Uploads the receipt image to /api/v1/files/upload (category=receipt) // so the new expense row can link to it via receiptFileIds. Runs in // parallel with the OCR scan so the rep can keep editing fields while // the upload completes. const uploadMutation = useMutation({ mutationFn: async (file: File): Promise => { const formData = new FormData(); formData.append('file', file); formData.append('category', 'receipt'); const res = await fetch('/api/v1/files/upload', { method: 'POST', body: formData, credentials: 'include', }); if (!res.ok) throw new Error('Receipt upload failed'); const json = (await res.json()) as { data: { id: string; filename: string } }; return { id: json.data.id, filename: json.data.filename }; }, onSuccess: (meta) => { setUploadedFile(meta); }, }); const saveMutation = useMutation({ mutationFn: () => apiFetch('/api/v1/expenses', { method: 'POST', body: { establishmentName: establishment, amount: Number(amount), currency, category: category || undefined, expenseDate: date ? new Date(date) : new Date(), paymentStatus: 'unpaid', receiptFileIds: uploadedFile ? [uploadedFile.id] : undefined, // The scanner path always has a receipt (we wouldn't have OCR'd // it otherwise), so we never need the no-receipt flag here. }, }), onSuccess: () => { router.push(`/${params.portSlug}/expenses`); }, }); function handleFileChange(e: React.ChangeEvent) { const file = e.target.files?.[0]; if (!file) return; setPendingFile(file); const url = URL.createObjectURL(file); setPreviewUrl(url); // Kick off OCR scan + storage upload concurrently. The two are // independent server calls and the rep is staring at the preview // while both run. scanMutation.mutate(file); uploadMutation.mutate(file); } function handleClearReceipt() { if (previewUrl) URL.revokeObjectURL(previewUrl); setPreviewUrl(null); setUploadedFile(null); setPendingFile(null); setScanResult(null); // Reset in-flight mutations so a late onSuccess doesn't repopulate // the form against an already-cleared UI (audit finding: stale // receipt could land on the next Save). scanMutation.reset(); uploadMutation.reset(); if (fileInputRef.current) fileInputRef.current.value = ''; if (cameraInputRef.current) cameraInputRef.current.value = ''; } void pendingFile; return (

Scan Receipt

Upload a receipt image and we will extract the expense details automatically.

Upload Receipt {previewUrl ? (
Receipt preview
{uploadMutation.isPending && ( Uploading receipt… )} {uploadedFile && ( Receipt uploaded ({uploadedFile.filename}) )} {uploadMutation.isError && ( Receipt upload failed — save will still create the expense without an image. )}
) : (
{/* Camera button — available on mobile devices that surface the built-in capture flow when an `image/*` input has the `capture` attribute. Hidden on desktop where it's a no-op. */} {/* File picker — works on every platform. Phrased so the copy fits both mobile (library/files) and desktop (drag and drop). */}

JPEG, PNG, HEIC, WebP up to 10 MB

)} {/* `image/*` is the broadest accept — includes HEIC on iOS, JPEG/PNG/WebP everywhere. The capture attribute on the second input invokes the native camera flow on mobile. */} {scanMutation.isPending && (
Scanning receipt...
)}
{(scanResult || scanMutation.isSuccess) && ( Extracted Details {scanResult && ( (confidence: {Math.round((scanResult.confidence ?? 0) * 100)}%) )}
setAmount(e.target.value)} placeholder="0.00" />
setCurrency(e.target.value.toUpperCase())} maxLength={3} placeholder="USD" />
setEstablishment(e.target.value)} placeholder="Establishment name" />
setDate(e.target.value)} />
{saveMutation.isError && (

{(saveMutation.error as Error).message}

)}
)}
); }