From 4d4a9a40c9b58db553a4b94852092da2157e21c4 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Mon, 23 Mar 2026 20:01:07 +0100 Subject: [PATCH] feat: AR/AP navigation separation and bookkeeping improvements (#109) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) * 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) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- app/(dashboard)/bookkeeping/[id]/page.tsx | 315 ++++++++++++++++++ app/(dashboard)/expenses/[id]/page.tsx | 6 +- .../supplier-invoices/[id]/page.tsx | 8 +- .../[id]/chain/__tests__/route.test.ts | 153 +++++++++ .../journal-entries/[id]/chain/route.ts | 110 ++++++ components/bookkeeping/CorrectionChain.tsx | 95 ++++++ .../bookkeeping/CorrectionEntryDialog.tsx | 29 +- components/bookkeeping/JournalEntryList.tsx | 32 +- .../bookkeeping/JournalEntryStatusBadge.tsx | 58 ++++ components/dashboard/DashboardNav.tsx | 185 +++++----- components/transactions/QuickReviewDialog.tsx | 6 + .../transactions/SwipeCategorizationView.tsx | 5 + components/transactions/TemplatePicker.tsx | 3 +- .../transactions/TransactionInboxCard.tsx | 20 +- lib/bookkeeping/client-account-names.ts | 31 +- 15 files changed, 942 insertions(+), 114 deletions(-) create mode 100644 app/(dashboard)/bookkeeping/[id]/page.tsx create mode 100644 app/api/bookkeeping/journal-entries/[id]/chain/__tests__/route.test.ts create mode 100644 app/api/bookkeeping/journal-entries/[id]/chain/route.ts create mode 100644 components/bookkeeping/CorrectionChain.tsx create mode 100644 components/bookkeeping/JournalEntryStatusBadge.tsx diff --git a/app/(dashboard)/bookkeeping/[id]/page.tsx b/app/(dashboard)/bookkeeping/[id]/page.tsx new file mode 100644 index 00000000..3118a87f --- /dev/null +++ b/app/(dashboard)/bookkeeping/[id]/page.tsx @@ -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(null) + const [chain, setChain] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(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 ( +
+ +

Laddar verifikation...

+
+ ) + } + + if (error || !entry) { + return ( +
+ + + Tillbaka till bokföring + + + +

{error || 'Verifikation hittades inte'}

+
+
+
+ ) + } + + 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 ( +
+ {/* Back link */} + + + Tillbaka till bokföring + + + {/* Header */} +
+
+
+

+ {entry.voucher_series}{entry.voucher_number} +

+ +
+

{entry.description}

+
+ + {canCorrect && ( + + )} +
+ + {/* Info cards */} +
+ + + Verifikationsdetaljer + + +
+ Datum + {entry.entry_date} +
+ {entry.committed_at && ( +
+ Bokförd + {new Date(entry.committed_at).toLocaleDateString('sv-SE')} +
+ )} +
+ Typ + {sourceTypeLabels[entry.source_type] || entry.source_type} +
+
+
+ + + + Summering + + +
+ Debet + + {totalDebit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} + +
+
+ Kredit + + {totalCredit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} + +
+
+ Antal rader + {lines.length} +
+
+
+ + + + Underlag + + +
+ {attachmentCount > 0 ? ( + <> + + {attachmentCount} {attachmentCount === 1 ? 'dokument' : 'dokument'} + + ) : ( + <> + + Inga underlag bifogade + + )} +
+
+
+
+ + {/* Lines table */} + + + Kontorader + + + {/* Desktop table */} +
+ + + + + + + + + + + {lines.map((line) => ( + + + + + + + ))} + + + + + + + + +
KontoBeskrivningDebetKredit
{line.line_description || ''} + {Number(line.debit_amount) > 0 + ? Number(line.debit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 }) + : ''} + + {Number(line.credit_amount) > 0 + ? Number(line.credit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 }) + : ''} +
Summa + {totalDebit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} + + {totalCredit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} +
+
+ + {/* Mobile cards */} +
+ {lines.map((line) => ( +
+
+
+ {line.line_description && ( +

{line.line_description}

+ )} +
+
+ {Number(line.debit_amount) > 0 && ( +

{Number(line.debit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} D

+ )} + {Number(line.credit_amount) > 0 && ( +

{Number(line.credit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} K

+ )} +
+
+ ))} +
+ Summa +
+ D: {totalDebit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} + K: {totalCredit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} +
+
+
+
+
+ + {/* Attachments */} + + + Underlag + + + + + + + {/* Correction chain */} + {chain.length > 0 && ( + + + Ändringshistorik + + + + + + )} + + {/* Correction dialog */} + {showCorrection && entry && ( + { + setShowCorrection(false) + fetchData() + }} + /> + )} +
+ ) +} diff --git a/app/(dashboard)/expenses/[id]/page.tsx b/app/(dashboard)/expenses/[id]/page.tsx index 84b655c7..578e51fc 100644 --- a/app/(dashboard)/expenses/[id]/page.tsx +++ b/app/(dashboard)/expenses/[id]/page.tsx @@ -367,7 +367,7 @@ export default function ExpenseDetailPage() { {formatAmount(p.amount)} {p.currency} {p.journal_entry_id ? ( - + {p.journal_entry_id.substring(0, 8)}... ) : '-'} @@ -387,7 +387,7 @@ export default function ExpenseDetailPage() {
Registreringsverifikation {invoice.registration_journal_entry_id.substring(0, 8)}... @@ -400,7 +400,7 @@ export default function ExpenseDetailPage() {
Betalningsverifikation {invoice.payment_journal_entry_id.substring(0, 8)}... diff --git a/app/(dashboard)/supplier-invoices/[id]/page.tsx b/app/(dashboard)/supplier-invoices/[id]/page.tsx index b56e1ac2..236930d6 100644 --- a/app/(dashboard)/supplier-invoices/[id]/page.tsx +++ b/app/(dashboard)/supplier-invoices/[id]/page.tsx @@ -386,7 +386,7 @@ export default function SupplierInvoiceDetailPage() { {formatAmount(p.amount)} {p.currency} {p.journal_entry_id ? ( - + {p.journal_entry_id.substring(0, 8)}... ) : '-'} @@ -407,7 +407,7 @@ export default function SupplierInvoiceDetailPage() {
{p.journal_entry_id ? ( - + {p.journal_entry_id.substring(0, 8)}... ) : -} @@ -430,7 +430,7 @@ export default function SupplierInvoiceDetailPage() {
Registreringsverifikation {invoice.registration_journal_entry_id.substring(0, 8)}... @@ -443,7 +443,7 @@ export default function SupplierInvoiceDetailPage() {
Betalningsverifikation {invoice.payment_journal_entry_id.substring(0, 8)}... diff --git a/app/api/bookkeeping/journal-entries/[id]/chain/__tests__/route.test.ts b/app/api/bookkeeping/journal-entries/[id]/chain/__tests__/route.test.ts new file mode 100644 index 00000000..bbc619bb --- /dev/null +++ b/app/api/bookkeeping/journal-entries/[id]/chain/__tests__/route.test.ts @@ -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 | null, + singleError = null as { message: string } | null, + referencingIds = [] as { id: string }[], + chainEntries = [] as ReturnType[], +} = {}) { + const fromCalls: string[] = [] + + const mockFrom = vi.fn().mockImplementation((table: string) => { + fromCalls.push(table) + const callIndex = fromCalls.length + + const builder: Record> = {} + 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 + // 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) + }) +}) diff --git a/app/api/bookkeeping/journal-entries/[id]/chain/route.ts b/app/api/bookkeeping/journal-entries/[id]/chain/route.ts new file mode 100644 index 00000000..9a1106af --- /dev/null +++ b/app/api/bookkeeping/journal-entries/[id]/chain/route.ts @@ -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([id]) + const toVisit = new Set() + + // 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 } }) +} diff --git a/components/bookkeeping/CorrectionChain.tsx b/components/bookkeeping/CorrectionChain.tsx new file mode 100644 index 00000000..268c006e --- /dev/null +++ b/components/bookkeeping/CorrectionChain.tsx @@ -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 ( +
+

Ändringskedja

+ +
+ +

+ 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. +

+
+ +
+ {/* Vertical line connecting nodes */} +
+ + {sorted.map((entry) => { + const role = getRole(entry) + const total = getTotal(entry) + const isCurrent = entry.id === currentEntryId + + return ( + +
+ {/* Timeline dot */} +
+ +
+ {role.label} + + {entry.voucher_series}{entry.voucher_number} + + {entry.entry_date} + + {isCurrent && ( + + Aktuell + + )} + + {total.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} kr + +
+ {entry.description && ( +

{entry.description}

+ )} +
+ + ) + })} +
+
+ ) +} diff --git a/components/bookkeeping/CorrectionEntryDialog.tsx b/components/bookkeeping/CorrectionEntryDialog.tsx index 4540d4b5..ff4356c5 100644 --- a/components/bookkeeping/CorrectionEntryDialog.tsx +++ b/components/bookkeeping/CorrectionEntryDialog.tsx @@ -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([]) const [lines, setLines] = useState([]) 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 ? ( + + ) : undefined, + }) onOpenChange(false) onCorrected() } catch (err) { @@ -130,6 +143,16 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor Skapa ändringsverifikation + {/* Storno explanation */} +
+

Hur fungerar en ändringsverifikation?

+

En bokförd verifikation kan inte ändras direkt. Istället skapas automatiskt:

+
    +
  1. En stornoverifikation som nollställer den ursprungliga
  2. +
  3. En ny verifikation med dina rättade uppgifter
  4. +
+
+ {/* Original entry (read-only) */}
diff --git a/components/bookkeeping/JournalEntryList.tsx b/components/bookkeeping/JournalEntryList.tsx index a4519338..57f00bf4 100644 --- a/components/bookkeeping/JournalEntryList.tsx +++ b/components/bookkeeping/JournalEntryList.tsx @@ -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) { ) : ( )} - + e.stopPropagation()} + > {entry.voucher_series}{entry.voucher_number} - + {entry.entry_date} + {(entry.status === 'reversed' || entry.source_type === 'storno' || entry.source_type === 'correction') && ( + + )} {entry.description} {attachmentCounts[entry.id] ? ( @@ -257,9 +266,13 @@ export default function JournalEntryList({ periodId }: Props) { ) : ( )} - + e.stopPropagation()} + > {entry.voucher_series}{entry.voucher_number} - + {entry.entry_date} @@ -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' && ( -
+
+ + {entry.status === 'posted' && entry.source_type !== 'storno' && entry.source_type !== 'correction' && ( -
- )} + )} +
)} diff --git a/components/bookkeeping/JournalEntryStatusBadge.tsx b/components/bookkeeping/JournalEntryStatusBadge.tsx new file mode 100644 index 00000000..bdc9331f --- /dev/null +++ b/components/bookkeeping/JournalEntryStatusBadge.tsx @@ -0,0 +1,58 @@ +import { Badge } from '@/components/ui/badge' +import type { JournalEntry } from '@/types' + +const statusConfig: Record = { + 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 = { + storno: { label: 'Storno', variant: 'destructive' }, + correction: { label: 'Rättelse', variant: 'default' }, +} + +export const sourceTypeLabels: Record = { + 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 ( + + {showStatus && status && ( + + {status.label} + + )} + {sourceType && ( + + {sourceType.label} + + )} + + ) +} diff --git a/components/dashboard/DashboardNav.tsx b/components/dashboard/DashboardNav.tsx index e73d5611..8c59ae22 100644 --- a/components/dashboard/DashboardNav.tsx +++ b/components/dashboard/DashboardNav.tsx @@ -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 = { 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
- {/* Finans group */} -
-

- {groupLabels.finans} -

-
- {finansItems.map((item) => { - const Icon = item.icon - const active = isActive(item.href) - const badge = item.href === '/transactions' && uncategorizedTransactionCount > 0 - ? uncategorizedTransactionCount - : null - return ( - - - {item.label} - {badge !== null && ( - - {badge > 99 ? '99+' : badge} - - )} - - ) - })} + {/* AR / AP / Accounting groups */} + {sidebarGroups.map(({ key, items, spacing }) => ( +
+

+ {groupLabels[key]} +

+
+ {items.map((item) => { + const Icon = item.icon + const active = isActive(item.href) + const badge = item.href === '/transactions' && uncategorizedTransactionCount > 0 + ? uncategorizedTransactionCount + : null + return ( + + + {item.label} + {badge !== null && ( + + {badge > 99 ? '99+' : badge} + + )} + + ) + })} +
-
+ ))} {/* Övrigt group - collapsible */}
@@ -416,43 +429,45 @@ export default function DashboardNav({ companyName, entityType, uncategorizedTra })}
- {/* Finans divider */} -
- Finans -
-
- - {/* Finance items */} -
- {finansItems.map((item) => { - const Icon = item.icon - const active = isActive(item.href) - const badge = item.href === '/transactions' && uncategorizedTransactionCount > 0 - ? uncategorizedTransactionCount - : null - return ( - - - {item.label} - {badge !== null && ( - - {badge > 99 ? '99+' : badge} - - )} - - ) - })} -
+ {/* AR / AP / Accounting groups (mobile) */} + {sidebarGroups.map(({ key, items }) => ( +
+
+ {groupLabels[key]} +
+
+
+ {items.map((item) => { + const Icon = item.icon + const active = isActive(item.href) + const badge = item.href === '/transactions' && uncategorizedTransactionCount > 0 + ? uncategorizedTransactionCount + : null + return ( + + + {item.label} + {badge !== null && ( + + {badge > 99 ? '99+' : badge} + + )} + + ) + })} +
+
+ ))} {/* Övrigt divider */}
diff --git a/components/transactions/QuickReviewDialog.tsx b/components/transactions/QuickReviewDialog.tsx index d0071afb..d8aa014f 100644 --- a/components/transactions/QuickReviewDialog.tsx +++ b/components/transactions/QuickReviewDialog.tsx @@ -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({ )}
+ {template && ( +

+ D: {formatAccountWithName(template.debit_account)} → K: {formatAccountWithName(template.credit_account)} +

+ )}
{/* Template special rules */} diff --git a/components/transactions/SwipeCategorizationView.tsx b/components/transactions/SwipeCategorizationView.tsx index c6b28f25..a6c12f28 100644 --- a/components/transactions/SwipeCategorizationView.tsx +++ b/components/transactions/SwipeCategorizationView.tsx @@ -424,6 +424,11 @@ export default function SwipeCategorizationView({ Byt mall
+ {selectedTemplate && ( +

+ D: {formatAccountWithName(selectedTemplate.debit_account)} → K: {formatAccountWithName(selectedTemplate.credit_account)} +

+ )}
{/* Template special rules warning */} diff --git a/components/transactions/TemplatePicker.tsx b/components/transactions/TemplatePicker.tsx index 5810df72..d6ed86f5 100644 --- a/components/transactions/TemplatePicker.tsx +++ b/components/transactions/TemplatePicker.tsx @@ -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

- D: {template.debit_account} · K: {template.credit_account} + D: {formatAccountWithName(template.debit_account)} · K: {formatAccountWithName(template.credit_account)} {vatLabel && ( { if (onOpenTemplateReview && tmpl) { onOpenTemplateReview(transaction, ts.template_id) @@ -194,13 +194,17 @@ export default function TransactionInboxCard({ }} disabled={isProcessing || isDisabled} > - {isProcessing && idx === 0 ? ( - - ) : null} - {ts.name_sv} - - ({ts.debit_account}) - +
+
+ {isProcessing && idx === 0 ? ( + + ) : null} + {ts.name_sv} +
+ + D: {ts.debit_account} → K: {ts.credit_account} + +
) })} diff --git a/lib/bookkeeping/client-account-names.ts b/lib/bookkeeping/client-account-names.ts index 7abcc874..fbadd878 100644 --- a/lib/bookkeeping/client-account-names.ts +++ b/lib/bookkeeping/client-account-names.ts @@ -6,19 +6,26 @@ const ACCOUNT_NAMES: Record = { // 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 = { '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', } /**