fix(bookkeeping): journal search finds a voucher by its label, with a spinner while searching (#1808)
"Sök verifikationstext" only matched journal_entries.description, so typing A209 never returned voucher A209 itself: only other vouchers whose text mentioned it. Users read that as "the voucher is missing". A label-shaped needle (A209, a 209, A-209) now also matches voucher_series + voucher_number via a PostgREST OR (needle double-quoted so commas/parentheses stay literal); other needles keep the plain description ILIKE. parseVoucher accepts one space or hyphen between series and number. The search box shows a spinner while a server-side search is in flight; before, the only signal was the list dimming. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
9622382579
commit
d4c42fd8db
@@ -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 })
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
<Loader2
|
||||
className="absolute right-7 top-1/2 h-3.5 w-3.5 -translate-y-1/2 animate-spin text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{searchInput && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -176,12 +176,19 @@ describe('parseVoucher', () => {
|
||||
expect(parseVoucher(' a5 ')).toEqual({ series: 'A', number: 5 })
|
||||
})
|
||||
|
||||
it('accepts one space or hyphen between series and number (how users type it in search)', () => {
|
||||
expect(parseVoucher('A 209')).toEqual({ series: 'A', number: 209 })
|
||||
expect(parseVoucher('A-209')).toEqual({ series: 'A', number: 209 })
|
||||
expect(parseVoucher('a-1')).toEqual({ series: 'A', number: 1 })
|
||||
})
|
||||
|
||||
it('returns null for malformed input', () => {
|
||||
expect(parseVoucher('')).toBeNull()
|
||||
expect(parseVoucher('-')).toBeNull()
|
||||
expect(parseVoucher('123')).toBeNull()
|
||||
expect(parseVoucher('AA1')).toBeNull()
|
||||
expect(parseVoucher('A0')).toBeNull()
|
||||
expect(parseVoucher('A-1')).toBeNull()
|
||||
expect(parseVoucher('A--1')).toBeNull()
|
||||
expect(parseVoucher('A 1')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -107,15 +107,17 @@ export function formatVoucher(entry: {
|
||||
|
||||
/**
|
||||
* Parse a formatted voucher label back into its parts. Returns null when the
|
||||
* input does not match the expected shape (single uppercase letter followed
|
||||
* by a positive integer). Use for filter inputs / search.
|
||||
* input does not match the expected shape: a single letter followed by a
|
||||
* positive integer, optionally separated by one space or hyphen ("A209",
|
||||
* "a 209", "A-209"). Use for filter inputs / search: these are the shapes
|
||||
* users type when they look for a voucher by its number.
|
||||
*/
|
||||
export function parseVoucher(
|
||||
formatted: string,
|
||||
): { series: string; number: number } | null {
|
||||
if (typeof formatted !== 'string') return null
|
||||
const trimmed = formatted.trim().toUpperCase()
|
||||
const match = trimmed.match(/^([A-Z])(\d+)$/)
|
||||
const match = trimmed.match(/^([A-Z])[ -]?(\d+)$/)
|
||||
if (!match) return null
|
||||
const number = parseInt(match[2], 10)
|
||||
if (!Number.isFinite(number) || number <= 0) return null
|
||||
|
||||
Reference in New Issue
Block a user