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.
+
+ )}
{searchInput && (