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:
parent
d6118bac54
commit
024a8330fa
4 changed files with 171 additions and 82 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue