feat: AR/AP navigation separation and bookkeeping improvements (#109)
* feat: separate AR/AP/accounting into distinct nav groups (#92) Split the flat "Finans" sidebar group into three visually distinct sections — Försäljning (AR), Inköp (AP), and Redovisning — so users coming from Fortnox immediately find customer invoicing and supplier invoices as top-level concepts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: journal entry detail view, correction chain, and account name display - Add journal entry detail page at /bookkeeping/[id] with full entry view - Add correction chain API and component showing storno relationships - Add JournalEntryStatusBadge component for entry status display - Show debit/credit account names in template picker and review dialogs - Expand client-side BAS account name mapping with additional accounts - Show account codes on transaction inbox suggestion buttons Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address review feedback — N+1 query, duplicate name, nav dedup - Batch reverse-lookup into single query per BFS iteration (was N+1) - Differentiate account 2393 from 2893 in display names - Extract shared loop for desktop/mobile nav group rendering Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
7ab789d95c
commit
4d4a9a40c9
@@ -0,0 +1,153 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
createMockRequest,
|
||||
parseJsonResponse,
|
||||
createMockRouteParams,
|
||||
makeJournalEntry,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
const mockCreateClient = vi.fn()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => mockCreateClient(),
|
||||
}))
|
||||
|
||||
import { GET } from '../route'
|
||||
|
||||
function buildMockSupabase({
|
||||
user = { id: 'user-1', email: 'test@test.se' },
|
||||
singleResult = null as ReturnType<typeof makeJournalEntry> | null,
|
||||
singleError = null as { message: string } | null,
|
||||
referencingIds = [] as { id: string }[],
|
||||
chainEntries = [] as ReturnType<typeof makeJournalEntry>[],
|
||||
} = {}) {
|
||||
const fromCalls: string[] = []
|
||||
|
||||
const mockFrom = vi.fn().mockImplementation((table: string) => {
|
||||
fromCalls.push(table)
|
||||
const callIndex = fromCalls.length
|
||||
|
||||
const builder: Record<string, ReturnType<typeof vi.fn>> = {}
|
||||
const chainFn = (name: string) => {
|
||||
builder[name] = vi.fn().mockReturnValue(builder)
|
||||
return builder[name]
|
||||
}
|
||||
|
||||
chainFn('select')
|
||||
chainFn('eq')
|
||||
chainFn('or')
|
||||
chainFn('in')
|
||||
chainFn('order')
|
||||
|
||||
// First call: single entry fetch
|
||||
if (callIndex === 1) {
|
||||
builder.single = vi.fn().mockResolvedValue({
|
||||
data: singleResult,
|
||||
error: singleError,
|
||||
})
|
||||
}
|
||||
|
||||
// Second call: reverse lookup for referencing entries
|
||||
if (callIndex === 2) {
|
||||
// The or() call resolves the query
|
||||
builder.or = vi.fn().mockReturnValue({
|
||||
then: (resolve: (v: unknown) => void) => resolve({ data: referencingIds }),
|
||||
}) as unknown as ReturnType<typeof vi.fn>
|
||||
// Make it thenable
|
||||
const orResult = { data: referencingIds }
|
||||
builder.or = vi.fn().mockResolvedValue(orResult)
|
||||
}
|
||||
|
||||
// Third+ calls: chain entries fetch
|
||||
if (callIndex >= 3) {
|
||||
builder.order = vi.fn().mockResolvedValue({ data: chainEntries })
|
||||
}
|
||||
|
||||
return builder
|
||||
})
|
||||
|
||||
return {
|
||||
auth: { getUser: vi.fn().mockResolvedValue({ data: { user } }) },
|
||||
from: mockFrom,
|
||||
}
|
||||
}
|
||||
|
||||
describe('GET /api/bookkeeping/journal-entries/[id]/chain', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockCreateClient.mockResolvedValue({
|
||||
auth: { getUser: vi.fn().mockResolvedValue({ data: { user: null } }) },
|
||||
})
|
||||
|
||||
const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/chain')
|
||||
const response = await GET(request, createMockRouteParams({ id: 'entry-1' }))
|
||||
const { status, body } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(401)
|
||||
expect(body).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('returns 404 when entry not found', async () => {
|
||||
mockCreateClient.mockResolvedValue(
|
||||
buildMockSupabase({ singleError: { message: 'not found' } })
|
||||
)
|
||||
|
||||
const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/chain')
|
||||
const response = await GET(request, createMockRouteParams({ id: 'entry-1' }))
|
||||
const { status, body } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(404)
|
||||
expect(body).toEqual({ error: 'Entry not found' })
|
||||
})
|
||||
|
||||
it('returns entry with empty chain for standalone entry', async () => {
|
||||
const entry = makeJournalEntry({ id: 'entry-1', status: 'posted' })
|
||||
mockCreateClient.mockResolvedValue(
|
||||
buildMockSupabase({ singleResult: entry, referencingIds: [] })
|
||||
)
|
||||
|
||||
const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/chain')
|
||||
const response = await GET(request, createMockRouteParams({ id: 'entry-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ data: { entry: unknown; chain: unknown[] } }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.entry).toEqual(entry)
|
||||
expect(body.data.chain).toEqual([])
|
||||
})
|
||||
|
||||
it('returns entry with chain for corrected entry', async () => {
|
||||
const original = makeJournalEntry({
|
||||
id: 'entry-1',
|
||||
status: 'reversed',
|
||||
reversed_by_id: 'storno-1',
|
||||
})
|
||||
const storno = makeJournalEntry({
|
||||
id: 'storno-1',
|
||||
source_type: 'storno',
|
||||
reverses_id: 'entry-1',
|
||||
})
|
||||
const correction = makeJournalEntry({
|
||||
id: 'correction-1',
|
||||
source_type: 'correction',
|
||||
correction_of_id: 'entry-1',
|
||||
})
|
||||
|
||||
mockCreateClient.mockResolvedValue(
|
||||
buildMockSupabase({
|
||||
singleResult: original,
|
||||
referencingIds: [{ id: 'storno-1' }, { id: 'correction-1' }],
|
||||
chainEntries: [storno, correction],
|
||||
})
|
||||
)
|
||||
|
||||
const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/chain')
|
||||
const response = await GET(request, createMockRouteParams({ id: 'entry-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ data: { entry: unknown; chain: unknown[] } }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.entry).toEqual(original)
|
||||
expect(body.data.chain).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,110 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Fetch the requested entry with lines
|
||||
const { data: entry, error } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('*, lines:journal_entry_lines(*)')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (error || !entry) {
|
||||
return NextResponse.json({ error: 'Entry not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Collect all related entry IDs by following FK links iteratively
|
||||
const visited = new Set<string>([id])
|
||||
const toVisit = new Set<string>()
|
||||
|
||||
// Seed with direct FK references from this entry
|
||||
for (const fk of [entry.reverses_id, entry.reversed_by_id, entry.correction_of_id]) {
|
||||
if (fk && !visited.has(fk)) toVisit.add(fk)
|
||||
}
|
||||
|
||||
// Also find entries that reference this entry (reverse lookup)
|
||||
const { data: referencing } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('id')
|
||||
.eq('user_id', user.id)
|
||||
.or(`reverses_id.eq.${id},reversed_by_id.eq.${id},correction_of_id.eq.${id}`)
|
||||
|
||||
if (referencing) {
|
||||
for (const r of referencing) {
|
||||
if (!visited.has(r.id)) toVisit.add(r.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Iteratively expand (bounded) to handle multi-level correction chains
|
||||
const MAX_ITERATIONS = 10
|
||||
for (let i = 0; i < MAX_ITERATIONS && toVisit.size > 0; i++) {
|
||||
const batch = Array.from(toVisit)
|
||||
toVisit.clear()
|
||||
for (const bid of batch) visited.add(bid)
|
||||
|
||||
const { data: batchEntries } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('id, reverses_id, reversed_by_id, correction_of_id')
|
||||
.eq('user_id', user.id)
|
||||
.in('id', batch)
|
||||
|
||||
if (!batchEntries) continue
|
||||
|
||||
// Collect FK references from forward links
|
||||
for (const e of batchEntries) {
|
||||
for (const fk of [e.reverses_id, e.reversed_by_id, e.correction_of_id]) {
|
||||
if (fk && !visited.has(fk)) toVisit.add(fk)
|
||||
}
|
||||
}
|
||||
|
||||
// Single reverse-lookup for the whole batch instead of one per entry
|
||||
const batchOr = batch
|
||||
.flatMap(bid => [
|
||||
`reverses_id.eq.${bid}`,
|
||||
`reversed_by_id.eq.${bid}`,
|
||||
`correction_of_id.eq.${bid}`,
|
||||
])
|
||||
.join(',')
|
||||
|
||||
const { data: refs } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('id')
|
||||
.eq('user_id', user.id)
|
||||
.or(batchOr)
|
||||
|
||||
if (refs) {
|
||||
for (const r of refs) {
|
||||
if (!visited.has(r.id)) toVisit.add(r.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch all chain entries (excluding the main entry itself) with lines
|
||||
const chainIds = Array.from(visited).filter((cid) => cid !== id)
|
||||
let chain: typeof entry[] = []
|
||||
|
||||
if (chainIds.length > 0) {
|
||||
const { data: chainEntries } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('*, lines:journal_entry_lines(*)')
|
||||
.eq('user_id', user.id)
|
||||
.in('id', chainIds)
|
||||
.order('created_at', { ascending: true })
|
||||
|
||||
chain = chainEntries || []
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: { entry, chain } })
|
||||
}
|
||||
Reference in New Issue
Block a user