diff --git a/app/api/bookkeeping/journal-entries/__tests__/route.test.ts b/app/api/bookkeeping/journal-entries/__tests__/route.test.ts index 71018d56..58da0abf 100644 --- a/app/api/bookkeeping/journal-entries/__tests__/route.test.ts +++ b/app/api/bookkeeping/journal-entries/__tests__/route.test.ts @@ -156,6 +156,55 @@ describe('GET /api/bookkeeping/journal-entries', () => { expect(mockSupabase.rpc).not.toHaveBeenCalled() }) + it('matches a voucher label like "A209" on series+number as well as description', async () => { + enqueue({ data: [], error: null, count: 0 }) + + const request = createMockRequest('/api/bookkeeping/journal-entries', { + searchParams: { period_id: 'period-1', search: 'A209' }, + }) + const response = await GET(request) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(200) + const orCalls = findCalls('journal_entries', 'or') + expect(orCalls).toHaveLength(1) + expect(orCalls[0][0]).toBe( + 'description.ilike."%A209%",and(voucher_series.eq.A,voucher_number.eq.209)', + ) + // The plain ilike path must not ALSO run, or the OR would be ANDed away. + expect(findCalls('journal_entries', 'ilike')).toHaveLength(0) + }) + + it('accepts "a 209" and "A-209" as voucher labels', async () => { + for (const needle of ['a 209', 'A-209']) { + reset() + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + enqueue({ data: [], error: null, count: 0 }) + const request = createMockRequest('/api/bookkeeping/journal-entries', { + searchParams: { period_id: 'period-1', search: needle }, + }) + await GET(request) + const orCalls = findCalls('journal_entries', 'or') + expect(orCalls, needle).toHaveLength(1) + expect(String(orCalls[0][0])).toContain('and(voucher_series.eq.A,voucher_number.eq.209)') + } + }) + + it('keeps the plain description ilike for non-label needles', async () => { + enqueue({ data: [], error: null, count: 0 }) + + const request = createMockRequest('/api/bookkeeping/journal-entries', { + searchParams: { period_id: 'period-1', search: 'hyra, kvartal 1 (50%)' }, + }) + await GET(request) + + expect(findCalls('journal_entries', 'or')).toHaveLength(0) + const ilikeCalls = findCalls('journal_entries', 'ilike') + expect(ilikeCalls).toHaveLength(1) + expect(ilikeCalls[0][0]).toBe('description') + expect(ilikeCalls[0][1]).toBe('%hyra, kvartal 1 (50\\%)%') + }) + it('orders by the total_amount computed column on amount sort, bypassing the RPC', async () => { enqueue({ data: [], error: null, count: 0 }) diff --git a/app/api/bookkeeping/journal-entries/route.ts b/app/api/bookkeeping/journal-entries/route.ts index aa96296e..4feb6835 100644 --- a/app/api/bookkeeping/journal-entries/route.ts +++ b/app/api/bookkeeping/journal-entries/route.ts @@ -6,6 +6,7 @@ import { withRouteContext } from '@/lib/api/with-route-context' import { validateBody } from '@/lib/api/validate' import { CreateJournalEntrySchema } from '@/lib/api/schemas' import { escapeLikePattern } from '@/lib/invoices/duplicate-payment-guard' +import { parseVoucher } from '@/lib/bookkeeping/voucher-series-resolver' import { getErrorMessage } from '@/lib/errors/get-error-message' ensureInitialized() @@ -207,7 +208,21 @@ export const GET = withRouteContext('bookkeeping.journal_entries.list', async (r // The cap bounds DB work against oversized/pathological inputs (compliance // A.8.28 / ASVS V1.2.5); escaping prevents silent over-matching on values // like "50%". Supabase parameterises the value, so this is not about SQLi. - query = query.ilike('description', `%${escapeLikePattern(search)}%`) + const needle = `%${escapeLikePattern(search)}%` + // The first thing a user searches for is the voucher's own label ("A209"). + // A description-only match never finds it (only OTHER vouchers that + // mention A209 in their text), so a label-shaped needle also matches + // voucher_series + voucher_number. The OR is a PostgREST filter list, so + // the needle is double-quoted to keep commas/parentheses literal. + const voucher = parseVoucher(search) + if (voucher) { + const quotedNeedle = `"${needle.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` + query = query.or( + `description.ilike.${quotedNeedle},and(voucher_series.eq.${voucher.series},voucher_number.eq.${voucher.number})`, + ) + } else { + query = query.ilike('description', needle) + } } // Collapse correction groups (voucher-sort / search path): hide the storno diff --git a/components/bookkeeping/JournalEntryList.tsx b/components/bookkeeping/JournalEntryList.tsx index 32e12c8c..483b7eb9 100644 --- a/components/bookkeeping/JournalEntryList.tsx +++ b/components/bookkeeping/JournalEntryList.tsx @@ -1050,8 +1050,16 @@ export default function JournalEntryList({ value={searchInput} onChange={(e) => setSearchInput(e.target.value)} containerClassName="min-w-0 max-w-none" - className="pr-7" + className={cn('pr-7', loading && search && 'pr-12')} /> + {loading && search && ( + // Search is server-side and can take a moment on a large ledger; + // without this the only signal was the list dimming. +