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,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>
|
||||
)
|
||||
}
|
||||
@@ -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 } })
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Info } from 'lucide-react'
|
||||
import JournalEntryStatusBadge from '@/components/bookkeeping/JournalEntryStatusBadge'
|
||||
import type { JournalEntry, JournalEntryLine } from '@/types'
|
||||
|
||||
interface Props {
|
||||
currentEntryId: string
|
||||
chain: JournalEntry[]
|
||||
}
|
||||
|
||||
function getRole(entry: JournalEntry): { label: string; color: string } {
|
||||
if (entry.source_type === 'storno') {
|
||||
return { label: 'Storno', color: 'bg-destructive' }
|
||||
}
|
||||
if (entry.source_type === 'correction') {
|
||||
return { label: 'Rättelse', color: 'bg-primary' }
|
||||
}
|
||||
return { label: 'Original', color: 'bg-muted-foreground' }
|
||||
}
|
||||
|
||||
function getTotal(entry: JournalEntry): number {
|
||||
const lines = (entry.lines || []) as JournalEntryLine[]
|
||||
return lines.reduce((sum, l) => sum + (Number(l.debit_amount) || 0), 0)
|
||||
}
|
||||
|
||||
export default function CorrectionChain({ currentEntryId, chain }: Props) {
|
||||
if (chain.length === 0) return null
|
||||
|
||||
// Combine current entry isn't in chain — chain is "other" entries
|
||||
// Sort chronologically
|
||||
const sorted = [...chain].sort(
|
||||
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium">Ändringskedja</h3>
|
||||
|
||||
<div className="rounded-lg bg-muted/50 border p-3 flex gap-2 text-sm text-muted-foreground">
|
||||
<Info className="h-4 w-4 shrink-0 mt-0.5" />
|
||||
<p>
|
||||
Bokförda verifikationer kan inte ändras direkt. Istället skapas en stornoverifikation
|
||||
som nollställer den ursprungliga, och en ny rättelsepost med de korrekta uppgifterna.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative space-y-0">
|
||||
{/* Vertical line connecting nodes */}
|
||||
<div className="absolute left-[7px] top-3 bottom-3 w-px bg-border" />
|
||||
|
||||
{sorted.map((entry) => {
|
||||
const role = getRole(entry)
|
||||
const total = getTotal(entry)
|
||||
const isCurrent = entry.id === currentEntryId
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={entry.id}
|
||||
href={`/bookkeeping/${entry.id}`}
|
||||
className="block"
|
||||
>
|
||||
<div className={`relative pl-7 py-2 rounded-md transition-colors hover:bg-muted/50 ${isCurrent ? 'bg-muted/30' : ''}`}>
|
||||
{/* Timeline dot */}
|
||||
<div className={`absolute left-0.5 top-[18px] h-3 w-3 rounded-full border-2 border-background ${role.color}`} />
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-medium text-muted-foreground">{role.label}</span>
|
||||
<span className="font-mono text-sm">
|
||||
{entry.voucher_series}{entry.voucher_number}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">{entry.entry_date}</span>
|
||||
<JournalEntryStatusBadge entry={entry} showStatus={false} />
|
||||
{isCurrent && (
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
|
||||
Aktuell
|
||||
</Badge>
|
||||
)}
|
||||
<span className="ml-auto text-sm tabular-nums text-muted-foreground">
|
||||
{total.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} kr
|
||||
</span>
|
||||
</div>
|
||||
{entry.description && (
|
||||
<p className="text-xs text-muted-foreground truncate mt-0.5">{entry.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -33,6 +34,7 @@ interface Props {
|
||||
|
||||
export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCorrected }: Props) {
|
||||
const { toast } = useToast()
|
||||
const router = useRouter()
|
||||
const [accounts, setAccounts] = useState<BASAccount[]>([])
|
||||
const [lines, setLines] = useState<CorrectionLine[]>([])
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
@@ -104,12 +106,23 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor
|
||||
body: JSON.stringify({ lines: apiLines }),
|
||||
})
|
||||
|
||||
const result = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
const { error } = await res.json()
|
||||
throw new Error(error || 'Failed to create correction')
|
||||
throw new Error(result.error || 'Failed to create correction')
|
||||
}
|
||||
|
||||
toast({ title: 'Ändringsverifikation skapad', description: 'Storno och rättelse har bokförts.' })
|
||||
const correctedId = result.data?.corrected?.id
|
||||
|
||||
toast({
|
||||
title: 'Ändringsverifikation skapad',
|
||||
description: 'Storno och rättelse har bokförts.',
|
||||
action: correctedId ? (
|
||||
<Button variant="outline" size="sm" onClick={() => router.push(`/bookkeeping/${correctedId}`)}>
|
||||
Visa rättelsen
|
||||
</Button>
|
||||
) : undefined,
|
||||
})
|
||||
onOpenChange(false)
|
||||
onCorrected()
|
||||
} catch (err) {
|
||||
@@ -130,6 +143,16 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor
|
||||
<DialogTitle>Skapa ändringsverifikation</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Storno explanation */}
|
||||
<div className="rounded-lg bg-muted/50 border p-3 text-sm text-muted-foreground">
|
||||
<p className="font-medium text-foreground mb-1">Hur fungerar en ändringsverifikation?</p>
|
||||
<p>En bokförd verifikation kan inte ändras direkt. Istället skapas automatiskt:</p>
|
||||
<ol className="list-decimal list-inside mt-1 space-y-0.5">
|
||||
<li>En <strong>stornoverifikation</strong> som nollställer den ursprungliga</li>
|
||||
<li>En ny verifikation med dina rättade uppgifter</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{/* Original entry (read-only) */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -11,6 +12,7 @@ import { Input } from '@/components/ui/input'
|
||||
import { AccountNumber } from '@/components/ui/account-number'
|
||||
import JournalEntryAttachments from '@/components/bookkeeping/JournalEntryAttachments'
|
||||
import CorrectionEntryDialog from '@/components/bookkeeping/CorrectionEntryDialog'
|
||||
import JournalEntryStatusBadge from '@/components/bookkeeping/JournalEntryStatusBadge'
|
||||
import type { JournalEntry, JournalEntryLine } from '@/types'
|
||||
|
||||
const NEEDS_ATTACHMENT = new Set([
|
||||
@@ -229,12 +231,19 @@ export default function JournalEntryList({ periodId }: Props) {
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 shrink-0" />
|
||||
)}
|
||||
<span className="font-mono text-sm text-muted-foreground w-16">
|
||||
<Link
|
||||
href={`/bookkeeping/${entry.id}`}
|
||||
className="font-mono text-sm text-primary hover:underline w-16"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{entry.voucher_series}{entry.voucher_number}
|
||||
</span>
|
||||
</Link>
|
||||
<span className="text-sm text-muted-foreground w-24">
|
||||
{entry.entry_date}
|
||||
</span>
|
||||
{(entry.status === 'reversed' || entry.source_type === 'storno' || entry.source_type === 'correction') && (
|
||||
<JournalEntryStatusBadge entry={entry} showStatus={entry.status === 'reversed'} />
|
||||
)}
|
||||
<span className="flex-1 truncate">{entry.description}</span>
|
||||
{attachmentCounts[entry.id] ? (
|
||||
<span className="flex items-center gap-0.5 text-muted-foreground mr-1" title={`${attachmentCounts[entry.id]} underlag`}>
|
||||
@@ -257,9 +266,13 @@ export default function JournalEntryList({ periodId }: Props) {
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 shrink-0" />
|
||||
)}
|
||||
<span className="font-mono text-sm text-muted-foreground">
|
||||
<Link
|
||||
href={`/bookkeeping/${entry.id}`}
|
||||
className="font-mono text-sm text-primary hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{entry.voucher_series}{entry.voucher_number}
|
||||
</span>
|
||||
</Link>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{entry.entry_date}
|
||||
</span>
|
||||
@@ -383,8 +396,11 @@ export default function JournalEntryList({ periodId }: Props) {
|
||||
onCountChange={(c) => handleAttachmentCountChange(entry.id, c)}
|
||||
/>
|
||||
|
||||
{entry.status === 'posted' && entry.source_type !== 'storno' && entry.source_type !== 'correction' && (
|
||||
<div className="mt-4 pt-3 border-t flex gap-2">
|
||||
<div className="mt-4 pt-3 border-t flex gap-2">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/bookkeeping/${entry.id}`}>Visa detaljer</Link>
|
||||
</Button>
|
||||
{entry.status === 'posted' && entry.source_type !== 'storno' && entry.source_type !== 'correction' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -392,8 +408,8 @@ export default function JournalEntryList({ periodId }: Props) {
|
||||
>
|
||||
Skapa ändringsverifikation
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import type { JournalEntry } from '@/types'
|
||||
|
||||
const statusConfig: Record<string, { label: string; variant: 'default' | 'secondary' | 'success' | 'warning' | 'destructive' }> = {
|
||||
draft: { label: 'Utkast', variant: 'secondary' },
|
||||
posted: { label: 'Bokförd', variant: 'success' },
|
||||
reversed: { label: 'Omförd', variant: 'warning' },
|
||||
cancelled: { label: 'Makulerad', variant: 'secondary' },
|
||||
}
|
||||
|
||||
const sourceTypeBadges: Record<string, { label: string; variant: 'default' | 'secondary' | 'success' | 'warning' | 'destructive' }> = {
|
||||
storno: { label: 'Storno', variant: 'destructive' },
|
||||
correction: { label: 'Rättelse', variant: 'default' },
|
||||
}
|
||||
|
||||
export const sourceTypeLabels: Record<string, string> = {
|
||||
manual: 'Manuell',
|
||||
bank_transaction: 'Banktransaktion',
|
||||
invoice_created: 'Faktura skapad',
|
||||
invoice_paid: 'Fakturabetalning',
|
||||
credit_note: 'Kreditfaktura',
|
||||
salary_payment: 'Lön',
|
||||
opening_balance: 'Ingående balans',
|
||||
year_end: 'Årsbokslut',
|
||||
storno: 'Storno',
|
||||
correction: 'Rättelse',
|
||||
import: 'Import',
|
||||
system: 'System',
|
||||
supplier_invoice_registered: 'Leverantörsfaktura',
|
||||
supplier_invoice_paid: 'Leverantörsbetalning',
|
||||
supplier_invoice_cash_payment: 'Kontantbetalning',
|
||||
currency_revaluation: 'Valutaomvärdering',
|
||||
}
|
||||
|
||||
interface Props {
|
||||
entry: JournalEntry
|
||||
showStatus?: boolean
|
||||
}
|
||||
|
||||
export default function JournalEntryStatusBadge({ entry, showStatus = true }: Props) {
|
||||
const status = statusConfig[entry.status]
|
||||
const sourceType = sourceTypeBadges[entry.source_type]
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{showStatus && status && (
|
||||
<Badge variant={status.variant} className="text-[10px] px-1.5 py-0">
|
||||
{status.label}
|
||||
</Badge>
|
||||
)}
|
||||
{sourceType && (
|
||||
<Badge variant={sourceType.variant} className="text-[10px] px-1.5 py-0">
|
||||
{sourceType.label}
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -57,23 +57,28 @@ const navItems: NavItem[] = [
|
||||
{ href: '/', label: 'Översikt', icon: LayoutDashboard, group: 'main' },
|
||||
{ href: '/kpi', label: 'Nyckeltal', icon: TrendingUp, group: 'main' },
|
||||
{ href: '/deadlines', label: 'Deadlines', icon: Calendar, group: 'main' },
|
||||
{ href: '/invoices', label: 'Fakturor', icon: Receipt, group: 'finans' },
|
||||
{ href: '/customers', label: 'Kunder', icon: Users, group: 'finans' },
|
||||
{ href: '/expenses', label: 'Utgifter', icon: Wallet, group: 'finans' },
|
||||
// AR — Accounts Receivable
|
||||
{ href: '/invoices', label: 'Fakturor', icon: Receipt, group: 'försäljning' },
|
||||
{ href: '/customers', label: 'Kunder', icon: Users, group: 'försäljning' },
|
||||
// AP — Accounts Payable
|
||||
{ href: '/expenses', label: 'Utgifter', icon: Wallet, group: 'inköp' },
|
||||
// Temporarily hidden pending module rework (see feedback #49)
|
||||
{ href: '/suppliers', label: 'Leverantörer', icon: Building2, group: 'finans', hidden: true },
|
||||
{ href: '/supplier-invoices', label: 'Leverantörsfakturor', icon: FileInput, group: 'finans', hidden: true },
|
||||
{ href: '/transactions', label: 'Transaktioner', icon: ArrowLeftRight, group: 'finans' },
|
||||
{ href: '/bookkeeping', label: 'Bokföring', icon: BookOpen, group: 'finans' },
|
||||
{ href: '/reports', label: 'Rapporter', icon: BarChart3, group: 'finans' },
|
||||
{ href: '/import', label: 'Importera', icon: Upload, group: 'finans' },
|
||||
{ href: '/suppliers', label: 'Leverantörer', icon: Building2, group: 'inköp', hidden: true },
|
||||
{ href: '/supplier-invoices', label: 'Leverantörsfakturor', icon: FileInput, group: 'inköp', hidden: true },
|
||||
// General accounting
|
||||
{ href: '/transactions', label: 'Transaktioner', icon: ArrowLeftRight, group: 'redovisning' },
|
||||
{ href: '/bookkeeping', label: 'Bokföring', icon: BookOpen, group: 'redovisning' },
|
||||
{ href: '/reports', label: 'Rapporter', icon: BarChart3, group: 'redovisning' },
|
||||
{ href: '/import', label: 'Importera', icon: Upload, group: 'redovisning' },
|
||||
{ href: '/help', label: 'Hjälp', icon: HelpCircle, group: 'övrigt' },
|
||||
{ href: '/settings', label: 'Inställningar', icon: Settings, group: 'övrigt' },
|
||||
]
|
||||
|
||||
const groupLabels: Record<string, string> = {
|
||||
main: 'Huvudmeny',
|
||||
finans: 'Finans',
|
||||
försäljning: 'Försäljning',
|
||||
inköp: 'Inköp',
|
||||
redovisning: 'Redovisning',
|
||||
övrigt: 'Övrigt',
|
||||
}
|
||||
|
||||
@@ -124,9 +129,15 @@ export default function DashboardNav({ companyName, entityType, uncategorizedTra
|
||||
)
|
||||
|
||||
const mainItems = filteredItems.filter(i => i.group === 'main')
|
||||
const finansItems = filteredItems.filter(i => i.group === 'finans')
|
||||
const övrigtItems = filteredItems.filter(i => i.group === 'övrigt')
|
||||
|
||||
// Groups rendered as distinct sidebar sections (AR, AP, Accounting)
|
||||
const sidebarGroups = [
|
||||
{ key: 'försäljning', items: filteredItems.filter(i => i.group === 'försäljning'), spacing: 'mb-4' },
|
||||
{ key: 'inköp', items: filteredItems.filter(i => i.group === 'inköp'), spacing: 'mb-4' },
|
||||
{ key: 'redovisning', items: filteredItems.filter(i => i.group === 'redovisning'), spacing: 'mb-6' },
|
||||
] as const
|
||||
|
||||
const mobileNavItems = [
|
||||
{ href: '/', label: 'Översikt', icon: LayoutDashboard },
|
||||
{ href: '/invoices', label: 'Fakturor', icon: Receipt },
|
||||
@@ -179,44 +190,46 @@ export default function DashboardNav({ companyName, entityType, uncategorizedTra
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Finans group */}
|
||||
<div className="mb-6">
|
||||
<p className="px-3 mb-1.5 text-[10px] font-semibold text-muted-foreground uppercase tracking-[0.08em]">
|
||||
{groupLabels.finans}
|
||||
</p>
|
||||
<div className="space-y-px">
|
||||
{finansItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
const active = isActive(item.href)
|
||||
const badge = item.href === '/transactions' && uncategorizedTransactionCount > 0
|
||||
? uncategorizedTransactionCount
|
||||
: null
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'group flex items-center px-3 py-[7px] text-[13px] transition-colors duration-150 rounded-lg',
|
||||
active
|
||||
? 'bg-primary/12 text-foreground font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
|
||||
)}
|
||||
>
|
||||
<Icon className={cn(
|
||||
"mr-2.5 h-[15px] w-[15px] flex-shrink-0",
|
||||
active ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
|
||||
)} />
|
||||
<span className="flex-1">{item.label}</span>
|
||||
{badge !== null && (
|
||||
<span className="ml-auto min-w-[18px] h-[18px] flex items-center justify-center rounded-full bg-primary/15 text-primary text-[10px] font-semibold px-1">
|
||||
{badge > 99 ? '99+' : badge}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
{/* AR / AP / Accounting groups */}
|
||||
{sidebarGroups.map(({ key, items, spacing }) => (
|
||||
<div key={key} className={spacing}>
|
||||
<p className="px-3 mb-1.5 text-[10px] font-semibold text-muted-foreground uppercase tracking-[0.08em]">
|
||||
{groupLabels[key]}
|
||||
</p>
|
||||
<div className="space-y-px">
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon
|
||||
const active = isActive(item.href)
|
||||
const badge = item.href === '/transactions' && uncategorizedTransactionCount > 0
|
||||
? uncategorizedTransactionCount
|
||||
: null
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'group flex items-center px-3 py-[7px] text-[13px] transition-colors duration-150 rounded-lg',
|
||||
active
|
||||
? 'bg-primary/12 text-foreground font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
|
||||
)}
|
||||
>
|
||||
<Icon className={cn(
|
||||
"mr-2.5 h-[15px] w-[15px] flex-shrink-0",
|
||||
active ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
|
||||
)} />
|
||||
<span className="flex-1">{item.label}</span>
|
||||
{badge !== null && (
|
||||
<span className="ml-auto min-w-[18px] h-[18px] flex items-center justify-center rounded-full bg-primary/15 text-primary text-[10px] font-semibold px-1">
|
||||
{badge > 99 ? '99+' : badge}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Övrigt group - collapsible */}
|
||||
<div className="mb-4">
|
||||
@@ -416,43 +429,45 @@ export default function DashboardNav({ companyName, entityType, uncategorizedTra
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Finans divider */}
|
||||
<div className="flex items-center gap-3 my-1.5 px-3">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground/60 uppercase tracking-[0.08em]">Finans</span>
|
||||
<div className="flex-1 h-px bg-border/30" />
|
||||
</div>
|
||||
|
||||
{/* Finance items */}
|
||||
<div className="space-y-0.5">
|
||||
{finansItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
const active = isActive(item.href)
|
||||
const badge = item.href === '/transactions' && uncategorizedTransactionCount > 0
|
||||
? uncategorizedTransactionCount
|
||||
: null
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={closeMobileMenu}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 min-h-[44px] rounded-lg transition-colors',
|
||||
active
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-foreground active:bg-muted/60'
|
||||
)}
|
||||
>
|
||||
<Icon className={cn("h-[18px] w-[18px] flex-shrink-0", active ? "text-primary" : "text-muted-foreground")} />
|
||||
<span className="text-sm flex-1">{item.label}</span>
|
||||
{badge !== null && (
|
||||
<span className="min-w-[20px] h-[20px] flex items-center justify-center rounded-full bg-primary/15 text-primary text-[10px] font-semibold px-1.5">
|
||||
{badge > 99 ? '99+' : badge}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{/* AR / AP / Accounting groups (mobile) */}
|
||||
{sidebarGroups.map(({ key, items }) => (
|
||||
<div key={key}>
|
||||
<div className="flex items-center gap-3 my-1.5 px-3">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground/60 uppercase tracking-[0.08em]">{groupLabels[key]}</span>
|
||||
<div className="flex-1 h-px bg-border/30" />
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon
|
||||
const active = isActive(item.href)
|
||||
const badge = item.href === '/transactions' && uncategorizedTransactionCount > 0
|
||||
? uncategorizedTransactionCount
|
||||
: null
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={closeMobileMenu}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 min-h-[44px] rounded-lg transition-colors',
|
||||
active
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-foreground active:bg-muted/60'
|
||||
)}
|
||||
>
|
||||
<Icon className={cn("h-[18px] w-[18px] flex-shrink-0", active ? "text-primary" : "text-muted-foreground")} />
|
||||
<span className="text-sm flex-1">{item.label}</span>
|
||||
{badge !== null && (
|
||||
<span className="min-w-[20px] h-[20px] flex items-center justify-center rounded-full bg-primary/15 text-primary text-[10px] font-semibold px-1.5">
|
||||
{badge > 99 ? '99+' : badge}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Övrigt divider */}
|
||||
<div className="flex items-center gap-3 my-1.5 px-3">
|
||||
|
||||
@@ -9,6 +9,7 @@ import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { ArrowUpRight, ArrowDownRight, Check, Paperclip, ChevronDown, ChevronUp, AlertTriangle } from 'lucide-react'
|
||||
import { getDefaultAccountForCategory } from '@/lib/bookkeeping/category-mapping'
|
||||
import type { BookingTemplate } from '@/lib/bookkeeping/booking-templates'
|
||||
import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
|
||||
import JournalEntryPreview from './JournalEntryPreview'
|
||||
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
|
||||
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
|
||||
@@ -194,6 +195,11 @@ export default function QuickReviewDialog({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{template && (
|
||||
<p className="mt-1.5 text-xs font-mono text-muted-foreground">
|
||||
D: {formatAccountWithName(template.debit_account)} → K: {formatAccountWithName(template.credit_account)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Template special rules */}
|
||||
|
||||
@@ -424,6 +424,11 @@ export default function SwipeCategorizationView({
|
||||
Byt mall
|
||||
</button>
|
||||
</div>
|
||||
{selectedTemplate && (
|
||||
<p className="mt-1.5 text-xs font-mono text-muted-foreground">
|
||||
D: {formatAccountWithName(selectedTemplate.debit_account)} → K: {formatAccountWithName(selectedTemplate.credit_account)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Template special rules warning */}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type BookingTemplate,
|
||||
type TemplateGroup,
|
||||
} from '@/lib/bookkeeping/booking-templates'
|
||||
import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
|
||||
import type { EntityType } from '@/types'
|
||||
import type { SuggestedTemplate } from '@/lib/transactions/category-suggestions'
|
||||
|
||||
@@ -92,7 +93,7 @@ function TemplateCard({ template, selected, onClick, compact }: TemplateCardProp
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||
<span className="text-xs font-mono text-muted-foreground">
|
||||
D: {template.debit_account} · K: {template.credit_account}
|
||||
D: {formatAccountWithName(template.debit_account)} · K: {formatAccountWithName(template.credit_account)}
|
||||
</span>
|
||||
{vatLabel && (
|
||||
<Badge
|
||||
|
||||
@@ -184,7 +184,7 @@ export default function TransactionInboxCard({
|
||||
key={ts.template_id}
|
||||
size="sm"
|
||||
variant={idx === 0 ? 'default' : 'outline'}
|
||||
className="h-9 text-xs"
|
||||
className="h-auto py-1.5 text-xs"
|
||||
onClick={() => {
|
||||
if (onOpenTemplateReview && tmpl) {
|
||||
onOpenTemplateReview(transaction, ts.template_id)
|
||||
@@ -194,13 +194,17 @@ export default function TransactionInboxCard({
|
||||
}}
|
||||
disabled={isProcessing || isDisabled}
|
||||
>
|
||||
{isProcessing && idx === 0 ? (
|
||||
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
|
||||
) : null}
|
||||
{ts.name_sv}
|
||||
<span className="ml-1 opacity-70 font-normal">
|
||||
({ts.debit_account})
|
||||
</span>
|
||||
<div className="flex flex-col items-start">
|
||||
<div className="flex items-center">
|
||||
{isProcessing && idx === 0 ? (
|
||||
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
|
||||
) : null}
|
||||
{ts.name_sv}
|
||||
</div>
|
||||
<span className="opacity-70 font-normal font-mono text-[10px]">
|
||||
D: {ts.debit_account} → K: {ts.credit_account}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -6,19 +6,26 @@
|
||||
|
||||
const ACCOUNT_NAMES: Record<string, string> = {
|
||||
// Assets (1xxx)
|
||||
'1250': 'Inventarier',
|
||||
'1510': 'Kundfordringar',
|
||||
'1630': 'Skattekonto',
|
||||
'1680': 'Fordringar hos ägare',
|
||||
'1930': 'Företagskonto',
|
||||
|
||||
// Equity & Liabilities (2xxx)
|
||||
'2013': 'Övriga egna uttag',
|
||||
'2018': 'Egna insättningar',
|
||||
'2350': 'Långfristiga skulder',
|
||||
'2393': 'Kortfristig skuld närstående',
|
||||
'2440': 'Leverantörsskulder',
|
||||
'2510': 'Personalskatt',
|
||||
'2611': 'Utg. moms 25%',
|
||||
'2621': 'Utg. moms 12%',
|
||||
'2631': 'Utg. moms 6%',
|
||||
'2614': 'Utg. moms omvänd',
|
||||
'2641': 'Ing. moms',
|
||||
'2645': 'Beräknad ing. moms',
|
||||
'2731': 'Arbetsgivaravgifter',
|
||||
'2893': 'Skuld till ägare',
|
||||
|
||||
// Revenue (3xxx)
|
||||
@@ -29,31 +36,51 @@ const ACCOUNT_NAMES: Record<string, string> = {
|
||||
'3305': 'Exportförsäljning',
|
||||
'3308': 'EU-tjänster',
|
||||
'3900': 'Övriga rörelseintäkter',
|
||||
'3960': 'Valutakursvinster',
|
||||
|
||||
// Cost of goods (4xxx)
|
||||
'4010': 'Varuinköp',
|
||||
|
||||
// External expenses (5xxx)
|
||||
'5010': 'Lokalhyra',
|
||||
'5020': 'El & uppvärmning',
|
||||
'5410': 'Förbrukningsinventarier',
|
||||
'5420': 'Programvaror',
|
||||
'5421': 'Molntjänster',
|
||||
'5460': 'Förbrukningsvaror',
|
||||
'5611': 'Drivmedel bil',
|
||||
'5613': 'Reparation fordon',
|
||||
'5614': 'Parkering',
|
||||
'5615': 'Leasing fordon',
|
||||
'5800': 'Resekostnader',
|
||||
'5810': 'Biljetter & transport',
|
||||
'5820': 'Hotell',
|
||||
'5910': 'Annonsering',
|
||||
'5920': 'Design & grafik',
|
||||
'5990': 'Konferens',
|
||||
|
||||
// Other external expenses (6xxx)
|
||||
'6071': 'Representation',
|
||||
'6110': 'Kontorsförbrukning',
|
||||
'6200': 'Telefon & internet',
|
||||
'6211': 'Mobiltelefon',
|
||||
'6230': 'Internet',
|
||||
'6250': 'Porto',
|
||||
'6310': 'Företagsförsäkring',
|
||||
'6530': 'Redovisningstjänster',
|
||||
'6550': 'Konsulttjänster',
|
||||
'6570': 'Bankavgifter',
|
||||
'6980': 'Medlemsavgifter',
|
||||
'6991': 'Övriga kostnader',
|
||||
|
||||
// Personnel (7xxx)
|
||||
// Personnel & financial (7xxx / 8xxx)
|
||||
'7210': 'Löner',
|
||||
'7410': 'Pensionsförsäkring',
|
||||
'7610': 'Utbildning',
|
||||
'7622': 'Intern representation',
|
||||
'7960': 'Valutakursförluster',
|
||||
'3960': 'Valutakursvinster',
|
||||
'8310': 'Ränteintäkter',
|
||||
'8410': 'Räntekostnader',
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user