Add Scan Receipt button to create transactions from receipt photos

- New backend endpoint POST /transactions/parse-receipt (file upload, no existing txn needed)
- Refactored AI call logic into shared _call_ai_parse helper (no duplication)
- Scan Receipt button in transactions toolbar → file picker → AI parse → pre-filled form
- TransactionFormModal accepts initialValues prop to pre-populate fields from receipt
- "Fields pre-filled from receipt" banner shown in form when AI-populated
- Scan error displayed inline with dismiss button
- Supports JPEG, PNG, WebP, PDF (Anthropic) or images (OpenAI)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-04-22 19:15:59 +00:00
parent d6118bac54
commit 024a8330fa
4 changed files with 171 additions and 82 deletions

View file

@ -41,3 +41,12 @@ export async function parseReceipt(txnId: string, attachmentId: string): Promise
const { data } = await api.post(`/transactions/${txnId}/attachments/${attachmentId}/parse`);
return data;
}
export async function parseReceiptFile(file: File): Promise<ParsedReceipt> {
const form = new FormData();
form.append("file", file);
const { data } = await api.post("/transactions/parse-receipt", form, {
headers: { "Content-Type": "multipart/form-data" },
});
return data;
}

View file

@ -2,7 +2,7 @@ import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { format } from "date-fns";
import { X, Loader2 } from "lucide-react";
import { X, Loader2, Sparkles } from "lucide-react";
import type { Account } from "@/api/accounts";
const schema = z.object({
@ -21,22 +21,35 @@ const schema = z.object({
type Form = z.infer<typeof schema>;
export interface TransactionInitialValues {
description?: string;
merchant?: string;
amount?: number;
date?: string;
currency?: string;
}
interface Props {
accounts: Account[];
categories: { id: string; name: string; type: string }[];
onClose: () => void;
onSubmit: (data: any) => void;
isLoading: boolean;
initialValues?: TransactionInitialValues;
parsedFromReceipt?: boolean;
}
export default function TransactionFormModal({ accounts, categories, onClose, onSubmit, isLoading }: Props) {
export default function TransactionFormModal({ accounts, categories, onClose, onSubmit, isLoading, initialValues, parsedFromReceipt }: Props) {
const { register, handleSubmit, watch, formState: { errors } } = useForm<Form>({
resolver: zodResolver(schema),
defaultValues: {
type: "expense",
status: "cleared",
currency: "GBP",
date: format(new Date(), "yyyy-MM-dd"),
currency: initialValues?.currency ?? "GBP",
date: initialValues?.date ?? format(new Date(), "yyyy-MM-dd"),
description: initialValues?.description ?? "",
merchant: initialValues?.merchant ?? "",
amount: initialValues?.amount ? Math.abs(initialValues.amount) : undefined,
},
});
@ -70,6 +83,13 @@ export default function TransactionFormModal({ accounts, categories, onClose, on
</div>
<form onSubmit={handleSubmit(handleFormSubmit)} className="p-6 space-y-4">
{parsedFromReceipt && (
<div className="flex items-center gap-2 bg-primary/10 border border-primary/20 rounded-lg px-3 py-2 text-xs text-primary">
<Sparkles className="w-3.5 h-3.5 shrink-0" />
Fields pre-filled from receipt review before saving
</div>
)}
{/* Type */}
<div>
<label className="text-sm font-medium block mb-1.5">Type</label>

View file

@ -1,16 +1,19 @@
import { useState } from "react";
import { useRef, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { getTransactions, deleteTransaction, createTransaction, getCategories } from "@/api/transactions";
import type { Transaction } from "@/api/transactions";
import { getAccounts } from "@/api/accounts";
import { parseReceiptFile } from "@/api/settings";
import type { ParsedReceipt } from "@/api/settings";
import { formatCurrency } from "@/utils/currency";
import { cn } from "@/utils/cn";
import { format, startOfMonth, subMonths, startOfYear } from "date-fns";
import {
Plus, Trash2, Search, ChevronLeft, ChevronRight, Upload,
ArrowUpCircle, ArrowDownCircle, ArrowLeftRight, TrendingUp, Paperclip, RefreshCw
ArrowUpCircle, ArrowDownCircle, ArrowLeftRight, TrendingUp, Paperclip, RefreshCw, ScanLine, Loader2,
} from "lucide-react";
import TransactionFormModal from "./TransactionFormModal";
import type { TransactionInitialValues } from "./TransactionFormModal";
import TransactionDetailDrawer from "./TransactionDetailDrawer";
import { Link } from "react-router-dom";
@ -49,6 +52,10 @@ export default function TransactionList() {
const qc = useQueryClient();
const [showForm, setShowForm] = useState(false);
const [selectedTxn, setSelectedTxn] = useState<Transaction | null>(null);
const [receiptParsed, setReceiptParsed] = useState<ParsedReceipt | null>(null);
const [scanError, setScanError] = useState<string | null>(null);
const [scanning, setScanning] = useState(false);
const receiptInputRef = useRef<HTMLInputElement>(null);
const [search, setSearch] = useState("");
const [filterType, setFilterType] = useState("");
const [filterAccount, setFilterAccount] = useState("");
@ -89,9 +96,25 @@ export default function TransactionList() {
qc.invalidateQueries({ queryKey: ["transactions"] });
qc.invalidateQueries({ queryKey: ["accounts"] });
setShowForm(false);
setReceiptParsed(null);
},
});
async function handleReceiptFile(file: File) {
setScanning(true);
setScanError(null);
try {
const parsed = await parseReceiptFile(file);
setReceiptParsed(parsed);
setShowForm(true);
} catch (e: any) {
setScanError(e?.response?.data?.detail ?? "Could not parse receipt. Check your AI settings.");
} finally {
setScanning(false);
if (receiptInputRef.current) receiptInputRef.current.value = "";
}
}
const accountMap = Object.fromEntries(accounts.map((a) => [a.id, a]));
const categoryMap = Object.fromEntries(categories.map((c) => [c.id, c]));
@ -114,15 +137,38 @@ export default function TransactionList() {
<span className="hidden sm:inline">Import CSV</span>
</Link>
<button
onClick={() => setShowForm(true)}
onClick={() => receiptInputRef.current?.click()}
disabled={scanning}
title="Scan receipt with AI"
className="flex items-center gap-2 border border-border px-3 py-2 rounded-lg text-sm hover:bg-secondary disabled:opacity-50 transition-colors"
>
{scanning ? <Loader2 className="w-4 h-4 animate-spin" /> : <ScanLine className="w-4 h-4" />}
<span className="hidden sm:inline">Scan Receipt</span>
</button>
<button
onClick={() => { setReceiptParsed(null); setShowForm(true); }}
className="flex items-center gap-2 bg-primary text-primary-foreground px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary/90 transition-colors"
>
<Plus className="w-4 h-4" />
<span className="hidden sm:inline">Add</span>
</button>
</div>
<input
ref={receiptInputRef}
type="file"
accept=".jpg,.jpeg,.png,.webp,.pdf,image/jpeg,image/png,image/webp,application/pdf"
className="sr-only"
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleReceiptFile(f); }}
/>
</div>
{scanError && (
<div className="flex items-center gap-2 bg-destructive/10 border border-destructive/30 text-destructive rounded-lg px-3 py-2 text-sm">
{scanError}
<button onClick={() => setScanError(null)} className="ml-auto text-destructive/70 hover:text-destructive"></button>
</div>
)}
{/* Date presets */}
<div className="flex gap-1 flex-wrap">
{DATE_PRESETS.map((p) => (
@ -301,9 +347,17 @@ export default function TransactionList() {
<TransactionFormModal
accounts={accounts}
categories={categories}
onClose={() => setShowForm(false)}
onClose={() => { setShowForm(false); setReceiptParsed(null); }}
onSubmit={(data) => createMutation.mutate(data)}
isLoading={createMutation.isPending}
initialValues={receiptParsed ? {
description: receiptParsed.description ?? undefined,
merchant: receiptParsed.merchant ?? undefined,
amount: receiptParsed.amount ?? undefined,
date: receiptParsed.date ?? undefined,
currency: receiptParsed.currency ?? undefined,
} : undefined}
parsedFromReceipt={!!receiptParsed}
/>
)}