507 lines
16 KiB
TypeScript
507 lines
16 KiB
TypeScript
|
|
'use client';
|
||
|
|
|
||
|
|
import { useRef, useState, useEffect } from 'react';
|
||
|
|
import { useRouter } from 'next/navigation';
|
||
|
|
import { Camera, Loader2, RotateCcw, AlertTriangle, CheckCircle2, Save } from 'lucide-react';
|
||
|
|
|
||
|
|
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';
|
||
|
|
|
||
|
|
// ─── 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' }
|
||
|
|
| {
|
||
|
|
kind: 'verify';
|
||
|
|
parsed: ParsedReceipt;
|
||
|
|
source: 'ai' | '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' | '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,
|
||
|
|
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 === 'ai' && parsed.confidence < 0.6;
|
||
|
|
const noOcr = source === '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>
|
||
|
|
{reason === 'no-ocr-configured' ? (
|
||
|
|
<>
|
||
|
|
<p className="font-medium">Manual entry mode</p>
|
||
|
|
<p className="text-xs mt-0.5">
|
||
|
|
No AI provider is configured for this port. Fill in the details below to save the
|
||
|
|
expense with the photo attached.
|
||
|
|
</p>
|
||
|
|
</>
|
||
|
|
) : (
|
||
|
|
<>
|
||
|
|
<p className="font-medium">We couldn't read the receipt automatically</p>
|
||
|
|
<p className="text-xs mt-0.5">
|
||
|
|
{providerError ? `Reason: ${providerError}.` : ''} 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">
|
||
|
|
The AI returned a confidence of {Math.round(parsed.confidence * 100)}%.
|
||
|
|
</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">Confidence {Math.round(parsed.confidence * 100)}%.</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);
|
||
|
|
|
||
|
|
// Revoke blob URL on unmount.
|
||
|
|
useEffect(() => {
|
||
|
|
return () => {
|
||
|
|
if (imagePreview) URL.revokeObjectURL(imagePreview);
|
||
|
|
};
|
||
|
|
}, [imagePreview]);
|
||
|
|
|
||
|
|
async function handleFile(file: File) {
|
||
|
|
if (imagePreview) URL.revokeObjectURL(imagePreview);
|
||
|
|
setImagePreview(URL.createObjectURL(file));
|
||
|
|
setState({ kind: 'processing' });
|
||
|
|
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;
|
||
|
|
setState({
|
||
|
|
kind: 'verify',
|
||
|
|
parsed: body.data.parsed,
|
||
|
|
source: body.data.source,
|
||
|
|
reason: body.data.reason,
|
||
|
|
providerError: body.data.providerError,
|
||
|
|
});
|
||
|
|
} catch (err) {
|
||
|
|
setState({
|
||
|
|
kind: 'error',
|
||
|
|
message: err instanceof Error ? err.message : 'Upload failed',
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
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);
|
||
|
|
}
|
||
|
|
setState({ kind: 'idle' });
|
||
|
|
if (fileRef.current) fileRef.current.value = '';
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<main className="mx-auto flex min-h-[100dvh] w-full max-w-xl flex-col gap-4 px-4 py-6 sm:py-10">
|
||
|
|
<header className="flex 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}
|
||
|
|
</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'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">Reading receipt…</p>
|
||
|
|
</section>
|
||
|
|
) : null}
|
||
|
|
|
||
|
|
{state.kind === 'verify' && imagePreview ? (
|
||
|
|
<VerifyForm
|
||
|
|
parsed={state.parsed}
|
||
|
|
imagePreview={imagePreview}
|
||
|
|
imageFile={fileRef.current?.files?.[0] 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>
|
||
|
|
);
|
||
|
|
}
|