feat(llm-parse): read a photo, as a booking or as a receipt in Costs

Whether the AI Parsing model reads images is an admin setting now, and it
lands with the two features that send a photo, so it has something to
control.

The setting is `vision: 'auto' | 'on' | 'off'` on the addon config. On
Automatic a local Ollama is asked what the model can do (`/api/show`), and a
cloud provider means no; Yes and No are applied as set. The old `multimodal`
flag is not read: the admin screens wrote it false on every save, so a
stored false meant nothing. The per-user toggle keeps its on/off meaning and
is read by the extraction now. The lookup is server-side only: capped read,
validated body, never throws, cached per server and model.
GET /api/llm/capabilities answers it for the signed-in caller, and both
photo pickers below ask it.

The booking import takes JPG, PNG and WEBP when the model reads images.
The photo goes as an image to every provider (image_url, an Anthropic image
block, Ollama's native chat with `images`), capped at 1600px, and skips KDE
Itinerary. A scanned PDF, which used to reach the model as empty text, has
its first two pages drawn by pdf-parse and sent the same way. The rest is
the import as it was: background job, review, the file attached to the
booking, the linked cost. On a real qwen3.5:4b a train ticket came back as
bare clock times until the router gave the model every type's field
meanings, and its price as an object inside a string, which read as 4900;
both are handled.

Costs gets Scan receipt: a dialog laid out like the booking import's takes
one photo, read on the import job queue, then reviewed in the existing
expense editor pre-filled with merchant, total, currency, day and lines,
the photo waiting in Receipts & Invoices to be uploaded on save. No table,
no migration, no category guess, no document-type detection.
This commit is contained in:
RemEx
2026-09-24 17:12:54 +00:00
committed by Maurice
parent 706c6c3959
commit b1c05bca83
183 changed files with 3098 additions and 218 deletions
@@ -703,7 +703,7 @@ describe('AddonManager', () => {
await user.click(screen.getByRole('button', { name: 'Save' }));
await screen.findByText('Saved');
expect(bodies[0]).toEqual({
config: { provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••', multimodal: true },
config: { provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••', vision: 'auto' },
});
await user.click(screen.getByRole('button', { name: 'Save' }));
@@ -737,7 +737,7 @@ describe('AddonManager', () => {
await screen.findByText('Saved');
// The model is trimmed before it is stored, the key is sent verbatim
expect(bodies[0]).toEqual({
config: { provider: 'openai', model: 'mistral:7b', baseUrl: '', apiKey: 'sk-live', multimodal: false },
config: { provider: 'openai', model: 'mistral:7b', baseUrl: '', apiKey: 'sk-live', vision: 'auto' },
});
});
@@ -765,10 +765,40 @@ describe('AddonManager', () => {
await screen.findByText('Saved');
// The stale local base URL must not ride along to Anthropic — it would hijack the endpoint.
expect(bodies[0]).toEqual({
config: { provider: 'anthropic', model: 'claude-haiku-4-5-20251001', baseUrl: '', apiKey: 'sk-ant-live', multimodal: false },
config: { provider: 'anthropic', model: 'claude-haiku-4-5-20251001', baseUrl: '', apiKey: 'sk-ant-live', vision: 'auto' },
});
});
it('FE-ADMIN-ADDON-035: whether the model reads images is auto, on or off, kept as stored and saved as chosen', async () => {
const user = userEvent.setup();
const bodies: unknown[] = [];
server.use(
addonsRoute([llmAddon({ provider: 'openai', model: 'gpt-4.1-mini', baseUrl: '', apiKey: 'sk', vision: 'off' })]),
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
bodies.push(await request.json());
return HttpResponse.json({ success: true });
}),
);
render(<><ToastContainer /><AddonManager /></>);
// Stored off stays off; a cloud provider is told what Automatic means for it.
await user.click(await screen.findByRole('button', { name: 'No' }));
expect(screen.getByText('Automatic means no for a cloud model. Choose Yes if this model reads images.')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Yes' }));
await user.click(screen.getByRole('button', { name: 'Save' }));
await screen.findByText('Saved');
expect(bodies[0]).toMatchObject({ config: { provider: 'openai', vision: 'on' } });
expect(bodies[0]).not.toHaveProperty('config.multimodal');
});
it('FE-ADMIN-ADDON-036: with the local provider the hint says Automatic asks the server', async () => {
server.use(addonsRoute([llmAddon({ provider: 'local', model: 'qwen3.5:4b' })]), modelsRoute([]));
render(<AddonManager />);
expect(await screen.findByText('Automatic asks the Ollama server whether this model reads images.')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Automatic' })).toBeInTheDocument();
});
it('FE-ADMIN-ADDON-027: an error frame in the pull stream aborts the pull and is reported', async () => {
const user = userEvent.setup();
server.use(
+13 -1
View File
@@ -6,6 +6,7 @@ import { useIsDark } from '../../hooks/useIsDark'
import { useToast } from '../shared/Toast'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Loader2 } from 'lucide-react'
import CustomSelect from '../shared/CustomSelect'
import { asLlmVision, LLM_VISION_MODES, type LlmVision } from '@trek/shared'
import EmptyState from '../shared/EmptyState'
import DawarichIcon from '../shared/DawarichIcon'
import AirTrailIcon from '../shared/AirTrailIcon'
@@ -371,12 +372,14 @@ const RECOMMENDED_MODELS: { id: string; label: string; note: string; recommended
* provider, it also lists installed Ollama models and can pull NuExtract models.
*/
function LlmParsingConfig({ addon }: { addon: Addon }) {
const { t } = useTranslation()
const toast = useToast()
const cfg = (addon.config ?? {}) as Record<string, unknown>
const [provider, setProvider] = useState<string>((cfg.provider as string) ?? 'local')
const [model, setModel] = useState<string>((cfg.model as string) ?? '')
const [baseUrl, setBaseUrl] = useState<string>((cfg.baseUrl as string) ?? '')
const [apiKey, setApiKey] = useState<string>((cfg.apiKey as string) ?? '')
const [vision, setVision] = useState<LlmVision>(asLlmVision(cfg.vision))
const [saving, setSaving] = useState(false)
// Local-provider model management.
@@ -438,7 +441,7 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
setSaving(true)
try {
// Send the masked sentinel unchanged so the server keeps the stored key.
await adminApi.updateAddon(addon.id, { config: { provider, model: model.trim(), baseUrl: provider === 'anthropic' ? '' : baseUrl.trim(), apiKey, multimodal: cfg.multimodal === true } })
await adminApi.updateAddon(addon.id, { config: { provider, model: model.trim(), baseUrl: provider === 'anthropic' ? '' : baseUrl.trim(), apiKey, vision } })
toast.success('Saved')
} catch {
toast.error('Failed to save')
@@ -455,6 +458,7 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
{ value: 'openai', label: 'OpenAI', icon: <Cloud size={14} /> },
{ value: 'anthropic', label: 'Anthropic', icon: <Sparkles size={14} /> },
]
const visionOptions = LLM_VISION_MODES.map(value => ({ value, label: t(`admin.addons.llm.vision.${value}`) }))
/* Lives in the tile's shelf like the collab toggles, so everything is caption-
sized and single-column — the band this used to be had a whole page width. */
@@ -486,6 +490,14 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
<input autoComplete="off" className={fieldCls} value={model} onChange={e => setModel(e.target.value)} placeholder={provider === 'anthropic' ? 'claude-opus-4-8' : provider === 'openai' ? 'gpt-4o' : 'select or pull below'} />
</label>
<div>
<span className={labelCls}>{t('settings.aiParsing.multimodal')}</span>
<CustomSelect value={vision} onChange={v => setVision(asLlmVision(v))} options={visionOptions} />
<p className="mt-1 text-caption text-content-faint">
{t(provider === 'local' ? 'admin.addons.llm.vision.hintLocal' : 'admin.addons.llm.vision.hintCloud')}
</p>
</div>
{/* Local model management (Ollama) */}
{provider === 'local' && (
<div className="space-y-2 rounded-lg border border-edge-secondary bg-surface p-2.5">
@@ -304,3 +304,44 @@ describe('BackgroundTasksWidget: the mark on a finished job (#2477)', () => {
expect(baseElement.querySelector('svg.lucide-alert-triangle')).toBeNull()
})
})
describe('BackgroundTasksWidget — a receipt scanned from Costs', () => {
const RECEIPT = { merchant: 'Café', date: '2026-09-20', total: 12.5, currency: 'EUR', items: [] }
it('FE-W4BGT-025: says it is reading the receipt while the job runs', () => {
useBackgroundTasksStore.setState({ tasks: [task({ status: 'running', kind: 'costs', label: 'bill.jpg', items: undefined })] })
render(<BackgroundTasksWidget />)
expect(screen.getByText('Reading the receipt…')).toBeInTheDocument()
})
it('FE-W4BGT-026: import:done with a read receipt offers to review the expense, which opens the trip', () => {
useBackgroundTasksStore.setState({ tasks: [task({ status: 'running', kind: 'costs', label: 'bill.jpg', items: undefined })] })
render(<BackgroundTasksWidget />)
const handler = vi.mocked(addListener).mock.calls[0][0] as WsHandler
act(() => { handler({ type: 'import:done', jobId: 'j1', tripId: 't1', result: { receipt: RECEIPT, warnings: [] } }) })
expect(useBackgroundTasksStore.getState().tasks[0].receipt).toEqual(RECEIPT)
fireEvent.click(screen.getByRole('button', { name: 'Review expense' }))
expect(useBackgroundTasksStore.getState().tasks[0].reviewRequested).toBe(true)
expect(navigate).toHaveBeenCalledWith('/trips/t1')
})
it('FE-W4BGT-027: a photo nothing could be read from says so, with its warning and no AI retry', async () => {
vi.mocked(healthApi.features).mockResolvedValue({ bookingImport: true, aiParsing: true })
const photo = new File(['x'], 'bill.jpg', { type: 'image/jpeg' })
useBackgroundTasksStore.setState({ tasks: [task({ kind: 'costs', receipt: null, warnings: ['bill.jpg: no receipt could be read'], sourceFiles: [photo] })] })
render(<BackgroundTasksWidget />)
expect(screen.getByText('No receipt could be read from this photo.')).toBeInTheDocument()
expect(screen.getByText('bill.jpg: no receipt could be read')).toBeInTheDocument()
await waitFor(() => expect(healthApi.features).toHaveBeenCalled())
expect(screen.queryByRole('button', { name: 'Try AI parsing' })).not.toBeInTheDocument()
})
it('FE-W4BGT-028: the reload reconcile reads a receipt job\'s status too', async () => {
vi.mocked(reservationsApi.importJobStatus).mockResolvedValue({ status: 'done', done: 1, total: 1, result: { receipt: RECEIPT, warnings: [] } as never })
useBackgroundTasksStore.setState({ tasks: [task({ kind: 'costs', items: undefined })] })
render(<BackgroundTasksWidget />)
expect(await screen.findByRole('button', { name: 'Review expense' })).toBeInTheDocument()
})
})
@@ -6,7 +6,18 @@ import { useTranslation } from '../../i18n'
import { addListener, removeListener } from '../../api/websocket'
import { reservationsApi, healthApi } from '../../api/client'
import { saveImportFiles } from '../../db/offlineDb'
import { useBackgroundTasksStore, type BackgroundImportTask } from '../../store/backgroundTasksStore'
import { useBackgroundTasksStore, taskFoundSomething, type BackgroundImportTask } from '../../store/backgroundTasksStore'
import type { ReceiptRead } from '@trek/shared'
/**
* One reading of a finished job's result, wherever it arrives from (the push,
* the reload reconcile, the poll): a booking parse answers `items`, a receipt
* scan `receipt`.
*/
function settleJob(id: string, tripId: string, result: unknown): void {
const r = result as { items?: unknown[]; warnings?: string[]; receipt?: ReceiptRead | null } | undefined
useBackgroundTasksStore.getState().setDone(id, tripId, (r?.items ?? []) as never, r?.warnings ?? [], r?.receipt)
}
/**
* Global, route-independent widget (bottom-right) that tracks background booking
@@ -20,7 +31,6 @@ export default function BackgroundTasksWidget() {
const navigate = useNavigate()
const tasks = useBackgroundTasksStore((s) => s.tasks)
const setProgress = useBackgroundTasksStore((s) => s.setProgress)
const setDone = useBackgroundTasksStore((s) => s.setDone)
const setError = useBackgroundTasksStore((s) => s.setError)
const requestReview = useBackgroundTasksStore((s) => s.requestReview)
const dismiss = useBackgroundTasksStore((s) => s.dismiss)
@@ -66,7 +76,7 @@ export default function BackgroundTasksWidget() {
reservationsApi
.importJobStatus(task.tripId, task.id)
.then((s) => {
if (s.status === 'done') setDone(task.id, task.tripId, (s.result?.items ?? []) as never, s.result?.warnings ?? [])
if (s.status === 'done') settleJob(task.id, task.tripId, s.result)
else if (s.status === 'error') setError(task.id, task.tripId, s.error ?? 'error')
else setProgress(task.id, task.tripId, s.done, s.total)
})
@@ -87,14 +97,12 @@ export default function BackgroundTasksWidget() {
const tripId = String(e.tripId ?? '')
if (!id) return
if (type === 'import:progress') setProgress(id, tripId, Number(e.done ?? 0), Number(e.total ?? 1))
else if (type === 'import:done') {
const result = e.result as { items?: unknown[]; warnings?: string[] } | undefined
setDone(id, tripId, (result?.items ?? []) as never, result?.warnings ?? [])
} else if (type === 'import:error') setError(id, tripId, String(e.message ?? 'error'))
else if (type === 'import:done') settleJob(id, tripId, e.result)
else if (type === 'import:error') setError(id, tripId, String(e.message ?? 'error'))
}
addListener(handler)
return () => removeListener(handler)
}, [setProgress, setDone, setError])
}, [setProgress, setError])
// Backstop: poll jobs whose state we still need — running ones (in case a WebSocket push
// was missed) and a restored 'done' task whose items haven't been re-fetched yet (so a
@@ -107,7 +115,7 @@ export default function BackgroundTasksWidget() {
reservationsApi
.importJobStatus(task.tripId, task.id)
.then((s) => {
if (s.status === 'done') setDone(task.id, task.tripId, (s.result?.items ?? []) as never, s.result?.warnings ?? [])
if (s.status === 'done') settleJob(task.id, task.tripId, s.result)
else if (s.status === 'error') setError(task.id, task.tripId, s.error ?? 'error')
else setProgress(task.id, task.tripId, s.done, s.total)
})
@@ -120,7 +128,7 @@ export default function BackgroundTasksWidget() {
}
}, 5000)
return () => clearInterval(iv)
}, [tasks, setProgress, setDone, setError, t])
}, [tasks, setProgress, setError, t])
if (tasks.length === 0) return null
@@ -141,11 +149,11 @@ export default function BackgroundTasksWidget() {
>
<div style={{ flexShrink: 0, marginTop: 1 }}>
{(task.status === 'running' || (task.status === 'done' && task.items === undefined)) && <Loader2 size={16} className="animate-spin" color="var(--accent)" />}
{task.status === 'done' && task.items !== undefined && task.items.length > 0 && <CheckCircle2 size={16} color="var(--success)" />}
{task.status === 'done' && task.items !== undefined && taskFoundSomething(task) && <CheckCircle2 size={16} color="var(--success)" />}
{/* A parse that found nothing is not a success, whatever the job
status says: the same card used to show a green tick next to
"no reservations could be extracted" (#2477). */}
{task.status === 'done' && task.items?.length === 0 && <AlertTriangle size={16} color="var(--warning)" />}
{task.status === 'done' && task.items !== undefined && !taskFoundSomething(task) && <AlertTriangle size={16} color="var(--warning)" />}
{task.status === 'error' && <AlertCircle size={16} color="var(--danger)" />}
</div>
@@ -156,7 +164,7 @@ export default function BackgroundTasksWidget() {
{task.status === 'running' && (
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 1 }}>
{t('reservations.import.parsing')}
{t(task.kind === 'costs' ? 'costs.scan.reading' : 'reservations.import.parsing')}
{task.total > 1 ? ` · ${task.done}/${task.total}` : ''}
</div>
)}
@@ -165,14 +173,14 @@ export default function BackgroundTasksWidget() {
task.items === undefined ? (
// Restored from a reload; items are being re-fetched (see the poll backstop).
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 1 }}>{t('reservations.import.parsing')}</div>
) : task.items.length > 0 ? (
) : taskFoundSomething(task) ? (
<div>
<button type="button"
onClick={() => review(task)}
className="bg-accent text-accent-text"
style={{ marginTop: 4, border: 'none', borderRadius: 8, padding: '4px 12px', fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}
>
{t('common.import')}
{t(task.kind === 'costs' ? 'costs.scan.review' : 'common.import')}
</button>
{/* A partly-understood import warns too. Warnings used to be
shown only when nothing at all was found, so anything the
@@ -188,14 +196,14 @@ export default function BackgroundTasksWidget() {
) : (
<div>
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 1 }}>
{t('reservations.import.previewEmpty')}
{t(task.kind === 'costs' ? 'costs.scan.nothingRead' : 'reservations.import.previewEmpty')}
{(task.warnings?.length ?? 0) > 0 && (
<div style={{ color: '#b45309', marginTop: 3, whiteSpace: 'pre-wrap', wordBreak: 'break-word', maxHeight: 96, overflowY: 'auto' }}>
{task.warnings!.join('\n')}
</div>
)}
</div>
{aiParsing && task.mode !== 'force-ai' && task.sourceFiles && task.sourceFiles.length > 0 && (
{aiParsing && task.kind !== 'costs' && task.mode !== 'force-ai' && task.sourceFiles && task.sourceFiles.length > 0 && (
<button type="button"
onClick={() => retryWithAi(task)}
disabled={retrying === task.id}
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { calculateTicketShares, finalBudgetFor, finalBudgetSources, hasTicketSplit, paidByUser, payerSum, payersBalanced, readTicketItems, readUserNote, rebalancePayers, settlementDate, splitCents, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers'
import { calculateTicketShares, expenseEditorFor, newExpenseSeed, receiptToPrefill, finalBudgetFor, finalBudgetSources, hasTicketSplit, paidByUser, payerSum, payersBalanced, readTicketItems, readUserNote, rebalancePayers, settlementDate, splitCents, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers'
describe('splitCents', () => {
it('splits evenly when it divides cleanly', () => {
@@ -310,3 +310,55 @@ describe('finalBudgetSources', () => {
.toEqual({ fronted: [], moved: [], outstanding: [] })
})
})
describe('newExpenseSeed', () => {
it('starts a plain new expense on the base currency, today, with nothing to attach', () => {
expect(newExpenseSeed(undefined, 'eur', [1, 2], '2026-09-24')).toEqual({
currency: 'EUR', day: '2026-09-24', total: '', ticketItems: [], receiptFiles: [],
})
})
it('reads a booking price in the base currency, padded to its decimals', () => {
expect(newExpenseSeed({ amount: 4.9 }, 'EUR', [], '2026-09-24').total).toBe('4.90')
})
it('seeds a scanned receipt: its currency, day, lines shared by everyone, and the photo', () => {
const photo = new File(['x'], 'bill.jpg')
const seed = newExpenseSeed(
{ amount: 1500, currency: 'jpy', date: '2026-09-20', lines: [{ name: 'Ramen', price: 1200 }, { name: 'Tea', price: 300 }], receiptFiles: [photo] },
'EUR', [1, 2], '2026-09-24',
)
expect(seed).toMatchObject({ currency: 'JPY', day: '2026-09-20', total: '1500', receiptFiles: [photo] })
expect(seed.ticketItems.map(i => [i.name, i.price, [...i.participants]])).toEqual([['Ramen', '1200', [1, 2]], ['Tea', '300', [1, 2]]])
})
})
describe('receiptToPrefill', () => {
it('turns a read receipt into an expense prefill, leaving out what was not read', () => {
const photo = new File(['x'], 'bill.jpg')
expect(receiptToPrefill({ merchant: 'Café', date: null, total: 12.5, currency: 'EUR', items: [] }, [photo])).toEqual({
name: 'Café', amount: 12.5, currency: 'EUR', date: undefined, lines: [], receiptFiles: [photo],
})
expect(receiptToPrefill({ merchant: null, date: '2026-09-01', total: null, currency: null, items: [] }, [])).toMatchObject({
name: undefined, amount: undefined, currency: undefined, date: '2026-09-01',
})
})
})
describe('expenseEditorFor', () => {
const closeBooking = () => {}
const closeReceipt = () => {}
it('is nothing when neither opener asks', () => {
expect(expenseEditorFor(null, closeBooking, null, closeReceipt)).toBeNull()
})
it('opens a scanned receipt as a new expense', () => {
expect(expenseEditorFor(null, closeBooking, { name: 'Café' }, closeReceipt)).toEqual({ key: 'receipt', editing: null, prefill: { name: 'Café' }, close: closeReceipt })
})
it('lets the booking win, since it is what was just clicked', () => {
expect(expenseEditorFor({ editing: null, prefill: { amount: 3 } }, closeBooking, { name: 'Café' }, closeReceipt))
.toEqual({ key: 'booking', editing: null, prefill: { amount: 3 }, close: closeBooking })
})
})
@@ -11,8 +11,10 @@
* Amounts are the raw input strings, parsed on use (same as customAmounts).
*/
import type { BudgetParticipantFinal } from '@trek/shared'
import { currencyDecimals } from '../../utils/formatters'
import type { BudgetParticipantFinal, ReceiptRead } from '@trek/shared'
import { amountToInputString, currencyDecimals } from '../../utils/formatters'
import type { ExpensePrefill } from './CostsPanel'
import type { BudgetItem } from '../../types'
// The split and receipt fields guard their own precision on every keystroke, so the
// guard has to follow the currency: a three-decimal one (KWD, BHD, …) seeds three
@@ -264,3 +266,59 @@ export function calculateTicketShares(items: TicketItem[]): { shares: Record<num
return { shares: finalShares, total: totalCents / 100 }
}
/**
* Where a new expense's editor starts from a prefill: a booking's price, or a
* scanned receipt, which also brings its currency, day, lines and photo. Both
* editors (the desktop modal and the phone sheet) seed from this one place.
*
* A prefill without a currency is in `base`, which is also what the currency
* field starts on. Receipt lines seed the Ticket split, shared by everyone like
* a line added by hand, while the split itself stays Equally until the person
* switches.
*/
export function newExpenseSeed(prefill: ExpensePrefill | undefined, base: string, peopleIds: number[], today: string) {
const currency = (prefill?.currency || base).toUpperCase()
return {
currency,
day: prefill?.date || today,
total: prefill?.amount != null ? amountToInputString(prefill.amount, currency) : '',
ticketItems: (prefill?.lines ?? []).map((line, i): TicketItem => ({
id: `receipt-${i}`,
name: line.name,
price: amountToInputString(line.price, currency),
participants: new Set(peopleIds),
})),
receiptFiles: prefill?.receiptFiles ?? [],
}
}
/** A scanned receipt as the prefill of a new expense, with its photo to attach on save. */
export function receiptToPrefill(receipt: ReceiptRead, photos: File[]): ExpensePrefill {
return {
name: receipt.merchant ?? undefined,
amount: receipt.total ?? undefined,
currency: receipt.currency ?? undefined,
date: receipt.date ?? undefined,
lines: receipt.items,
receiptFiles: photos,
}
}
/**
* The expense editor a trip page shows, if any. Two things open it: a booking's
* Costs block (edit that expense, or start one from the booking's price) and a
* receipt scanned from Costs. The booking wins, since it is what the person just
* clicked; `key` remounts the editor when one hands over to the other, so each
* starts from its own seed.
*/
export function expenseEditorFor(
booking: { editing: BudgetItem | null; prefill?: ExpensePrefill } | null,
closeBooking: () => void,
receipt: ExpensePrefill | null,
closeReceipt: () => void,
): { key: string; editing: BudgetItem | null; prefill?: ExpensePrefill; close: () => void } | null {
if (booking) return { key: 'booking', editing: booking.editing, prefill: booking.prefill, close: closeBooking }
if (receipt) return { key: 'receipt', editing: null, prefill: receipt, close: closeReceipt }
return null
}
@@ -1625,6 +1625,33 @@ describe('CostsPanel — expense modal', () => {
expect(onSaved).toHaveBeenCalled()
})
it('FE-W5COSTS-081: offers Scan receipt beside Add expense only when the AI model reads images', async () => {
server.use(http.get('/api/llm/capabilities', () => HttpResponse.json({ images: true })))
const { unmount } = render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
fireEvent.click((await screen.findAllByRole('button', { name: 'Scan receipt' }))[0])
expect(screen.getByRole('dialog', { name: 'Scan a receipt' })).toBeInTheDocument()
unmount()
server.use(http.get('/api/llm/capabilities', () => HttpResponse.json({ images: false })))
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await screen.findAllByRole('button', { name: 'Add expense' })
expect(screen.queryByRole('button', { name: 'Scan receipt' })).not.toBeInTheDocument()
})
it('FE-W5COSTS-080: a scanned receipt opens in its own currency and day, with its photo waiting to be attached', () => {
const photo = new File(['x'], 'bill.jpg', { type: 'image/jpeg' })
render(
<ExpenseModal tripId={1} base="EUR" people={tripMembers} me={1} editing={null}
prefill={{ name: 'Sushi Dai', amount: 4200, currency: 'JPY', date: '2026-09-20', lines: [{ name: 'Omakase', price: 4200 }], receiptFiles: [photo] }}
onClose={() => {}} onSaved={() => {}} />
)
expect(screen.getByDisplayValue('Sushi Dai')).toBeInTheDocument()
// Yen has no minor unit, so the amount is not padded.
expect(screen.getByDisplayValue('4200')).toBeInTheDocument()
expect(screen.getByText('bill.jpg')).toBeInTheDocument()
})
// The mobile sheet already dates a new expense by the traveller's own clock;
// the desktop modal filed it under the UTC day, so the same expense entered
// late in Tokyo or early in Los Angeles landed on a different day per surface.
+29 -12
View File
@@ -1,6 +1,6 @@
import { Fragment, useState, useEffect, useMemo, useCallback } from 'react'
import { useSearchParams } from 'react-router'
import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2, AlertCircle, Download, StickyNote, ChevronDown, Receipt, Paperclip } from 'lucide-react'
import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2, AlertCircle, Download, StickyNote, ChevronDown, Receipt, Paperclip, ScanLine } from 'lucide-react'
import { useTripStore } from '../../store/tripStore'
import { useAuthStore } from '../../store/authStore'
import { useSettingsStore } from '../../store/settingsStore'
@@ -19,11 +19,13 @@ import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
import { localToday } from '../Planner/today'
import { useReceiptScan } from './useReceiptScan'
import { ReceiptScanModal } from './ReceiptScanModal'
import { SYMBOLS, currenciesWith, SPLIT_COLORS } from './BudgetPanel.constants'
import { amountPattern, calculateTicketShares, finalBudgetFor, finalBudgetSources, hasTicketSplit, NOTE_MAX, paidByUser, payersBalanced, readTicketItems, readUserNote, rebalancePayers, settlementDate, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers'
import { amountPattern, calculateTicketShares, finalBudgetFor, finalBudgetSources, hasTicketSplit, NOTE_MAX, paidByUser, payersBalanced, readTicketItems, newExpenseSeed, readUserNote, rebalancePayers, settlementDate, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers'
import { COST_CATEGORY_LIST, catMeta } from './costsCategories'
import { ReceiptPreviewModal } from './ReceiptPreviewModal'
import type { BudgetParticipantFinal, BudgetUnconverted } from '@trek/shared'
import type { BudgetParticipantFinal, BudgetUnconverted, ReceiptLine } from '@trek/shared'
import type { BudgetItem, BudgetItemReceipt } from '../../types'
import type { TripMember } from './BudgetPanelMemberChips'
import GuestBadge from '../shared/GuestBadge'
@@ -83,6 +85,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const me = useAuthStore(s => s.user?.id ?? -1)
const can = useCanDo()
const canEdit = can('budget_edit', trip)
const receiptScan = useReceiptScan(tripId, canEdit)
const toast = useToast()
const { t, locale } = useTranslation()
const isMobile = useIsMobile()
@@ -387,7 +390,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<div style={{ maxWidth: '100%', margin: '0 auto' }}>
<CostsToolbar dateMeta={dateMeta} people={people} me={me} colorFor={colorFor}
canEdit={canEdit} canSettle={(settlement?.flows || []).length > 0}
onSettleAll={settleAll} onAddExpense={() => { setEditing(null); setModalOpen(true) }} />
onSettleAll={settleAll} onAddExpense={() => { setEditing(null); setModalOpen(true) }}
onScanReceipt={receiptScan.offered ? receiptScan.open : undefined} />
{/* ── Summary cards ── */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16, marginBottom: 36 }} className="costs-summary">
@@ -509,6 +513,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
</div>
</div>)}
<ReceiptScanModal scan={receiptScan} />
{modalOpen && (
<ExpenseModal tripId={tripId} base={base} people={people} me={me} editing={editing}
onClose={() => setModalOpen(false)}
@@ -634,6 +640,13 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<Plus size={17} /> {t('costs.addExpense')}
</button>
)}
{receiptScan.offered && (
<button type="button" onClick={receiptScan.open}
style={{ marginTop: 8, width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, background: 'transparent', border: '1px solid rgba(255,255,255,0.16)', color: '#fff', padding: 11, borderRadius: 14, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit' }} // theme-lint-disable — on the dark total card, drawn like Add expense above it
>
<ScanLine size={16} /> {t('costs.scan.button')}
</button>
)}
</section>
{/* Owe / Owed */}
@@ -1237,6 +1250,11 @@ export interface ExpensePrefill {
reservationId?: number
/** Set when the expense is being created from a place (#1298). */
placeId?: number
/** The rest comes from a scanned receipt: its currency, day and lines, and the photo, attached on save. */
currency?: string
date?: string
lines?: ReceiptLine[]
receiptFiles?: File[]
}
export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClose, onSaved }: {
@@ -1252,17 +1270,16 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
const [name, setName] = useState(editing?.name || prefill?.name || '')
const [cat, setCat] = useState<string>(editing ? catMeta(editing.category).key : (prefill?.category || 'food'))
const [currency, setCurrency] = useState(editingCurrency)
const [day, setDay] = useState(editing?.expense_date || localToday())
const [seed] = useState(() => newExpenseSeed(prefill, base, people.map(p => p.id), localToday()))
const [currency, setCurrency] = useState(editing ? editingCurrency : seed.currency)
const [day, setDay] = useState(editing ? (editing.expense_date || localToday()) : seed.day)
const [note, setNote] = useState(() => readUserNote(editing))
// Edit and prefill seeds are padded to the currency's decimals (#2175): the DB
// returns numbers, so a saved 4,90 would otherwise reopen as "4,9" and a saved
// 5,00 as "5". A prefill has no currency of its own — it is read as `base`,
// which is also what the currency field starts on.
// 5,00 as "5".
const [total, setTotal] = useState<string>(() => {
if (editing) return editing.total_price ? amountToInputString(editing.total_price, editingCurrency) : ''
if (prefill?.amount != null) return amountToInputString(prefill.amount, base)
return ''
return seed.total
})
const [participants, setParticipants] = useState<Set<number>>(() =>
editing ? new Set((editing.members || []).map(m => m.user_id)) : new Set(people.map(p => p.id)))
@@ -1303,7 +1320,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
return 'equally'
})
const [ticketItems, setTicketItems] = useState<TicketItem[]>(() => readTicketItems(editing))
const [ticketItems, setTicketItems] = useState<TicketItem[]>(() => editing ? readTicketItems(editing) : seed.ticketItems)
const [customAmounts, setCustomAmounts] = useState<Record<number, string>>(() => {
const m: Record<number, string> = {}
@@ -1318,7 +1335,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
})
const [receipts, setReceipts] = useState<BudgetItemReceipt[]>(() => editing?.receipts || [])
const [pendingReceiptFiles, setPendingReceiptFiles] = useState<File[]>([])
const [pendingReceiptFiles, setPendingReceiptFiles] = useState<File[]>(() => editing ? [] : seed.receiptFiles)
const [uploadingReceipt, setUploadingReceipt] = useState(false)
const [modalPreviewReceipts, setModalPreviewReceipts] = useState<{ receipts: BudgetItemReceipt[]; initialIndex: number } | null>(null)
@@ -1,4 +1,4 @@
// FE-COSTSBAR-001 to FE-COSTSBAR-007
// FE-COSTSBAR-001 to FE-COSTSBAR-008
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '../../../tests/helpers/render'
import CostsToolbar from './CostsToolbar'
@@ -66,4 +66,18 @@ describe('CostsToolbar', () => {
expect(screen.queryByRole('button')).toBeNull()
expect(screen.getByText('8 days')).toBeInTheDocument()
})
it('FE-COSTSBAR-008: offers Scan receipt only when handed a way to open it', () => {
const onScanReceipt = vi.fn()
const { unmount } = render(
<CostsToolbar dateMeta={null} people={PEOPLE} me={1} colorFor={() => '#000'} canEdit canSettle
onSettleAll={vi.fn()} onAddExpense={vi.fn()} onScanReceipt={onScanReceipt} />,
)
fireEvent.click(screen.getByRole('button', { name: 'Scan receipt' }))
expect(onScanReceipt).toHaveBeenCalledTimes(1)
unmount()
setup()
expect(screen.queryByRole('button', { name: 'Scan receipt' })).toBeNull()
})
})
+10 -2
View File
@@ -1,4 +1,4 @@
import { Check, Plus } from 'lucide-react'
import { Check, Plus, ScanLine } from 'lucide-react'
import { useTranslation } from '../../i18n'
import type { TripMember } from './BudgetPanelMemberChips'
@@ -13,6 +13,8 @@ interface CostsToolbarProps {
canSettle: boolean
onSettleAll: () => void
onAddExpense: () => void
/** Opens the receipt scan; left out when the AI model reads no images. */
onScanReceipt?: () => void
}
const BODY_SIZE = 'calc(13px * var(--fs-scale-body, 1))'
@@ -22,7 +24,7 @@ const BODY_SIZE = 'calc(13px * var(--fs-scale-body, 1))'
* Bookings, Lists and Files open with: the tab's name, then what the numbers
* cover (the trip's days and who travels), and the two actions on the right.
*/
export default function CostsToolbar({ dateMeta, people, me, colorFor, canEdit, canSettle, onSettleAll, onAddExpense }: CostsToolbarProps) {
export default function CostsToolbar({ dateMeta, people, me, colorFor, canEdit, canSettle, onSettleAll, onAddExpense, onScanReceipt }: CostsToolbarProps) {
const { t } = useTranslation()
const chip = 'inline-flex items-center whitespace-nowrap rounded-full bg-surface-card px-3 py-1.5 font-medium text-content-muted shadow-sm'
const button = 'inline-flex items-center gap-1.5 rounded-[10px] border-0 px-[14px] py-[9px] font-medium hover:opacity-[0.88]'
@@ -64,6 +66,12 @@ export default function CostsToolbar({ dateMeta, people, me, colorFor, canEdit,
<Check size={14} strokeWidth={2.5} />
{t('costs.settleUp')}
</button>
{onScanReceipt && (
<button type="button" onClick={onScanReceipt} className={`${button} bg-surface-card text-content`}>
<ScanLine size={14} strokeWidth={2.5} />
{t('costs.scan.button')}
</button>
)}
<button type="button" onClick={onAddExpense} className={`${button} bg-accent text-accent-text`}>
<Plus size={14} strokeWidth={2.5} />
{t('costs.addExpense')}
@@ -0,0 +1,148 @@
// FE-BUDGET-SCAN-001 to FE-BUDGET-SCAN-011
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { http, HttpResponse } from 'msw'
import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render'
import { server } from '../../../tests/helpers/msw/server'
import { saveImportFiles } from '../../db/offlineDb'
import { useBackgroundTasksStore } from '../../store/backgroundTasksStore'
import { useReceiptScan } from './useReceiptScan'
import { ReceiptScanModal } from './ReceiptScanModal'
import MReceiptScanButton from '../../mobile/screens/trip/tabs/MReceiptScanButton'
vi.mock('../../db/offlineDb', () => ({ saveImportFiles: vi.fn(async () => {}) }))
function Harness({ canEdit = true }: { canEdit?: boolean }) {
const scan = useReceiptScan(7, canEdit)
return (
<>
{scan.offered && <button type="button" onClick={scan.open}>Scan receipt</button>}
<ReceiptScanModal scan={scan} />
</>
)
}
const input = () => screen.getByTestId('receipt-scan-input') as HTMLInputElement
const photo = (name = 'bill.jpg', size = 4) => {
const f = new File(['x'.repeat(Math.min(size, 16))], name, { type: 'image/jpeg' })
if (size > 16) Object.defineProperty(f, 'size', { value: size })
return f
}
async function openDialog() {
fireEvent.click(await screen.findByRole('button', { name: 'Scan receipt' }))
return screen.getByRole('dialog', { name: 'Scan a receipt' })
}
beforeEach(() => {
vi.mocked(saveImportFiles).mockClear()
useBackgroundTasksStore.setState({ tasks: [] })
server.use(
http.get('/api/llm/capabilities', () => HttpResponse.json({ images: true })),
http.post('/api/trips/7/budget/receipt-scan', () => HttpResponse.json({ jobId: 'job-5' })),
)
})
describe('Scan receipt', () => {
it('FE-BUDGET-SCAN-001: offers the scan when the model reads images, and opens a dialog saying what it takes', async () => {
render(<Harness />)
await openDialog()
expect(screen.getByText(/A photo of one receipt \(JPG, PNG or WEBP, up to 10 MB\)/)).toBeInTheDocument()
expect(input().accept).toContain('image/jpeg')
expect(screen.getByRole('button', { name: 'Scan' })).toBeDisabled()
})
it('FE-BUDGET-SCAN-002: offers nothing, and asks nothing, without budget_edit', async () => {
let asked = false
server.use(http.get('/api/llm/capabilities', () => { asked = true; return HttpResponse.json({ images: true }) }))
render(<Harness canEdit={false} />)
await new Promise(r => setTimeout(r, 20))
expect(asked).toBe(false)
expect(screen.queryByRole('button', { name: 'Scan receipt' })).not.toBeInTheDocument()
})
it('FE-BUDGET-SCAN-003: offers nothing when the model reads no images', async () => {
server.use(http.get('/api/llm/capabilities', () => HttpResponse.json({ images: false })))
render(<Harness />)
await new Promise(r => setTimeout(r, 20))
expect(screen.queryByRole('button', { name: 'Scan receipt' })).not.toBeInTheDocument()
})
it('FE-BUDGET-SCAN-004: a picked photo is named, then Scan starts a costs job the widget follows and closes the dialog', async () => {
render(<Harness />)
await openDialog()
const picked = photo()
fireEvent.change(input(), { target: { files: [picked] } })
expect(screen.getByText('bill.jpg')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Scan' }))
await waitFor(() => expect(useBackgroundTasksStore.getState().tasks[0]).toMatchObject({ id: 'job-5', tripId: '7', label: 'bill.jpg', kind: 'costs', status: 'running' }))
expect(saveImportFiles).toHaveBeenCalledWith('job-5', [picked])
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
})
it('FE-BUDGET-SCAN-005: a dropped photo is taken like a picked one', async () => {
render(<Harness />)
await openDialog()
const zone = screen.getByText('Drop the receipt photo here, or click to pick or take one').closest('button')!
fireEvent.dragOver(zone)
expect(screen.getByText('Drop the photo to scan it')).toBeInTheDocument()
fireEvent.drop(zone, { dataTransfer: { files: [photo('drop.png')] } })
expect(screen.getByText('drop.png')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Scan' })).toBeEnabled()
})
it('FE-BUDGET-SCAN-006: refuses what is not a photo, and a photo over 10 MB, before anything is sent', async () => {
let sent = false
server.use(http.post('/api/trips/7/budget/receipt-scan', () => { sent = true; return HttpResponse.json({ jobId: 'x' }) }))
render(<Harness />)
await openDialog()
fireEvent.change(input(), { target: { files: [new File(['%PDF'], 'bill.pdf', { type: 'application/pdf' })] } })
expect(screen.getByRole('alert')).toHaveTextContent('Only a photo can be scanned: JPG, PNG or WEBP.')
fireEvent.change(input(), { target: { files: [photo('big.jpg', 11 * 1024 * 1024)] } })
expect(screen.getByRole('alert')).toHaveTextContent('File "big.jpg" exceeds 10 MB limit.')
expect(screen.getByRole('button', { name: 'Scan' })).toBeDisabled()
expect(sent).toBe(false)
})
it('FE-BUDGET-SCAN-007: says why when the server refuses, and keeps the dialog open', async () => {
server.use(http.post('/api/trips/7/budget/receipt-scan', () => HttpResponse.json({ error: 'The configured AI model does not read photos' }, { status: 400 })))
render(<Harness />)
await openDialog()
fireEvent.change(input(), { target: { files: [photo()] } })
fireEvent.click(screen.getByRole('button', { name: 'Scan' }))
expect(await screen.findByRole('alert')).toHaveTextContent('The configured AI model does not read photos')
expect(screen.getByRole('dialog')).toBeInTheDocument()
expect(useBackgroundTasksStore.getState().tasks).toEqual([])
})
it('FE-BUDGET-SCAN-008: Cancel, the close button and the backdrop close it; reopening starts empty', async () => {
render(<Harness />)
const dialog = await openDialog()
fireEvent.change(input(), { target: { files: [photo()] } })
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
await openDialog()
expect(screen.queryByText('bill.jpg')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
await openDialog()
fireEvent.click(screen.getByRole('dialog').parentElement!)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(dialog).not.toBeInTheDocument()
})
})
describe('MReceiptScanButton', () => {
it('FE-BUDGET-SCAN-009: the phone header button opens the same dialog', async () => {
render(<MReceiptScanButton tripId={7} canEdit />)
fireEvent.click(await screen.findByRole('button', { name: 'Scan receipt' }))
expect(screen.getByRole('dialog', { name: 'Scan a receipt' })).toBeInTheDocument()
})
it('FE-BUDGET-SCAN-010: draws nothing when the scan is not offered', async () => {
const { container } = render(<MReceiptScanButton tripId={7} canEdit={false} />)
await new Promise(r => setTimeout(r, 20))
expect(container).toBeEmptyDOMElement()
})
})
@@ -0,0 +1,67 @@
import { createPortal } from 'react-dom'
import { useRef, useState } from 'react'
import { ScanLine, X } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { RECEIPT_PHOTO_ACCEPT, type ReceiptScan } from './useReceiptScan'
/**
* The dialog behind "Scan receipt", laid out like the booking import's: what it
* takes, a drop zone that is also the picker (on a phone the picker offers the
* camera), then Scan, which hands the photo to the background read and closes.
*/
export function ReceiptScanModal({ scan }: { scan: ReceiptScan }) {
const { t } = useTranslation()
const inputRef = useRef<HTMLInputElement>(null)
const [dragOver, setDragOver] = useState(false)
if (!scan.isOpen) return null
const onDrop = (e: React.DragEvent) => {
e.preventDefault()
setDragOver(false)
scan.choose(Array.from(e.dataTransfer.files))
}
return createPortal(
<div role="presentation" className="fixed inset-0 z-[99999] flex items-center justify-center bg-black/40 p-4" onClick={(e) => { if (e.target === e.currentTarget) scan.close() }}>
<div role="dialog" aria-modal="true" aria-label={t('costs.scan.title')} className="flex max-h-[90vh] w-full max-w-[540px] flex-col rounded-2xl bg-surface-card p-6 shadow-2xl">
<div className="mb-3.5 flex items-center gap-2">
<h2 className="flex-1 text-subtitle font-bold text-content">{t('costs.scan.title')}</h2>
<button type="button" onClick={scan.close} aria-label={t('common.close')} className="flex rounded-md p-1 text-content-faint">
<X size={16} />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
<p className="mb-3.5 text-caption leading-snug text-content-faint">{t('costs.scan.accepted')}</p>
<input ref={inputRef} type="file" accept={RECEIPT_PHOTO_ACCEPT} hidden data-testid="receipt-scan-input"
onChange={(e) => { scan.choose(e.target.files ? Array.from(e.target.files) : []); e.target.value = '' }} />
<button type="button" onClick={() => inputRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
onDragEnter={(e) => { e.preventDefault(); setDragOver(true) }}
onDragLeave={(e) => { if (e.target === e.currentTarget) setDragOver(false) }}
onDrop={onDrop}
className={`mb-3 flex min-h-[100px] w-full flex-col items-center justify-center gap-1.5 rounded-xl border-2 border-dashed p-4 text-body font-medium transition-colors ${dragOver ? 'border-accent bg-surface-tertiary' : 'border-edge bg-transparent'}`}>
<ScanLine size={18} strokeWidth={1.8} className={`pointer-events-none ${dragOver ? 'text-accent' : 'text-content-faint'}`} />
<span className={`pointer-events-none break-all text-center ${scan.photo ? 'text-content' : 'text-content-faint'}`}>
{dragOver ? t('costs.scan.dropActive') : scan.photo ? scan.photo.name : t('costs.scan.dropHere')}
</span>
</button>
{scan.error && (
<div role="alert" className="mt-2 rounded-lg border border-danger bg-danger-soft px-2.5 py-2 text-caption text-danger">{scan.error}</div>
)}
</div>
<div className="mt-3.5 flex justify-end gap-2 border-t border-edge-secondary pt-3.5">
<button type="button" onClick={scan.close} className="rounded-lg border border-edge px-4 py-2 text-body font-medium text-content">
{t('common.cancel')}
</button>
<button type="button" onClick={scan.start} disabled={!scan.photo || scan.starting}
className={`rounded-lg px-4 py-2 text-body font-medium ${scan.photo && !scan.starting ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-faint'}`}>
{scan.starting ? t('costs.scan.reading') : t('costs.scan.start')}
</button>
</div>
</div>
</div>,
document.body,
)
}
@@ -0,0 +1,71 @@
import { useEffect, useState } from 'react'
import { useTranslation } from '../../i18n'
import { llmRepo } from '../../repo/llmRepo'
import { saveImportFiles } from '../../db/offlineDb'
import { useBackgroundTasksStore } from '../../store/backgroundTasksStore'
import { LLM_PHOTO_EXTENSIONS } from '@trek/shared'
/** The picker's filter: the extensions, and the MIME types a phone's camera roll matches on. */
export const RECEIPT_PHOTO_ACCEPT = [...LLM_PHOTO_EXTENSIONS, 'image/jpeg', 'image/png', 'image/webp'].join(',')
const MAX_PHOTO_BYTES = 10 * 1024 * 1024
/**
* "Scan a receipt" in Costs, for both shells. The button is only offered when
* the person may add expenses and their AI model reads images. It opens a
* dialog like the booking import's; the picked photo is read in the background
* like a booking import too: the background tasks widget follows it, and its
* review opens the expense editor pre-filled, with the photo waiting to be
* attached on save. The photo is kept in IndexedDB so a reload mid-read still
* has it for that review.
*/
export function useReceiptScan(tripId: number, canEdit: boolean) {
const { t } = useTranslation()
const addTask = useBackgroundTasksStore((s) => s.addTask)
const [readsPhotos, setReadsPhotos] = useState(false)
const [isOpen, setIsOpen] = useState(false)
const [photo, setPhoto] = useState<File | null>(null)
const [error, setError] = useState('')
const [starting, setStarting] = useState(false)
useEffect(() => {
if (!canEdit) return
let live = true
llmRepo.readsPhotos().then((v) => { if (live) setReadsPhotos(v) }).catch(() => { if (live) setReadsPhotos(false) })
return () => { live = false }
}, [canEdit])
const open = () => { setPhoto(null); setError(''); setIsOpen(true) }
const close = () => { if (!starting) setIsOpen(false) }
/** Take the first of the picked or dropped files, if it is a photo the model can read. */
const choose = (files: File[]) => {
const picked = files[0]
if (!picked) return
const ext = '.' + (picked.name.toLowerCase().split('.').pop() ?? '')
if (!(LLM_PHOTO_EXTENSIONS as readonly string[]).includes(ext)) { setError(t('costs.scan.unsupported')); return }
if (picked.size > MAX_PHOTO_BYTES) { setError(t('reservations.import.fileTooLarge', { name: picked.name })); return }
setPhoto(picked)
setError('')
}
const start = async () => {
if (!photo || starting) return
setStarting(true)
setError('')
try {
const jobId = await llmRepo.scanReceipt(tripId, photo)
await saveImportFiles(jobId, [photo])
addTask({ id: jobId, tripId: String(tripId), label: photo.name, total: 1, files: [photo], kind: 'costs' })
setIsOpen(false)
} catch (err) {
const message = (err as { response?: { data?: { error?: string } } })?.response?.data?.error
setError(message ?? t('costs.scan.failed'))
} finally {
setStarting(false)
}
}
return { offered: canEdit && readsPhotos, isOpen, open, close, photo, choose, error, starting, start }
}
export type ReceiptScan = ReturnType<typeof useReceiptScan>
@@ -197,3 +197,38 @@ describe('BookingImportModal', () => {
expect(onClose).toHaveBeenCalledTimes(1);
});
});
describe('BookingImportModal with a model that reads images', () => {
const jpg = () => new File(['jpeg'], 'ticket.jpg', { type: 'image/jpeg' });
it('FE-PLANNER-BOOKIMP-020: offers photos, and imports one with AI, when the model reads images', async () => {
let mode: string | null = null;
server.use(
http.get('/api/health/features', () => HttpResponse.json({ bookingImport: true, aiParsing: true })),
http.get('/api/llm/capabilities', () => HttpResponse.json({ images: true })),
http.post('/api/trips/4/reservations/import/booking/async', async ({ request }) => {
mode = (await readMultipart(request)).fields.mode ?? null;
return HttpResponse.json({ jobId: 'job-2' });
}),
);
render(<BookingImportModal {...defaultProps} kind="transports" />);
expect(await screen.findByText(/Photos \(JPG, PNG, WEBP\) are read by the AI model\./)).toBeInTheDocument();
expect(fileInput().accept).toContain('.jpg');
fireEvent.change(fileInput(), { target: { files: [jpg()] } });
expect(screen.getByText('ticket.jpg')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Import' }));
await waitFor(() => expect(useBackgroundTasksStore.getState().tasks.find(t => t.id === 'job-2')).toMatchObject({ kind: 'transports' }));
expect(mode).toBe('fallback-on-empty');
});
it('FE-PLANNER-BOOKIMP-021: refuses a photo while the model reads no images', async () => {
server.use(http.get('/api/llm/capabilities', () => HttpResponse.json({ images: false })));
render(<BookingImportModal {...defaultProps} />);
await waitFor(() => expect(fileInput().accept).not.toContain('.jpg'));
fireEvent.change(fileInput(), { target: { files: [jpg()] } });
expect(screen.getByText('Unsupported file format. Use EML, PDF, PKPass, HTML, or TXT.')).toBeInTheDocument();
expect(screen.queryByText(/Photos \(JPG/)).not.toBeInTheDocument();
});
});
@@ -3,6 +3,8 @@ import { useState, useRef, useEffect } from 'react'
import { Upload, X } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { reservationsApi, healthApi } from '../../api/client'
import { llmRepo } from '../../repo/llmRepo'
import { LLM_PHOTO_EXTENSIONS } from '@trek/shared'
import { useBackgroundTasksStore } from '../../store/backgroundTasksStore'
import { saveImportFiles } from '../../db/offlineDb'
@@ -36,6 +38,9 @@ export default function BookingImportModal({ isOpen, onClose, tripId, kind }: Bo
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [aiParsing, setAiParsing] = useState(false)
const [readsPhotos, setReadsPhotos] = useState(false)
// Only a model that reads images can do anything with a photo, so photos are offered only then.
const acceptedExts: readonly string[] = readsPhotos ? [...ACCEPTED_EXTS, ...LLM_PHOTO_EXTENSIONS] : ACCEPTED_EXTS
const reset = () => {
setFiles([])
@@ -53,13 +58,14 @@ export default function BookingImportModal({ isOpen, onClose, tripId, kind }: Bo
useEffect(() => {
if (!isOpen) return
healthApi.features().then((f) => setAiParsing(!!f.aiParsing)).catch(() => setAiParsing(false))
llmRepo.readsPhotos().then(setReadsPhotos).catch(() => setReadsPhotos(false))
}, [isOpen])
const handleClose = () => { reset(); onClose() }
const validateFile = (f: File): string | null => {
const ext = ('.' + f.name.toLowerCase().split('.').pop()) as string
if (!ACCEPTED_EXTS.includes(ext)) return t('reservations.import.unsupportedFormat')
if (!acceptedExts.includes(ext)) return t('reservations.import.unsupportedFormat')
if (f.size > MAX_FILE_BYTES) return t('reservations.import.fileTooLarge', { name: f.name })
return null
}
@@ -143,12 +149,13 @@ export default function BookingImportModal({ isOpen, onClose, tripId, kind }: Bo
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-faint)', marginBottom: 14, lineHeight: 1.45 }}>
{t('reservations.import.acceptedFormats')}
{readsPhotos && <> {t('reservations.import.acceptedPhotos')}</>}
</div>
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_EXTS.join(',')}
accept={acceptedExts.join(',')}
multiple
style={{ display: 'none' }}
onChange={handleInputChange}
@@ -40,7 +40,7 @@ function renderSection() {
}
function multimodalToggle(): HTMLElement {
const row = screen.getByText('Send documents as images').parentElement as HTMLElement;
const row = screen.getByText('Model reads images').parentElement as HTMLElement;
return row.querySelector('button') as HTMLElement;
}