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:
Jakob Wennberg
2026-03-23 20:01:07 +01:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 7ab789d95c
commit 4d4a9a40c9
15 changed files with 942 additions and 114 deletions
+315
View File
@@ -0,0 +1,315 @@
'use client'
import { useState, useEffect, useCallback, use } from 'react'
import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { AccountNumber } from '@/components/ui/account-number'
import { Loader2, ArrowLeft, Paperclip, AlertTriangle } from 'lucide-react'
import JournalEntryAttachments from '@/components/bookkeeping/JournalEntryAttachments'
import JournalEntryStatusBadge, { sourceTypeLabels } from '@/components/bookkeeping/JournalEntryStatusBadge'
import CorrectionEntryDialog from '@/components/bookkeeping/CorrectionEntryDialog'
import CorrectionChain from '@/components/bookkeeping/CorrectionChain'
import type { JournalEntry, JournalEntryLine } from '@/types'
export default function JournalEntryDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params)
const [entry, setEntry] = useState<JournalEntry | null>(null)
const [chain, setChain] = useState<JournalEntry[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showCorrection, setShowCorrection] = useState(false)
const [attachmentCount, setAttachmentCount] = useState(0)
const fetchData = useCallback(async () => {
setIsLoading(true)
setError(null)
try {
const res = await fetch(`/api/bookkeeping/journal-entries/${id}/chain`)
if (!res.ok) {
const { error: msg } = await res.json()
setError(msg || 'Kunde inte hämta verifikation')
return
}
const { data } = await res.json()
setEntry(data.entry)
setChain(data.chain)
} catch {
setError('Kunde inte hämta verifikation')
} finally {
setIsLoading(false)
}
}, [id])
useEffect(() => {
fetchData()
}, [fetchData])
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center py-24">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">Laddar verifikation...</p>
</div>
)
}
if (error || !entry) {
return (
<div className="space-y-4">
<Link
href="/bookkeeping"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4" />
Tillbaka till bokföring
</Link>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<p className="text-sm text-muted-foreground">{error || 'Verifikation hittades inte'}</p>
</CardContent>
</Card>
</div>
)
}
const lines = ((entry.lines || []) as JournalEntryLine[])
.slice()
.sort((a, b) => a.sort_order - b.sort_order)
const totalDebit = lines.reduce((sum, l) => sum + (Number(l.debit_amount) || 0), 0)
const totalCredit = lines.reduce((sum, l) => sum + (Number(l.credit_amount) || 0), 0)
const canCorrect =
entry.status === 'posted' &&
entry.source_type !== 'storno' &&
entry.source_type !== 'correction'
// Include current entry in the chain for the visualization
const fullChain = [entry, ...chain]
return (
<div className="space-y-6">
{/* Back link */}
<Link
href="/bookkeeping"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4" />
Tillbaka till bokföring
</Link>
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight font-mono">
{entry.voucher_series}{entry.voucher_number}
</h1>
<JournalEntryStatusBadge entry={entry} />
</div>
<p className="text-muted-foreground">{entry.description}</p>
</div>
{canCorrect && (
<Button
variant="outline"
size="sm"
className="w-full sm:w-auto"
onClick={() => setShowCorrection(true)}
>
Skapa ändringsverifikation
</Button>
)}
</div>
{/* Info cards */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Verifikationsdetaljer</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Datum</span>
<span>{entry.entry_date}</span>
</div>
{entry.committed_at && (
<div className="flex justify-between">
<span className="text-muted-foreground">Bokförd</span>
<span>{new Date(entry.committed_at).toLocaleDateString('sv-SE')}</span>
</div>
)}
<div className="flex justify-between">
<span className="text-muted-foreground">Typ</span>
<span>{sourceTypeLabels[entry.source_type] || entry.source_type}</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Summering</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Debet</span>
<span className="tabular-nums font-medium">
{totalDebit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Kredit</span>
<span className="tabular-nums font-medium">
{totalCredit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Antal rader</span>
<span>{lines.length}</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Underlag</CardTitle>
</CardHeader>
<CardContent className="text-sm">
<div className="flex items-center gap-2">
{attachmentCount > 0 ? (
<>
<Paperclip className="h-4 w-4 text-muted-foreground" />
<span>{attachmentCount} {attachmentCount === 1 ? 'dokument' : 'dokument'}</span>
</>
) : (
<>
<AlertTriangle className="h-4 w-4 text-warning-foreground" />
<span className="text-muted-foreground">Inga underlag bifogade</span>
</>
)}
</div>
</CardContent>
</Card>
</div>
{/* Lines table */}
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">Kontorader</CardTitle>
</CardHeader>
<CardContent>
{/* Desktop table */}
<div className="hidden sm:block">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 w-48">Konto</th>
<th className="py-2">Beskrivning</th>
<th className="py-2 w-28 text-right">Debet</th>
<th className="py-2 w-28 text-right">Kredit</th>
</tr>
</thead>
<tbody>
{lines.map((line) => (
<tr key={line.id} className="border-b last:border-0">
<td className="py-2"><AccountNumber number={line.account_number} showName /></td>
<td className="py-2 text-muted-foreground">{line.line_description || ''}</td>
<td className="py-2 text-right tabular-nums">
{Number(line.debit_amount) > 0
? Number(line.debit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })
: ''}
</td>
<td className="py-2 text-right tabular-nums">
{Number(line.credit_amount) > 0
? Number(line.credit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })
: ''}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="font-semibold">
<td colSpan={2} className="py-2">Summa</td>
<td className="py-2 text-right tabular-nums">
{totalDebit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
</td>
<td className="py-2 text-right tabular-nums">
{totalCredit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
</td>
</tr>
</tfoot>
</table>
</div>
{/* Mobile cards */}
<div className="sm:hidden space-y-2">
{lines.map((line) => (
<div key={line.id} className="flex items-center justify-between py-2 border-b last:border-0 gap-2">
<div className="min-w-0 flex-1">
<div className="text-sm"><AccountNumber number={line.account_number} showName /></div>
{line.line_description && (
<p className="text-xs text-muted-foreground truncate">{line.line_description}</p>
)}
</div>
<div className="text-right shrink-0 text-sm tabular-nums">
{Number(line.debit_amount) > 0 && (
<p>{Number(line.debit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} D</p>
)}
{Number(line.credit_amount) > 0 && (
<p>{Number(line.credit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} K</p>
)}
</div>
</div>
))}
<div className="flex justify-between font-semibold text-sm pt-1">
<span>Summa</span>
<div className="flex gap-3 tabular-nums">
<span>D: {totalDebit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}</span>
<span>K: {totalCredit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}</span>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Attachments */}
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">Underlag</CardTitle>
</CardHeader>
<CardContent>
<JournalEntryAttachments
journalEntryId={entry.id}
onCountChange={setAttachmentCount}
/>
</CardContent>
</Card>
{/* Correction chain */}
{chain.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">Ändringshistorik</CardTitle>
</CardHeader>
<CardContent>
<CorrectionChain currentEntryId={id} chain={fullChain} />
</CardContent>
</Card>
)}
{/* Correction dialog */}
{showCorrection && entry && (
<CorrectionEntryDialog
entry={entry}
open={showCorrection}
onOpenChange={setShowCorrection}
onCorrected={() => {
setShowCorrection(false)
fetchData()
}}
/>
)}
</div>
)
}
+3 -3
View File
@@ -367,7 +367,7 @@ export default function ExpenseDetailPage() {
<td className="py-2 text-right font-mono">{formatAmount(p.amount)} {p.currency}</td>
<td className="py-2">
{p.journal_entry_id ? (
<Link href={`/bookkeeping?entry=${p.journal_entry_id}`} className="text-primary hover:underline font-mono text-xs">
<Link href={`/bookkeeping/${p.journal_entry_id}`} className="text-primary hover:underline font-mono text-xs">
{p.journal_entry_id.substring(0, 8)}...
</Link>
) : '-'}
@@ -387,7 +387,7 @@ export default function ExpenseDetailPage() {
<div className="flex justify-between">
<span className="text-muted-foreground">Registreringsverifikation</span>
<Link
href={`/bookkeeping?entry=${invoice.registration_journal_entry_id}`}
href={`/bookkeeping/${invoice.registration_journal_entry_id}`}
className="text-primary hover:underline font-mono"
>
{invoice.registration_journal_entry_id.substring(0, 8)}...
@@ -400,7 +400,7 @@ export default function ExpenseDetailPage() {
<div className="flex justify-between">
<span className="text-muted-foreground">Betalningsverifikation</span>
<Link
href={`/bookkeeping?entry=${invoice.payment_journal_entry_id}`}
href={`/bookkeeping/${invoice.payment_journal_entry_id}`}
className="text-primary hover:underline font-mono"
>
{invoice.payment_journal_entry_id.substring(0, 8)}...
@@ -386,7 +386,7 @@ export default function SupplierInvoiceDetailPage() {
<td className="py-2 text-right font-mono">{formatAmount(p.amount)} {p.currency}</td>
<td className="py-2">
{p.journal_entry_id ? (
<Link href={`/bookkeeping?entry=${p.journal_entry_id}`} className="text-primary hover:underline font-mono text-xs">
<Link href={`/bookkeeping/${p.journal_entry_id}`} className="text-primary hover:underline font-mono text-xs">
{p.journal_entry_id.substring(0, 8)}...
</Link>
) : '-'}
@@ -407,7 +407,7 @@ export default function SupplierInvoiceDetailPage() {
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
{p.journal_entry_id ? (
<Link href={`/bookkeeping?entry=${p.journal_entry_id}`} className="text-primary hover:underline font-mono">
<Link href={`/bookkeeping/${p.journal_entry_id}`} className="text-primary hover:underline font-mono">
{p.journal_entry_id.substring(0, 8)}...
</Link>
) : <span>-</span>}
@@ -430,7 +430,7 @@ export default function SupplierInvoiceDetailPage() {
<div className="flex justify-between">
<span className="text-muted-foreground">Registreringsverifikation</span>
<Link
href={`/bookkeeping?entry=${invoice.registration_journal_entry_id}`}
href={`/bookkeeping/${invoice.registration_journal_entry_id}`}
className="text-primary hover:underline font-mono"
>
{invoice.registration_journal_entry_id.substring(0, 8)}...
@@ -443,7 +443,7 @@ export default function SupplierInvoiceDetailPage() {
<div className="flex justify-between">
<span className="text-muted-foreground">Betalningsverifikation</span>
<Link
href={`/bookkeeping?entry=${invoice.payment_journal_entry_id}`}
href={`/bookkeeping/${invoice.payment_journal_entry_id}`}
className="text-primary hover:underline font-mono"
>
{invoice.payment_journal_entry_id.substring(0, 8)}...
@@ -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 } })
}