From d0c0d8a7d2eb103382310e98c65ffc0f79fdcc6e Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Fri, 20 Feb 2026 17:16:22 +0100 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20UX=20improvements=20=E2=80=94=20nav?= =?UTF-8?q?,=20reports=20tabs,=20dashboard=20alerts,=20transaction=20hints?= =?UTF-8?q?,=20settings=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move Reports to Finans nav group and auto-expand Övrigt on its pages - Make report tabs horizontally scrollable with gradient fade on mobile - Surface deadlines and alerts above the fold on dashboard - Add dismissible categorization hint card on transactions page - Split settings company form into 4 separate Cards for scannability - Add monthly breakdown report, document upload zone, journal entry attachments - Add batch category selector, receipt document linking, invoice form improvements Co-Authored-By: Claude Opus 4.6 --- app/(dashboard)/invoices/new/page.tsx | 83 +++- app/(dashboard)/reports/page.tsx | 70 +++- app/(dashboard)/settings/page.tsx | 371 ++++++++++-------- app/(dashboard)/transactions/page.tsx | 163 +++++++- app/api/documents/counts/route.ts | 55 +++ .../extensions/receipt-ocr/upload/route.ts | 7 +- app/api/reports/monthly-breakdown/route.ts | 26 ++ app/api/transactions/[id]/categorize/route.ts | 21 + app/globals.css | 9 + components/bookkeeping/DocumentUploadZone.tsx | 252 ++++++++++++ .../bookkeeping/JournalEntryAttachments.tsx | 209 ++++++++++ components/bookkeeping/JournalEntryForm.tsx | 35 +- components/bookkeeping/JournalEntryList.tsx | 94 ++++- .../bookkeeping/JournalEntryReviewContent.tsx | 10 +- components/dashboard/DashboardContent.tsx | 314 ++++++++++----- components/dashboard/DashboardNav.tsx | 9 +- components/reports/IncomeExpenseChart.tsx | 51 +++ components/reports/TrialBalanceChart.tsx | 69 ++++ components/reports/VatCompositionChart.tsx | 64 +++ .../transactions/BatchCategorySelector.tsx | 108 +++++ .../__tests__/monthly-breakdown.test.ts | 217 ++++++++++ lib/reports/monthly-breakdown.ts | 124 ++++++ ...20240101000029_add_receipt_document_id.sql | 6 + 23 files changed, 2065 insertions(+), 302 deletions(-) create mode 100644 app/api/documents/counts/route.ts create mode 100644 app/api/reports/monthly-breakdown/route.ts create mode 100644 components/bookkeeping/DocumentUploadZone.tsx create mode 100644 components/bookkeeping/JournalEntryAttachments.tsx create mode 100644 components/reports/IncomeExpenseChart.tsx create mode 100644 components/reports/TrialBalanceChart.tsx create mode 100644 components/reports/VatCompositionChart.tsx create mode 100644 components/transactions/BatchCategorySelector.tsx create mode 100644 lib/reports/__tests__/monthly-breakdown.test.ts create mode 100644 lib/reports/monthly-breakdown.ts create mode 100644 supabase/migrations/20240101000029_add_receipt_document_id.sql diff --git a/app/(dashboard)/invoices/new/page.tsx b/app/(dashboard)/invoices/new/page.tsx index 35c67569..1a5dc293 100644 --- a/app/(dashboard)/invoices/new/page.tsx +++ b/app/(dashboard)/invoices/new/page.tsx @@ -17,7 +17,8 @@ import { Separator } from '@/components/ui/separator' import { useToast } from '@/components/ui/use-toast' import { formatCurrency } from '@/lib/utils' import { getVatRules, getVatTreatmentLabel } from '@/lib/invoice/vat-rules' -import { Loader2, Plus, Trash2, ArrowLeft } from 'lucide-react' +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog' +import { Loader2, Plus, Trash2, ArrowLeft, Send } from 'lucide-react' import { ConfirmationDialog } from '@/components/ui/confirmation-dialog' import { InvoiceReviewContent } from '@/components/invoices/InvoiceReviewContent' import type { Customer, Currency, CreateInvoiceInput } from '@/types' @@ -58,6 +59,9 @@ export default function NewInvoicePage() { const [selectedCustomer, setSelectedCustomer] = useState(null) const [showReview, setShowReview] = useState(false) const [pendingData, setPendingData] = useState(null) + const [createdInvoiceId, setCreatedInvoiceId] = useState(null) + const [showSendPrompt, setShowSendPrompt] = useState(false) + const [isSending, setIsSending] = useState(false) const { register, @@ -168,7 +172,14 @@ export default function NewInvoicePage() { }) setShowReview(false) - router.push(`/invoices/${result.data.id}`) + + // If customer has email, offer to send immediately + if (selectedCustomer?.email) { + setCreatedInvoiceId(result.data.id) + setShowSendPrompt(true) + } else { + router.push(`/invoices/${result.data.id}`) + } } catch (error) { toast({ title: 'Fel', @@ -180,6 +191,37 @@ export default function NewInvoicePage() { } } + async function handleSendNow() { + if (!createdInvoiceId) return + setIsSending(true) + + try { + const response = await fetch(`/api/invoices/${createdInvoiceId}/send`, { + method: 'POST', + }) + + if (!response.ok) { + const result = await response.json() + throw new Error(result.error || 'Kunde inte skicka faktura') + } + + toast({ + title: 'Faktura skickad', + description: `Fakturan har skickats till ${selectedCustomer?.email}`, + }) + } catch (error) { + toast({ + title: 'Fel vid skickning', + description: error instanceof Error ? error.message : 'Något gick fel', + variant: 'destructive', + }) + } finally { + setIsSending(false) + setShowSendPrompt(false) + router.push(`/invoices/${createdInvoiceId}`) + } + } + if (isLoading) { return (
@@ -465,6 +507,43 @@ export default function NewInvoicePage() { /> )} + + {/* Send now prompt dialog */} + { + if (!open && createdInvoiceId) { + setShowSendPrompt(false) + router.push(`/invoices/${createdInvoiceId}`) + } + }}> + + + Skicka fakturan nu? + + Fakturan skapades. Vill du skicka den till {selectedCustomer?.email} direkt? + + + + + + + +
) } diff --git a/app/(dashboard)/reports/page.tsx b/app/(dashboard)/reports/page.tsx index 7de96c9a..14120498 100644 --- a/app/(dashboard)/reports/page.tsx +++ b/app/(dashboard)/reports/page.tsx @@ -10,6 +10,10 @@ import { Download, FileText, FileDown, TrendingUp, Scale, AlertCircle, Receipt, import { AccountNumber } from '@/components/ui/account-number' import { NEDeclarationView } from '@/extensions/ne-bilaga/NEDeclarationView' import { SRUExportView } from '@/extensions/sru-export/SRUExportView' +import { TrialBalanceChart } from '@/components/reports/TrialBalanceChart' +import { VatCompositionChart } from '@/components/reports/VatCompositionChart' +import { IncomeExpenseChart } from '@/components/reports/IncomeExpenseChart' +import type { MonthlyDataPoint } from '@/components/reports/IncomeExpenseChart' import type { FiscalPeriod, TrialBalanceRow, @@ -98,8 +102,9 @@ export default function ReportsPage() { {selectedPeriod ? ( - - +
+ + Saldobalans @@ -141,7 +146,9 @@ export default function ReportsPage() { Lev.reskontra - + +
+
@@ -248,22 +255,24 @@ function TrialBalanceView({ periodId }: { periodId: string }) { } return ( - - -
- Saldobalans - {data.isBalanced ? ( - Balanserad - ) : ( - Ej balanserad - )} -
-
- - - - - +
+ + + +
+ Saldobalans + {data.isBalanced ? ( + Balanserad + ) : ( + Ej balanserad + )} +
+
+ +
Konto
+ + + @@ -311,17 +320,22 @@ function TrialBalanceView({ periodId }: { periodId: string }) {
Konto Namn Period debet Period kredit
+
) } function IncomeStatementView({ periodId }: { periodId: string }) { const [data, setData] = useState(null) + const [monthlyData, setMonthlyData] = useState([]) + const [monthlyLoading, setMonthlyLoading] = useState(false) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) useEffect(() => { setLoading(true) setError(null) + setMonthlyLoading(true) + fetch(`/api/reports/income-statement?period_id=${periodId}`) .then((res) => res.json()) .then((result) => { @@ -336,6 +350,18 @@ function IncomeStatementView({ periodId }: { periodId: string }) { setError('Kunde inte hämta resultaträkning') setLoading(false) }) + + fetch(`/api/reports/monthly-breakdown?period_id=${periodId}`) + .then((res) => res.json()) + .then((result) => { + if (result.data?.months) { + setMonthlyData(result.data.months) + } + setMonthlyLoading(false) + }) + .catch(() => { + setMonthlyLoading(false) + }) }, [periodId]) if (loading) { @@ -371,6 +397,10 @@ function IncomeStatementView({ periodId }: { periodId: string }) { return (
+ {!monthlyLoading && monthlyData.length > 0 && ( + + )} + {/* Revenue */} @@ -741,6 +771,8 @@ function VatDeclarationView() { {data && ( <> + + {/* Summary */} diff --git a/app/(dashboard)/settings/page.tsx b/app/(dashboard)/settings/page.tsx index 21024c0b..579379db 100644 --- a/app/(dashboard)/settings/page.tsx +++ b/app/(dashboard)/settings/page.tsx @@ -317,191 +317,216 @@ export default function SettingsPage() { {/* Company settings */} -
- - - Företagsuppgifter - - Dessa uppgifter visas på dina fakturor - - - -
-
- - -
-
- - -
-
- + + {/* Företagsuppgifter */} + + + Företagsuppgifter + + Namn, organisationsnummer och adress + + + +
- +
- -
-
- - -
-
- - -
+
+ +
+
-
-

Bankuppgifter för fakturor

-
-
- - -
-
- - -
-
- - -
-
+
+ + +
+ +
+
+ +
- -
-

Fakturainställningar

-
-
- - -
-
- - -
-
- - -
-
- -
- - {settings?.entity_type === 'aktiebolag' ? ( - <> - -
- - - Obligatorisk för aktiebolag - -
- - ) : ( - - )} -

- {settings?.entity_type === 'aktiebolag' - ? 'Aktiebolag måste använda faktureringsmetoden enligt BFL.' - : 'Kontantmetoden är tillgänglig för enskild firma med omsättning under 3 MSEK.'} -

-
+
+ +
+
+ + -
-

Skatteinställningar

-
- - -
+ {/* Bankuppgifter */} + + + Bankuppgifter + + Betalningsuppgifter som visas på dina fakturor + + + +
+
+ +
+
+ + +
+
+ + +
+
+
+
-
- + {/* Fakturainställningar */} + + + Fakturainställningar + + Numrering, betalningsvillkor och bokföringsmetod + + + +
+
+ +
- - - +
+ + +
+
+ + +
+
+ +
+ + {settings?.entity_type === 'aktiebolag' ? ( + <> + +
+ + + Obligatorisk för aktiebolag + +
+ + ) : ( + + )} +

+ {settings?.entity_type === 'aktiebolag' + ? 'Aktiebolag måste använda faktureringsmetoden enligt BFL.' + : 'Kontantmetoden är tillgänglig för enskild firma med omsättning under 3 MSEK.'} +

+
+
+
+ + {/* Skatteinställningar */} + + + Skatteinställningar + + Preliminärskatt och F-skatt + + + +
+ + +
+
+
+ +
+ +
+ {/* Banking settings (only shown when extension is active or connections exist) */} diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index d43cfd45..14e7c00c 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -12,9 +12,11 @@ import { useToast } from '@/components/ui/use-toast' import { formatCurrency, formatDate } from '@/lib/utils' import { getCategoryDisplayName } from '@/lib/tax/expense-warnings' import Link from 'next/link' -import { Plus, Search, ArrowLeftRight, ArrowUpRight, ArrowDownRight, Sparkles, Check, FileText, Link2, Upload } from 'lucide-react' +import { Plus, Search, ArrowLeftRight, ArrowUpRight, ArrowDownRight, Sparkles, Check, FileText, Link2, Upload, CheckSquare, X } from 'lucide-react' +import { Checkbox } from '@/components/ui/checkbox' import TransactionForm from '@/components/transactions/TransactionForm' import SwipeCategorizationView from '@/components/transactions/SwipeCategorizationView' +import BatchCategorySelector from '@/components/transactions/BatchCategorySelector' import type { Transaction, TransactionCategory, CreateTransactionInput, Invoice, Customer } from '@/types' import type { SuggestedCategory } from '@/lib/transactions/category-suggestions' @@ -35,6 +37,15 @@ export default function TransactionsPage() { const [isConfirmingMatch, setIsConfirmingMatch] = useState(false) const [categorySuggestions, setCategorySuggestions] = useState>({}) const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false) + const [isBatchMode, setIsBatchMode] = useState(false) + const [selectedIds, setSelectedIds] = useState>(new Set()) + const [showBatchSelector, setShowBatchSelector] = useState(false) + const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null) + const [hideCategorizationHint, setHideCategorizationHint] = useState(true) + + useEffect(() => { + setHideCategorizationHint(localStorage.getItem('hideCategorizationHint') === 'true') + }, []) const { toast } = useToast() const supabase = createClient() @@ -357,6 +368,58 @@ export default function TransactionsPage() { } } + function toggleBatchSelect(id: string) { + setSelectedIds((prev) => { + const next = new Set(prev) + if (next.has(id)) { + next.delete(id) + } else { + next.add(id) + } + return next + }) + } + + function exitBatchMode() { + setIsBatchMode(false) + setSelectedIds(new Set()) + } + + async function handleBatchMarkPrivate() { + const ids = Array.from(selectedIds) + setBatchProgress({ done: 0, total: ids.length }) + + for (let i = 0; i < ids.length; i++) { + await handleCategorize(ids[i], false, 'private') + setBatchProgress({ done: i + 1, total: ids.length }) + } + + setBatchProgress(null) + toast({ + title: 'Klart', + description: `${ids.length} transaktioner markerade som privat`, + }) + exitBatchMode() + } + + async function handleBatchCategorize(category: TransactionCategory) { + const ids = Array.from(selectedIds) + setBatchProgress({ done: 0, total: ids.length }) + + for (let i = 0; i < ids.length; i++) { + await handleCategorize(ids[i], true, category) + setBatchProgress({ done: i + 1, total: ids.length }) + } + + setBatchProgress(null) + setShowBatchSelector(false) + toast({ + title: 'Klart', + description: `${ids.length} transaktioner kategoriserade`, + }) + exitBatchMode() + } + async function openSwipeView() { // Run batch invoice matching for income transactions first await runBatchInvoiceMatching() @@ -416,10 +479,19 @@ export default function TransactionsPage() { {uncategorizedTransactions.length > 0 && ( - + <> + + + )} @@ -474,6 +546,26 @@ export default function TransactionsPage() {
+ {/* Feature discovery hint */} + {!hideCategorizationHint && uncategorizedTransactions.length > 0 && ( +
+ +

+ Tips: Klicka "Kategorisera" ovan för att snabbt svepkategorisera transaktioner en i taget. Använd "Välj flera" för att hantera flera samtidigt. +

+ +
+ )} + {/* Transaction list */} {isLoading ? (
@@ -519,16 +611,31 @@ export default function TransactionsPage() { ) : (
- {filteredTransactions.map((transaction) => ( + {filteredTransactions.map((transaction) => { + const isUncategorized = transaction.is_business === null + const isSelected = selectedIds.has(transaction.id) + const showCheckbox = isBatchMode && isUncategorized + + return ( toggleBatchSelect(transaction.id) : undefined} >
+ {showCheckbox && ( + toggleBatchSelect(transaction.id)} + onClick={(e) => e.stopPropagation()} + /> + )}
0 @@ -618,10 +725,48 @@ export default function TransactionsPage() {
- ))} + ) + })}
)} + {/* Batch mode floating action bar */} + {isBatchMode && selectedIds.size > 0 && ( +
+ {selectedIds.size} valda + + + +
+ )} + + {/* Batch Category Selector */} + + {/* Invoice Match Confirmation Dialog */} diff --git a/app/api/documents/counts/route.ts b/app/api/documents/counts/route.ts new file mode 100644 index 00000000..2cf7e718 --- /dev/null +++ b/app/api/documents/counts/route.ts @@ -0,0 +1,55 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' + +/** + * GET /api/documents/counts?journal_entry_ids=id1,id2,... + * Returns attachment counts per journal entry ID. + * Max 50 IDs per request. + */ +export async function GET(request: Request) { + const supabase = await createClient() + + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { searchParams } = new URL(request.url) + const idsParam = searchParams.get('journal_entry_ids') + + if (!idsParam) { + return NextResponse.json({ error: 'journal_entry_ids is required' }, { status: 400 }) + } + + const ids = idsParam.split(',').filter(Boolean) + + if (ids.length === 0) { + return NextResponse.json({ data: {} }) + } + + if (ids.length > 50) { + return NextResponse.json({ error: 'Maximum 50 IDs per request' }, { status: 400 }) + } + + const { data, error } = await supabase + .from('document_attachments') + .select('journal_entry_id') + .eq('user_id', user.id) + .eq('is_current_version', true) + .in('journal_entry_id', ids) + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } + + // Group and count by journal_entry_id + const counts: Record = {} + for (const row of data || []) { + if (row.journal_entry_id) { + counts[row.journal_entry_id] = (counts[row.journal_entry_id] || 0) + 1 + } + } + + return NextResponse.json({ data: counts }) +} diff --git a/app/api/extensions/receipt-ocr/upload/route.ts b/app/api/extensions/receipt-ocr/upload/route.ts index acb4f047..4e247045 100644 --- a/app/api/extensions/receipt-ocr/upload/route.ts +++ b/app/api/extensions/receipt-ocr/upload/route.ts @@ -65,12 +65,14 @@ export async function POST(request: Request) { } // WORM archive copy (non-blocking — receipt flow continues even if this fails) + let wormDocumentId: string | null = null try { - await uploadDocument(user.id, { + const wormDoc = await uploadDocument(user.id, { name: imageFile.name, buffer: arrayBuffer, type: imageFile.type, }, { upload_source: 'camera' }) + wormDocumentId = wormDoc.id } catch (archiveErr) { console.error('[receipt-upload] WORM archive copy failed:', archiveErr) } @@ -86,6 +88,7 @@ export async function POST(request: Request) { user_id: user.id, image_url: imageUrl, status: 'processing', + document_id: wormDocumentId, }) .select() .single() @@ -178,7 +181,7 @@ export async function POST(request: Request) { type: 'receipt.extracted', payload: { receipt: completeReceipt, - documentId: null, + documentId: wormDocumentId, confidence: extraction.confidence, userId: user.id, }, diff --git a/app/api/reports/monthly-breakdown/route.ts b/app/api/reports/monthly-breakdown/route.ts new file mode 100644 index 00000000..d20adfba --- /dev/null +++ b/app/api/reports/monthly-breakdown/route.ts @@ -0,0 +1,26 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { generateMonthlyBreakdown } from '@/lib/reports/monthly-breakdown' + +export async function GET(request: Request) { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { searchParams } = new URL(request.url) + const periodId = searchParams.get('period_id') + + if (!periodId) { + return NextResponse.json({ error: 'period_id is required' }, { status: 400 }) + } + + try { + const data = await generateMonthlyBreakdown(user.id, periodId) + return NextResponse.json({ data }) + } catch { + return NextResponse.json({ error: 'Failed to generate monthly breakdown' }, { status: 500 }) + } +} diff --git a/app/api/transactions/[id]/categorize/route.ts b/app/api/transactions/[id]/categorize/route.ts index 2cec62fb..2547ae2a 100644 --- a/app/api/transactions/[id]/categorize/route.ts +++ b/app/api/transactions/[id]/categorize/route.ts @@ -209,6 +209,27 @@ export async function POST( } } + // Link receipt document to journal entry if both exist + if (journalEntryId && transaction.receipt_id) { + try { + const { data: receipt } = await supabase + .from('receipts') + .select('document_id') + .eq('id', transaction.receipt_id) + .single() + + if (receipt?.document_id) { + await supabase + .from('document_attachments') + .update({ journal_entry_id: journalEntryId }) + .eq('id', receipt.document_id) + .eq('user_id', user.id) + } + } catch (linkErr) { + console.error('[categorize] Failed to link receipt document:', linkErr) + } + } + // Update the transaction const { error: updateError } = await supabase .from('transactions') diff --git a/app/globals.css b/app/globals.css index 67204d5d..e9c4f4b1 100644 --- a/app/globals.css +++ b/app/globals.css @@ -278,6 +278,15 @@ h1, h2, h3 { background: hsl(var(--muted-foreground) / 0.3); } +/* Hide scrollbar utility */ +.scrollbar-hide { + -ms-overflow-style: none; + scrollbar-width: none; +} +.scrollbar-hide::-webkit-scrollbar { + display: none; +} + /* Selection color */ ::selection { background: hsl(var(--primary) / 0.15); diff --git a/components/bookkeeping/DocumentUploadZone.tsx b/components/bookkeeping/DocumentUploadZone.tsx new file mode 100644 index 00000000..c6e52b8b --- /dev/null +++ b/components/bookkeeping/DocumentUploadZone.tsx @@ -0,0 +1,252 @@ +'use client' + +import { useState, useCallback, useRef } from 'react' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Upload, FileText, ImageIcon, X, Loader2 } from 'lucide-react' + +export interface UploadedFile { + id?: string + file: File + status: 'pending' | 'uploading' | 'uploaded' | 'error' + error?: string + fileName: string + fileSize: number +} + +interface DocumentUploadZoneProps { + files: UploadedFile[] + onFilesChange: (files: UploadedFile[]) => void + journalEntryId?: string + maxFiles?: number + disabled?: boolean + compact?: boolean +} + +const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10 MB +const ACCEPTED_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'] +const ACCEPTED_EXTENSIONS = '.pdf,.jpg,.jpeg,.png,.webp' + +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` +} + +function isImageType(type: string): boolean { + return type.startsWith('image/') +} + +export default function DocumentUploadZone({ + files, + onFilesChange, + journalEntryId, + maxFiles = 5, + disabled = false, + compact = false, +}: DocumentUploadZoneProps) { + const [isDragging, setIsDragging] = useState(false) + const inputRef = useRef(null) + + const uploadFile = useCallback(async (file: UploadedFile): Promise => { + const formData = new FormData() + formData.append('file', file.file) + formData.append('upload_source', 'file_upload') + if (journalEntryId) { + formData.append('journal_entry_id', journalEntryId) + } + + try { + const res = await fetch('/api/documents', { + method: 'POST', + body: formData, + }) + const result = await res.json() + + if (result.error) { + return { ...file, status: 'error', error: result.error } + } + + return { ...file, status: 'uploaded', id: result.data?.id } + } catch { + return { ...file, status: 'error', error: 'Uppladdning misslyckades' } + } + }, [journalEntryId]) + + const handleFiles = useCallback(async (newFiles: File[]) => { + const remaining = maxFiles - files.length + if (remaining <= 0) return + + const validFiles: UploadedFile[] = [] + + for (const file of newFiles.slice(0, remaining)) { + if (!ACCEPTED_TYPES.includes(file.type)) { + validFiles.push({ + file, + status: 'error', + error: 'Filtypen stöds inte', + fileName: file.name, + fileSize: file.size, + }) + continue + } + if (file.size > MAX_FILE_SIZE) { + validFiles.push({ + file, + status: 'error', + error: 'Filen är för stor (max 10 MB)', + fileName: file.name, + fileSize: file.size, + }) + continue + } + validFiles.push({ + file, + status: 'uploading', + fileName: file.name, + fileSize: file.size, + }) + } + + let currentFiles = [...files, ...validFiles] + onFilesChange(currentFiles) + + // Upload files that passed validation + for (const f of validFiles.filter((f) => f.status === 'uploading')) { + const result = await uploadFile(f) + currentFiles = currentFiles.map((cf) => + cf.fileName === result.fileName && cf.status === 'uploading' ? result : cf + ) + onFilesChange([...currentFiles]) + } + }, [files, maxFiles, onFilesChange, uploadFile]) + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault() + if (!disabled) setIsDragging(true) + }, [disabled]) + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault() + setIsDragging(false) + }, []) + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault() + setIsDragging(false) + if (disabled) return + + const droppedFiles = Array.from(e.dataTransfer.files) + handleFiles(droppedFiles) + }, [disabled, handleFiles]) + + const handleInputChange = useCallback((e: React.ChangeEvent) => { + const selectedFiles = e.target.files + if (selectedFiles) { + handleFiles(Array.from(selectedFiles)) + } + // Reset input so the same file can be re-selected + if (inputRef.current) inputRef.current.value = '' + }, [handleFiles]) + + const removeFile = useCallback((index: number) => { + onFilesChange(files.filter((_, i) => i !== index)) + }, [files, onFilesChange]) + + const isUploading = files.some((f) => f.status === 'uploading') + + return ( +
+ {/* Drop zone */} +
inputRef.current?.click()} + > + + +
+ +
+

+ {compact ? 'Dra och släpp eller klicka' : 'Dra och släpp filer här'} +

+ {!compact && ( +

+ PDF, bilder (max 10 MB) +

+ )} +
+
+
+ + {/* File list */} + {files.length > 0 && ( +
+ {files.map((file, index) => ( +
+ {isImageType(file.file.type) ? ( + + ) : ( + + )} + {file.fileName} + + {formatFileSize(file.fileSize)} + + + {file.status === 'uploading' && ( + + )} + {file.status === 'uploaded' && ( + + Uppladdad + + )} + {file.status === 'error' && ( + + Fel + + )} + + +
+ ))} +
+ )} + + {isUploading && ( +

Laddar upp...

+ )} +
+ ) +} diff --git a/components/bookkeeping/JournalEntryAttachments.tsx b/components/bookkeeping/JournalEntryAttachments.tsx new file mode 100644 index 00000000..87b6d480 --- /dev/null +++ b/components/bookkeeping/JournalEntryAttachments.tsx @@ -0,0 +1,209 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' +import { Button } from '@/components/ui/button' +import { FileText, ImageIcon, Download, ChevronDown, ChevronUp, Plus } from 'lucide-react' +import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone' +import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone' + +interface DocumentRecord { + id: string + file_name: string + file_size_bytes: number + mime_type: string | null + storage_path: string + created_at: string + download_url?: string +} + +interface JournalEntryAttachmentsProps { + journalEntryId: string + onCountChange?: (count: number) => void +} + +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` +} + +function isImageType(type: string | null): boolean { + return type?.startsWith('image/') ?? false +} + +export default function JournalEntryAttachments({ + journalEntryId, + onCountChange, +}: JournalEntryAttachmentsProps) { + const [documents, setDocuments] = useState([]) + const [loading, setLoading] = useState(true) + const [expandedImage, setExpandedImage] = useState(null) + const [showUpload, setShowUpload] = useState(false) + const [uploadFiles, setUploadFiles] = useState([]) + + const fetchDocuments = useCallback(async () => { + try { + const res = await fetch( + `/api/documents?journal_entry_id=${journalEntryId}¤t_only=true` + ) + const { data } = await res.json() + setDocuments(data || []) + onCountChange?.(data?.length || 0) + } catch { + console.error('Failed to fetch documents') + } finally { + setLoading(false) + } + }, [journalEntryId, onCountChange]) + + useEffect(() => { + fetchDocuments() + }, [fetchDocuments]) + + // Refresh documents when uploads complete + useEffect(() => { + const allDone = uploadFiles.length > 0 && uploadFiles.every((f) => f.status !== 'uploading') + const hasUploaded = uploadFiles.some((f) => f.status === 'uploaded') + if (allDone && hasUploaded) { + fetchDocuments() + setUploadFiles([]) + setShowUpload(false) + } + }, [uploadFiles, fetchDocuments]) + + const handleDownload = async (docId: string) => { + try { + const res = await fetch(`/api/documents/${docId}`) + const { data } = await res.json() + if (data?.download_url) { + window.open(data.download_url, '_blank') + } + } catch { + console.error('Failed to get download URL') + } + } + + const handlePreviewToggle = async (doc: DocumentRecord) => { + if (expandedImage === doc.id) { + setExpandedImage(null) + return + } + + // Fetch signed URL for preview if not already loaded + if (!doc.download_url) { + try { + const res = await fetch(`/api/documents/${doc.id}`) + const { data } = await res.json() + if (data?.download_url) { + setDocuments((prev) => + prev.map((d) => (d.id === doc.id ? { ...d, download_url: data.download_url } : d)) + ) + } + } catch { + console.error('Failed to get preview URL') + return + } + } + + setExpandedImage(doc.id) + } + + if (loading) { + return ( +
+ Laddar underlag... +
+ ) + } + + return ( +
+
+

+ Underlag {documents.length > 0 && `(${documents.length})`} +

+ +
+ + {/* Upload zone */} + {showUpload && ( +
+ +
+ )} + + {/* Document list */} + {documents.length === 0 && !showUpload ? ( +

+ Inga underlag bifogade. +

+ ) : ( +
+ {documents.map((doc) => ( +
+
+ {isImageType(doc.mime_type) ? ( + + ) : ( + + )} + + {isImageType(doc.mime_type) && expandedImage !== doc.id && ( + + )} + + {doc.file_name} + + {formatFileSize(doc.file_size_bytes)} + + + +
+ + {/* Image preview */} + {expandedImage === doc.id && doc.download_url && ( +
+ {doc.file_name} +
+ )} +
+ ))} +
+ )} +
+ ) +} diff --git a/components/bookkeeping/JournalEntryForm.tsx b/components/bookkeeping/JournalEntryForm.tsx index ffb0c622..7e81e398 100644 --- a/components/bookkeeping/JournalEntryForm.tsx +++ b/components/bookkeeping/JournalEntryForm.tsx @@ -9,6 +9,8 @@ import { useToast } from '@/components/ui/use-toast' import { Plus, Trash2 } from 'lucide-react' import { ConfirmationDialog } from '@/components/ui/confirmation-dialog' import { JournalEntryReviewContent } from '@/components/bookkeeping/JournalEntryReviewContent' +import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone' +import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone' import type { CreateJournalEntryLineInput, FiscalPeriod } from '@/types' interface Props { @@ -34,6 +36,9 @@ export default function JournalEntryForm({ onCreated }: Props) { ]) const [isSubmitting, setIsSubmitting] = useState(false) const [showReview, setShowReview] = useState(false) + const [uploadedFiles, setUploadedFiles] = useState([]) + + const isUploading = uploadedFiles.some((f) => f.status === 'uploading') useEffect(() => { fetchPeriods() @@ -116,6 +121,23 @@ export default function JournalEntryForm({ onCreated }: Props) { variant: 'destructive', }) } else { + // Link uploaded documents to the new journal entry (non-blocking) + const journalEntryId = result.data?.id + if (journalEntryId) { + const filesToLink = uploadedFiles.filter((f) => f.status === 'uploaded' && f.id) + for (const file of filesToLink) { + try { + await fetch(`/api/documents/${file.id}/link`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ journal_entry_id: journalEntryId }), + }) + } catch (linkErr) { + console.error('[JournalEntryForm] Failed to link document:', linkErr) + } + } + } + toast({ title: 'Verifikation skapad', description: `Verifikation ${result.data?.voucher_series}${result.data?.voucher_number} har skapats.`, @@ -123,6 +145,7 @@ export default function JournalEntryForm({ onCreated }: Props) { setShowReview(false) // Reset form setDescription('') + setUploadedFiles([]) setLines([ { account_number: '', debit_amount: '', credit_amount: '', line_description: '' }, { account_number: '', debit_amount: '', credit_amount: '', line_description: '' }, @@ -275,6 +298,15 @@ export default function JournalEntryForm({ onCreated }: Props) {
+ {/* Document attachments */} +
+ + +
+ {!isBalanced && totalDebit > 0 && (

Differens: {Math.abs(totalDebit - totalCredit).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} kr @@ -284,7 +316,7 @@ export default function JournalEntryForm({ onCreated }: Props) {

@@ -305,6 +337,7 @@ export default function JournalEntryForm({ onCreated }: Props) { lines={lines} totalDebit={totalDebit} totalCredit={totalCredit} + attachmentCount={uploadedFiles.filter((f) => f.status === 'uploaded').length} /> diff --git a/components/bookkeeping/JournalEntryList.tsx b/components/bookkeeping/JournalEntryList.tsx index da3a6de6..98475cd7 100644 --- a/components/bookkeeping/JournalEntryList.tsx +++ b/components/bookkeeping/JournalEntryList.tsx @@ -1,13 +1,25 @@ 'use client' -import { useState, useEffect } from 'react' +import { useState, useEffect, useCallback } from 'react' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' -import { ChevronDown, ChevronRight } from 'lucide-react' +import { Label } from '@/components/ui/label' +import { Switch } from '@/components/ui/switch' +import { ChevronDown, ChevronRight, Paperclip, AlertTriangle } from 'lucide-react' import { AccountNumber } from '@/components/ui/account-number' +import JournalEntryAttachments from '@/components/bookkeeping/JournalEntryAttachments' import type { JournalEntry, JournalEntryLine } from '@/types' +const NEEDS_ATTACHMENT = new Set([ + 'manual', + 'bank_transaction', + 'supplier_invoice_registered', + 'supplier_invoice_paid', + 'supplier_invoice_cash_payment', + 'import', +]) + interface Props { periodId?: string } @@ -18,8 +30,23 @@ export default function JournalEntryList({ periodId }: Props) { const [expandedId, setExpandedId] = useState(null) const [count, setCount] = useState(0) const [page, setPage] = useState(0) + const [attachmentCounts, setAttachmentCounts] = useState>({}) + const [showMissingOnly, setShowMissingOnly] = useState(false) const pageSize = 20 + const fetchAttachmentCounts = useCallback(async (entryIds: string[]) => { + if (entryIds.length === 0) return + try { + const res = await fetch( + `/api/documents/counts?journal_entry_ids=${entryIds.join(',')}` + ) + const { data } = await res.json() + setAttachmentCounts(data || {}) + } catch { + console.error('Failed to fetch attachment counts') + } + }, []) + useEffect(() => { fetchEntries() }, [periodId, page]) @@ -34,11 +61,20 @@ export default function JournalEntryList({ periodId }: Props) { const res = await fetch(`/api/bookkeeping/journal-entries?${params}`) const { data, count: total } = await res.json() - setEntries(data || []) + const loadedEntries = data || [] + setEntries(loadedEntries) setCount(total || 0) setLoading(false) + + // Fetch attachment counts for the loaded entries + const ids = loadedEntries.map((e: JournalEntry) => e.id) + fetchAttachmentCounts(ids) } + const handleAttachmentCountChange = useCallback((entryId: string, count: number) => { + setAttachmentCounts((prev) => ({ ...prev, [entryId]: count })) + }, []) + const toggleExpand = (id: string) => { setExpandedId(expandedId === id ? null : id) } @@ -66,6 +102,12 @@ export default function JournalEntryList({ periodId }: Props) { salary_payment: 'Lön', opening_balance: 'Ingående balans', year_end: 'Årsbokslut', + supplier_invoice_registered: 'Leverantörsfaktura', + supplier_invoice_paid: 'Leverantörsbetalning', + supplier_invoice_cash_payment: 'Kontant leverantörsbetalning', + import: 'Import', + storno: 'Storno', + correction: 'Korrigering', } return labels[source] || source } @@ -90,10 +132,36 @@ export default function JournalEntryList({ periodId }: Props) { ) } + const filteredEntries = showMissingOnly + ? entries.filter( + (e) => + NEEDS_ATTACHMENT.has(e.source_type) && + !attachmentCounts[e.id] && + e.status === 'posted' + ) + : entries + return (
+ {/* Missing attachment filter */} +
+ + + {showMissingOnly && ( + + {filteredEntries.length} + + )} +
+
- {entries.map((entry) => { + {filteredEntries.map((entry) => { const isExpanded = expandedId === entry.id const lines = (entry.lines || []) as JournalEntryLine[] @@ -116,6 +184,19 @@ export default function JournalEntryList({ periodId }: Props) { {entry.entry_date} {entry.description} + {/* Attachment indicator */} + {attachmentCounts[entry.id] ? ( + + + {attachmentCounts[entry.id]} + + ) : ( + NEEDS_ATTACHMENT.has(entry.source_type) && entry.status === 'posted' && ( + + + + ) + )} {sourceLabel(entry.source_type)} @@ -178,6 +259,11 @@ export default function JournalEntryList({ periodId }: Props) { + + handleAttachmentCountChange(entry.id, c)} + /> )} diff --git a/components/bookkeeping/JournalEntryReviewContent.tsx b/components/bookkeeping/JournalEntryReviewContent.tsx index 9332555b..2223f9cf 100644 --- a/components/bookkeeping/JournalEntryReviewContent.tsx +++ b/components/bookkeeping/JournalEntryReviewContent.tsx @@ -2,7 +2,7 @@ import { Badge } from '@/components/ui/badge' import { AccountNumber } from '@/components/ui/account-number' -import { CheckCircle2 } from 'lucide-react' +import { CheckCircle2, Paperclip } from 'lucide-react' interface ReviewLine { account_number: string @@ -18,6 +18,7 @@ interface JournalEntryReviewContentProps { lines: ReviewLine[] totalDebit: number totalCredit: number + attachmentCount?: number } function formatAmount(amount: number): string { @@ -31,6 +32,7 @@ export function JournalEntryReviewContent({ lines, totalDebit, totalCredit, + attachmentCount, }: JournalEntryReviewContentProps) { const activeLines = lines.filter( (l) => l.account_number && (l.debit_amount || l.credit_amount) @@ -62,6 +64,12 @@ export function JournalEntryReviewContent({ Debet = Kredit + {attachmentCount != null && attachmentCount > 0 && ( + + + {attachmentCount} {attachmentCount === 1 ? 'underlag' : 'underlag'} + + )}
{/* Debit/Credit table */} diff --git a/components/dashboard/DashboardContent.tsx b/components/dashboard/DashboardContent.tsx index 5b4f15a8..7cb0c3bb 100644 --- a/components/dashboard/DashboardContent.tsx +++ b/components/dashboard/DashboardContent.tsx @@ -20,9 +20,13 @@ import { Receipt, ArrowLeftRight, ChevronDown, + ChevronUp, ArrowRight, Camera, Users, + Landmark, + CheckCircle2, + ClipboardList, } from 'lucide-react' import type { CompanySettings, EntityType, Deadline, ReceiptQueueSummary, OnboardingProgress } from '@/types' @@ -48,6 +52,7 @@ interface DashboardContentProps { export default function DashboardContent({ firstName, settings, summary, onboardingProgress }: DashboardContentProps) { const [showAllAlerts, setShowAllAlerts] = useState(false) + const [showMore, setShowMore] = useState(false) const entityType = (settings?.entity_type as EntityType) || 'enskild_firma' const preliminaryTaxMonthly = settings?.preliminary_tax_monthly || 0 @@ -253,12 +258,116 @@ export default function DashboardContent({ firstName, settings, summary, onboard )} - {/* Upcoming deadlines */} - {summary.deadlines && summary.deadlines.length > 0 && ( -
- -
- )} + {/* 4 Key Summary Cards */} + {(() => { + const passedDeadlinesCount = summary.deadlines.filter(d => !d.is_completed && new Date(d.due_date) <= new Date()).length + const pendingReceiptsCount = summary.receiptQueue + ? summary.receiptQueue.pending_review_count + summary.receiptQueue.unmatched_receipts_count + : 0 + const todoCount = summary.uncategorizedCount + summary.overdueInvoicesCount + pendingReceiptsCount + passedDeadlinesCount + + return ( +
+
+ {/* Card 1: Resultat */} + + +
+ + Resultat +
+

= 0 ? 'text-success' : 'text-destructive' + )}> + {formatLargeNumber(summary.mtd.net)} + kr +

+

+ {formatCurrency(summary.ytd.net)} i år +

+
+
+ + {/* Card 2: Att få betalt */} + + + +
+ + Att få betalt +
+

+ {summary.unpaidInvoicesCount} + st +

+

+ {formatCurrency(summary.unpaidInvoicesTotal)} +

+
+
+ + + {/* Card 3: Banksaldo */} + {summary.bankBalance !== null ? ( + + +
+ + Banksaldo +
+

+ {formatLargeNumber(summary.bankBalance)} + kr +

+
+
+ ) : ( + + + +
+ + Banksaldo +
+

Koppla bank

+

Importera transaktioner

+
+
+ + )} + + {/* Card 4: Att göra */} + + +
+ + Att göra +
+ {todoCount > 0 ? ( + <> +

+ {todoCount} + st +

+

+ Åtgärder att hantera +

+ + ) : ( + <> +
+ +

Allt klart!

+
+ + )} +
+
+
+
+ ) + })()} {/* Quick actions */}
@@ -294,15 +403,14 @@ export default function DashboardContent({ firstName, settings, summary, onboard
- {/* F-skatt warning */} -
- { window.location.href = '/settings' }} - /> -
+ {/* Upcoming deadlines — always visible */} + {summary.deadlines && summary.deadlines.length > 0 && ( +
+ +
+ )} - {/* Alerts section */} + {/* Alerts section — always visible */} {alertItems.length > 0 && (

Att hantera

@@ -321,82 +429,112 @@ export default function DashboardContent({ firstName, settings, summary, onboard
)} - {/* Uncategorized transactions warning */} - {summary.uncategorizedCount > 0 && (summary.uncategorizedIncome > 0 || summary.uncategorizedExpenses > 0) && ( -
- -
- -
-

- {summary.uncategorizedCount} okategoriserade transaktioner -

-

- {summary.uncategorizedIncome > 0 && ( - {formatCurrency(summary.uncategorizedIncome)} intäkter - )} - {summary.uncategorizedIncome > 0 && summary.uncategorizedExpenses > 0 && ', '} - {summary.uncategorizedExpenses > 0 && ( - {formatCurrency(summary.uncategorizedExpenses)} kostnader - )} - {' '}saknas i resultatet -

-
- + {/* Collapsible details section */} + + + {showMore && ( +
+ {/* F-skatt warning */} +
+ { window.location.href = '/settings' }} + /> +
+ + {/* Uncategorized transactions warning */} + {summary.uncategorizedCount > 0 && (summary.uncategorizedIncome > 0 || summary.uncategorizedExpenses > 0) && ( +
+ +
+ +
+

+ {summary.uncategorizedCount} okategoriserade transaktioner +

+

+ {summary.uncategorizedIncome > 0 && ( + {formatCurrency(summary.uncategorizedIncome)} intäkter + )} + {summary.uncategorizedIncome > 0 && summary.uncategorizedExpenses > 0 && ', '} + {summary.uncategorizedExpenses > 0 && ( + {formatCurrency(summary.uncategorizedExpenses)} kostnader + )} + {' '}saknas i resultatet +

+
+ +
+ +
+ )} + + {/* Income/Expenses */} +
+

Resultat

+
+ + +
+ + Intäkter +
+
+

+ {formatLargeNumber(summary.mtd.income)} + kr +

+

denna månad

+
+
+
+

I år

+

{formatCurrency(summary.ytd.income)}

+
+
+
+
+ + + +
+ + Kostnader +
+
+

+ {formatLargeNumber(summary.mtd.expenses)} + kr +

+

denna månad

+
+
+
+

I år

+

{formatCurrency(summary.ytd.expenses)}

+
+
+
+
- -
- )} - - {/* Income/Expenses */} -
-

Resultat

-
- - -
- - Intäkter -
-
-

- {formatLargeNumber(summary.mtd.income)} - kr -

-

denna månad

-
-
-
-

I år

-

{formatCurrency(summary.ytd.income)}

-
-
-
-
- - - -
- - Kostnader -
-
-

- {formatLargeNumber(summary.mtd.expenses)} - kr -

-

denna månad

-
-
-
-

I år

-

{formatCurrency(summary.ytd.expenses)}

-
-
-
-
+
-
+ )}
) } diff --git a/components/dashboard/DashboardNav.tsx b/components/dashboard/DashboardNav.tsx index bbdecbac..83851588 100644 --- a/components/dashboard/DashboardNav.tsx +++ b/components/dashboard/DashboardNav.tsx @@ -49,8 +49,8 @@ const navItems: NavItem[] = [ { href: '/supplier-invoices', label: 'Lev.fakturor', icon: FileInput, group: 'finans' }, { 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: 'övrigt' }, - { href: '/reports', label: 'Rapporter', icon: BarChart3, group: 'övrigt' }, { href: '/help', label: 'Hjälp', icon: HelpCircle, group: 'övrigt' }, { href: '/settings', label: 'Inställningar', icon: Settings, group: 'övrigt' }, ] @@ -66,7 +66,10 @@ export default function DashboardNav({ companyName, entityType }: DashboardNavPr const router = useRouter() const supabase = createClient() const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false) - const [isOvrigtExpanded, setIsOvrigtExpanded] = useState(false) + // Auto-expand Övrigt when the user is on one of its pages, or when manually toggled + const isOnOvrigtPage = ['/import', '/help', '/settings'].some(p => pathname.startsWith(p)) + const [manualOvrigtExpanded, setManualOvrigtExpanded] = useState(false) + const isOvrigtExpanded = isOnOvrigtPage || manualOvrigtExpanded const handleLogout = async () => { await supabase.auth.signOut() @@ -177,7 +180,7 @@ export default function DashboardNav({ companyName, entityType }: DashboardNavPr {/* Övrigt group - collapsible */}
+ ))} +
+
+
+

Intäkter

+
+ {incomeCategories.map((cat) => ( + + ))} +
+
+
+ )} + + + ) +} diff --git a/lib/reports/__tests__/monthly-breakdown.test.ts b/lib/reports/__tests__/monthly-breakdown.test.ts new file mode 100644 index 00000000..b6d8cf8d --- /dev/null +++ b/lib/reports/__tests__/monthly-breakdown.test.ts @@ -0,0 +1,217 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createMockSupabase } from '@/tests/helpers' + +// Mock Supabase server client +const { supabase, mockResult } = createMockSupabase() +vi.mock('@/lib/supabase/server', () => ({ + createClient: vi.fn(() => supabase), +})) + +import { generateMonthlyBreakdown } from '../monthly-breakdown' + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('generateMonthlyBreakdown', () => { + it('returns empty months when no fiscal period found', async () => { + mockResult({ data: null, error: { message: 'not found' } }) + + const result = await generateMonthlyBreakdown('user-1', 'period-1') + expect(result.months).toEqual([]) + }) + + it('returns empty months when no journal entries exist', async () => { + // First call: fiscal period + mockResult({ + data: { start_date: '2024-01-01', end_date: '2024-12-31' }, + error: null, + }) + + // We need two sequential calls with different results. + // The proxy-based mock returns the same result for all calls, + // so we re-mock after the first await completes. + // Instead, test that an empty lines result returns initialized months. + + // For this test, override at the supabase.from level to return different chains + let callCount = 0 + supabase.from.mockImplementation(() => { + callCount++ + if (callCount === 1) { + // fiscal_periods query + return { + select: () => ({ + eq: () => ({ + eq: () => ({ + single: () => + Promise.resolve({ + data: { start_date: '2024-01-01', end_date: '2024-12-31' }, + error: null, + }), + }), + }), + }), + } + } + // journal_entry_lines query + return { + select: () => ({ + eq: () => ({ + eq: () => ({ + eq: () => + Promise.resolve({ + data: [], + error: null, + }), + }), + }), + }), + } + }) + + const result = await generateMonthlyBreakdown('user-1', 'period-1') + expect(result.months.length).toBe(12) + expect(result.months[0].label).toBe('Jan') + expect(result.months[0].income).toBe(0) + expect(result.months[0].expenses).toBe(0) + expect(result.months[11].label).toBe('Dec') + }) + + it('correctly classifies revenue (class 3) and expense (class 4-7) accounts', async () => { + let callCount = 0 + supabase.from.mockImplementation(() => { + callCount++ + if (callCount === 1) { + return { + select: () => ({ + eq: () => ({ + eq: () => ({ + single: () => + Promise.resolve({ + data: { start_date: '2024-01-01', end_date: '2024-03-31' }, + error: null, + }), + }), + }), + }), + } + } + return { + select: () => ({ + eq: () => ({ + eq: () => ({ + eq: () => + Promise.resolve({ + data: [ + { + account_number: '3001', + debit: 0, + credit: 10000, + journal_entry: { entry_date: '2024-01-15', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' }, + }, + { + account_number: '5010', + debit: 3000, + credit: 0, + journal_entry: { entry_date: '2024-01-20', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' }, + }, + { + account_number: '3001', + debit: 0, + credit: 5000, + journal_entry: { entry_date: '2024-02-10', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' }, + }, + { + account_number: '6200', + debit: 1500, + credit: 0, + journal_entry: { entry_date: '2024-02-15', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' }, + }, + ], + error: null, + }), + }), + }), + }), + } + }) + + const result = await generateMonthlyBreakdown('user-1', 'period-1') + + // January + const jan = result.months.find((m) => m.label === 'Jan')! + expect(jan.income).toBe(10000) + expect(jan.expenses).toBe(3000) + expect(jan.net).toBe(7000) + + // February + const feb = result.months.find((m) => m.label === 'Feb')! + expect(feb.income).toBe(5000) + expect(feb.expenses).toBe(1500) + expect(feb.net).toBe(3500) + + // March should be zero + const mar = result.months.find((m) => m.label === 'Mar')! + expect(mar.income).toBe(0) + expect(mar.expenses).toBe(0) + }) + + it('ignores non-revenue/expense accounts (class 1, 2, 8)', async () => { + let callCount = 0 + supabase.from.mockImplementation(() => { + callCount++ + if (callCount === 1) { + return { + select: () => ({ + eq: () => ({ + eq: () => ({ + single: () => + Promise.resolve({ + data: { start_date: '2024-01-01', end_date: '2024-01-31' }, + error: null, + }), + }), + }), + }), + } + } + return { + select: () => ({ + eq: () => ({ + eq: () => ({ + eq: () => + Promise.resolve({ + data: [ + { + account_number: '1930', + debit: 10000, + credit: 0, + journal_entry: { entry_date: '2024-01-15', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' }, + }, + { + account_number: '2611', + debit: 0, + credit: 2500, + journal_entry: { entry_date: '2024-01-15', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' }, + }, + { + account_number: '8999', + debit: 500, + credit: 0, + journal_entry: { entry_date: '2024-01-20', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' }, + }, + ], + error: null, + }), + }), + }), + }), + } + }) + + const result = await generateMonthlyBreakdown('user-1', 'period-1') + const jan = result.months.find((m) => m.label === 'Jan')! + expect(jan.income).toBe(0) + expect(jan.expenses).toBe(0) + }) +}) diff --git a/lib/reports/monthly-breakdown.ts b/lib/reports/monthly-breakdown.ts new file mode 100644 index 00000000..91b9a1f6 --- /dev/null +++ b/lib/reports/monthly-breakdown.ts @@ -0,0 +1,124 @@ +import { createClient } from '@/lib/supabase/server' + +export interface MonthlyBreakdownMonth { + label: string + income: number + expenses: number + net: number +} + +export interface MonthlyBreakdown { + months: MonthlyBreakdownMonth[] +} + +const MONTH_LABELS = [ + 'Jan', 'Feb', 'Mar', 'Apr', 'Maj', 'Jun', + 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dec', +] + +/** + * Generate monthly income vs expenses breakdown for a fiscal period. + * + * Groups posted journal entry lines by month and account class: + * - Class 3 (30xx) = revenue (credit side) + * - Class 4-7 (40xx-79xx) = expenses (debit side) + */ +export async function generateMonthlyBreakdown( + userId: string, + fiscalPeriodId: string +): Promise { + const supabase = await createClient() + + // Get the fiscal period date range + const { data: period, error: periodError } = await supabase + .from('fiscal_periods') + .select('start_date, end_date') + .eq('id', fiscalPeriodId) + .eq('user_id', userId) + .single() + + if (periodError || !period) { + return { months: [] } + } + + // Get all posted journal entry lines for this period with their entry dates + const { data: lines, error: linesError } = await supabase + .from('journal_entry_lines') + .select(` + account_number, + debit, + credit, + journal_entry:journal_entries!inner( + entry_date, + status, + user_id, + fiscal_period_id + ) + `) + .eq('journal_entries.fiscal_period_id', fiscalPeriodId) + .eq('journal_entries.user_id', userId) + .eq('journal_entries.status', 'posted') + + if (linesError || !lines) { + return { months: [] } + } + + // Build monthly aggregates + const monthMap = new Map() + + // Initialize all months in the period range + const startDate = new Date(period.start_date) + const endDate = new Date(period.end_date) + const startMonth = startDate.getMonth() + const endMonth = endDate.getMonth() + (endDate.getFullYear() - startDate.getFullYear()) * 12 + + for (let m = startMonth; m <= endMonth; m++) { + monthMap.set(m % 12, { income: 0, expenses: 0 }) + } + + for (const line of lines) { + const entry = line.journal_entry as unknown as { + entry_date: string + status: string + user_id: string + fiscal_period_id: string + } + const accountClass = parseInt(line.account_number.charAt(0)) + const entryDate = new Date(entry.entry_date) + const month = entryDate.getMonth() + + if (!monthMap.has(month)) { + monthMap.set(month, { income: 0, expenses: 0 }) + } + + const bucket = monthMap.get(month)! + + if (accountClass === 3) { + // Revenue accounts: credit side represents revenue + bucket.income = Math.round((bucket.income + line.credit - line.debit) * 100) / 100 + } else if (accountClass >= 4 && accountClass <= 7) { + // Expense accounts: debit side represents expenses + bucket.expenses = Math.round((bucket.expenses + line.debit - line.credit) * 100) / 100 + } + } + + // Convert to sorted array + const months: MonthlyBreakdownMonth[] = [] + const sortedMonths = Array.from(monthMap.entries()).sort((a, b) => { + // Handle year boundaries (e.g., Nov-Dec-Jan for broken fiscal year) + const aAdj = a[0] < startMonth ? a[0] + 12 : a[0] + const bAdj = b[0] < startMonth ? b[0] + 12 : b[0] + return aAdj - bAdj + }) + + for (const [month, data] of sortedMonths) { + months.push({ + label: MONTH_LABELS[month], + income: data.income, + expenses: data.expenses, + net: Math.round((data.income - data.expenses) * 100) / 100, + }) + } + + return { months } +} diff --git a/supabase/migrations/20240101000029_add_receipt_document_id.sql b/supabase/migrations/20240101000029_add_receipt_document_id.sql new file mode 100644 index 00000000..266cc33d --- /dev/null +++ b/supabase/migrations/20240101000029_add_receipt_document_id.sql @@ -0,0 +1,6 @@ +-- Add document_id to receipts for linking receipt images to WORM archive documents +ALTER TABLE public.receipts + ADD COLUMN document_id uuid REFERENCES public.document_attachments(id) ON DELETE SET NULL; + +-- Index for efficient lookups +CREATE INDEX idx_receipts_document_id ON public.receipts (document_id); From f1d187c00514ecd901f30cb8acb649900f07cf2d Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Sat, 21 Feb 2026 09:58:01 +0100 Subject: [PATCH 2/5] fix: use window.location.origin for auth redirect URL The build-time env var NEXT_PUBLIC_APP_URL was baking in the wrong URL for magic link redirects. Using window.location.origin ensures the redirect always matches the domain the user is currently on. Co-Authored-By: Claude Opus 4.6 --- app/(auth)/login/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index 489f82d7..7da3b810 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -24,7 +24,7 @@ export default function LoginPage() { const { error } = await supabase.auth.signInWithOtp({ email, options: { - emailRedirectTo: `${process.env.NEXT_PUBLIC_APP_URL || window.location.origin}/auth/callback`, + emailRedirectTo: `${window.location.origin}/auth/callback`, }, }) From 91e2c1705aba5ed8e540255694e63d6fef49ed25 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Sat, 21 Feb 2026 14:57:15 +0100 Subject: [PATCH 3/5] feat: per-line VAT, invoice document types, ledger-based VAT declaration, bank reconciliation, and pagination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-line VAT rates: - Add generatePerRateLines() to group invoice items by vat_rate with separate revenue + VAT lines per rate group (invoice-entries.ts) - Add getAvailableVatRates() and getVatTreatmentForRate() (vat-rules.ts) - PDF template shows per-line VAT column and per-rate totals for mixed-rate invoices - Invoice create/review UI supports per-line rate selection - Types: add vat_rate/vat_amount to InvoiceItem, vat_rate to CreateInvoiceItemInput Invoice document types (proforma, delivery note): - Add InvoiceDocumentType, document_type and converted_from_id to Invoice type - PDF hides prices for delivery notes, adds proforma notice - Email templates support all document types - mark-paid skips journal entries for non-invoice document types - Migration 031: invoice_document_type Accounting method support: - Add AccountingMethod type (accrual/cash) - Migration 032: add_accounting_method column to company_settings VAT declaration rewrite: - Rewrite to read directly from general ledger (26xx/3xxx account lines) instead of aggregating invoices/transactions/receipts - ACCOUNT_RUTA mapping drives momsdeklaration boxes from GL balances Bank reconciliation: - Transaction ingest now pre-fetches unlinked GL lines and attempts auto-reconciliation during import - Add transaction.reconciled event type - Add ReconciliationMethod type and reconciliation_method on Transaction - Migration 030: bank_reconciliation - New reconciliation engine, API routes, and BankReconciliationView component Pagination (fetchAllRows): - New lib/supabase/fetch-all.ts overcomes PostgREST 1000-row limit - Adopted in all report generators, SIE/SRU export, account list APIs Fiscal period validation: - New validate-period-duration.ts enforces max 18 months per BFL 3 kap. - Applied in period-service.ts and fiscal-periods API Account mapper simplification: - Remove Levenshtein/fuzzy matching, use exact account number match only Swedbank parser improvements: - Support abbreviated headers (Clnr, Bokfdag, Radnr) - Use Referens column as counterparty Chart of accounts management: - Add DELETE endpoint with system account and usage protection - PUT uses partial updates - New AccountCombobox, AddAccountDialog, EditAccountDialog, ChartOfAccountsManager Tax deadline corrections: - Rewrite inkomstdeklaration_ab using Skatteverket lookup table - Rewrite arsredovisning deadline to 7 months after FY end per ÅRL 8:3 Onboarding first fiscal year: - Add first fiscal year toggle with date pickers and 18-month validation UI terminology: - Change "okategoriserad/kategorisera" to "obokförd/bokföra" throughout Report column fix: - Fix start_date/end_date to period_start/period_end in report queries Supplier invoice input: - CreateSupplierInvoiceItemInput uses amount field (legacy quantity/unit_price kept) Misc: - SIE import uses upsert for idempotent account creation - account-descriptions.ts falls back to BAS reference data - Add invoice_default_notes to CompanySettings - Update CLAUDE.md to reflect current project state Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 69 +- app/(dashboard)/bookkeeping/page.tsx | 4 +- app/(dashboard)/import/page.tsx | 22 +- app/(dashboard)/invoices/[id]/page.tsx | 150 +- app/(dashboard)/invoices/new/page.tsx | 209 +- app/(dashboard)/invoices/page.tsx | 28 +- app/(dashboard)/reports/page.tsx | 12 +- app/(dashboard)/settings/page.tsx | 52 +- .../supplier-invoices/new/page.tsx | 148 +- app/(dashboard)/transactions/page.tsx | 37 +- app/(onboarding)/onboarding/page.tsx | 92 +- .../bookkeeping/accounts/[number]/route.ts | 72 +- .../bookkeeping/accounts/activate/route.ts | 84 + .../bookkeeping/accounts/reference/route.ts | 62 + app/api/bookkeeping/accounts/route.ts | 41 +- app/api/bookkeeping/fiscal-periods/route.ts | 7 + app/api/import/sie/create-accounts/route.ts | 43 +- app/api/import/sie/execute/route.ts | 20 +- app/api/import/sie/parse/route.ts | 20 +- app/api/invoices/[id]/convert/route.ts | 137 ++ app/api/invoices/[id]/mark-paid/route.ts | 44 +- app/api/invoices/[id]/mark-sent/route.ts | 10 +- app/api/invoices/[id]/send/route.ts | 28 +- app/api/invoices/preview-pdf/route.ts | 134 ++ app/api/invoices/route.ts | 107 +- app/api/reconciliation/bank/link/route.ts | 33 + app/api/reconciliation/bank/run/route.ts | 45 + app/api/reconciliation/bank/status/route.ts | 20 + app/api/reconciliation/bank/unlink/route.ts | 30 + .../bank/unmatched-entries/route.ts | 20 + app/api/reports/vat-declaration/route.ts | 14 +- app/api/settings/route.ts | 10 + .../supplier-invoices/[id]/credit/route.ts | 46 +- app/api/supplier-invoices/route.ts | 13 +- .../transactions/[id]/match-invoice/route.ts | 140 +- components/bookkeeping/AccountCombobox.tsx | 218 ++ components/bookkeeping/AddAccountDialog.tsx | 225 ++ .../bookkeeping/ChartOfAccountsManager.tsx | 564 +++++ components/bookkeeping/DocumentUploadZone.tsx | 11 +- components/bookkeeping/EditAccountDialog.tsx | 139 ++ components/bookkeeping/JournalEntryForm.tsx | 28 +- components/dashboard/DashboardContent.tsx | 8 +- components/import/AccountMappingStep.tsx | 23 +- components/import/BankFileConfirmStep.tsx | 6 +- components/import/BankFilePreviewStep.tsx | 17 + components/import/BankFileResultStep.tsx | 8 +- components/import/ImportResultStep.tsx | 8 +- components/invoices/InvoiceReviewContent.tsx | 44 +- .../onboarding/Step3TaxRegistration.tsx | 442 +++- components/reports/BankReconciliationView.tsx | 598 +++++ .../SupplierInvoiceReviewContent.tsx | 188 +- .../transactions/BatchCategorySelector.tsx | 7 +- .../transactions/SwipeCategorizationView.tsx | 10 +- components/ui/confirmation-dialog.tsx | 3 + components/ui/empty-state.tsx | 2 +- .../Skärmavbild 2026-01-29 kl. 12.41.50.png | Bin 0 -> 83663 bytes .../Skärmavbild 2026-01-29 kl. 12.42.17.png | Bin 0 -> 86424 bytes .../Skärmavbild 2026-01-29 kl. 12.42.24.png | Bin 0 -> 85220 bytes extensions/ne-bilaga/ne-engine.ts | 14 +- .../components/ReceiptDashboard.tsx | 2 +- .../receipt-ocr/lib/receipt-categorizer.ts | 2 +- extensions/sru-export/sru-engine.ts | 50 +- .../__tests__/invoice-entries.test.ts | 355 ++- .../validate-period-duration.test.ts | 80 + lib/bookkeeping/account-descriptions.ts | 50 +- lib/bookkeeping/bas-reference.ts | 2038 +++++++++++++++++ lib/bookkeeping/invoice-entries.ts | 217 +- lib/bookkeeping/mapping-engine.ts | 2 +- lib/bookkeeping/validate-period-duration.ts | 48 + lib/core/bookkeeping/period-service.ts | 7 + lib/email/invoice-templates.ts | 29 +- lib/events/types.ts | 2 + lib/import/__tests__/account-mapper.test.ts | 351 +++ lib/import/__tests__/sie-import.test.ts | 223 ++ lib/import/__tests__/sie-parser.test.ts | 345 +++ lib/import/account-mapper.ts | 202 +- lib/import/bank-file/formats/swedbank.ts | 96 +- lib/import/sie-import.ts | 1 + lib/invoice/pdf-template.tsx | 129 +- lib/invoice/vat-rules.ts | 53 + .../__tests__/bank-reconciliation.test.ts | 515 +++++ lib/reconciliation/bank-reconciliation.ts | 548 +++++ lib/reports/__tests__/general-ledger.test.ts | 14 +- .../__tests__/journal-register.test.ts | 12 +- .../__tests__/monthly-breakdown.test.ts | 8 +- lib/reports/__tests__/sie-export.test.ts | 2 +- lib/reports/__tests__/trial-balance.test.ts | 2 +- lib/reports/__tests__/vat-declaration.test.ts | 324 ++- lib/reports/general-ledger.ts | 24 +- lib/reports/journal-register.ts | 20 +- lib/reports/monthly-breakdown.ts | 6 +- lib/reports/sie-export.ts | 16 +- lib/reports/trial-balance.ts | 14 +- lib/reports/vat-declaration.ts | 520 +---- lib/supabase/fetch-all.ts | 39 + lib/tax/__tests__/deadline-config.test.ts | 156 ++ lib/tax/deadline-config.ts | 110 +- lib/tax/expense-warnings.ts | 2 +- lib/transactions/__tests__/ingest.test.ts | 126 + lib/transactions/ingest.ts | 36 + scripts/clear-user-data.sql | 115 + .../20240101000030_bank_reconciliation.sql | 70 + .../20240101000031_invoice_document_type.sql | 19 + .../20240101000032_add_accounting_method.sql | 5 + tests/helpers.ts | 1 + types/index.ts | 39 +- 106 files changed, 9950 insertions(+), 1641 deletions(-) create mode 100644 app/api/bookkeeping/accounts/activate/route.ts create mode 100644 app/api/bookkeeping/accounts/reference/route.ts create mode 100644 app/api/invoices/[id]/convert/route.ts create mode 100644 app/api/invoices/preview-pdf/route.ts create mode 100644 app/api/reconciliation/bank/link/route.ts create mode 100644 app/api/reconciliation/bank/run/route.ts create mode 100644 app/api/reconciliation/bank/status/route.ts create mode 100644 app/api/reconciliation/bank/unlink/route.ts create mode 100644 app/api/reconciliation/bank/unmatched-entries/route.ts create mode 100644 components/bookkeeping/AccountCombobox.tsx create mode 100644 components/bookkeeping/AddAccountDialog.tsx create mode 100644 components/bookkeeping/ChartOfAccountsManager.tsx create mode 100644 components/bookkeeping/EditAccountDialog.tsx create mode 100644 components/reports/BankReconciliationView.tsx create mode 100644 dev_docs/bokio/Skärmavbild 2026-01-29 kl. 12.41.50.png create mode 100644 dev_docs/bokio/Skärmavbild 2026-01-29 kl. 12.42.17.png create mode 100644 dev_docs/bokio/Skärmavbild 2026-01-29 kl. 12.42.24.png create mode 100644 lib/bookkeeping/__tests__/validate-period-duration.test.ts create mode 100644 lib/bookkeeping/bas-reference.ts create mode 100644 lib/bookkeeping/validate-period-duration.ts create mode 100644 lib/import/__tests__/account-mapper.test.ts create mode 100644 lib/import/__tests__/sie-import.test.ts create mode 100644 lib/import/__tests__/sie-parser.test.ts create mode 100644 lib/reconciliation/__tests__/bank-reconciliation.test.ts create mode 100644 lib/reconciliation/bank-reconciliation.ts create mode 100644 lib/supabase/fetch-all.ts create mode 100644 lib/tax/__tests__/deadline-config.test.ts create mode 100644 scripts/clear-user-data.sql create mode 100644 supabase/migrations/20240101000030_bank_reconciliation.sql create mode 100644 supabase/migrations/20240101000031_invoice_document_type.sql create mode 100644 supabase/migrations/20240101000032_add_accounting_method.sql diff --git a/CLAUDE.md b/CLAUDE.md index ca8f6687..9222472a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,7 +40,13 @@ app/ components/ ui/ shadcn/ui primitives (button, card, dialog, table, etc.) - [feature]/ Feature-organized components (banking, invoices, suppliers, etc.) + bookkeeping/ Chart of accounts manager, account combobox, add/edit dialogs + calendar/ Calendar views, deadline cards, payment summary, todo widgets + chat/ ChatWidget, ChatPanel, ChatInput, ChatMessage + dashboard/ DashboardContent, DashboardNav, FSkattWarningCard + reports/ Report views (including BankReconciliationView) + settings/ CalendarFeedSettings + [feature]/ Feature-organized components (invoices, suppliers, import, etc.) extensions/ First-party extension implementations ai-categorization/ AI-powered transaction categorization @@ -55,12 +61,15 @@ extensions/ First-party extension implementations lib/ bookkeeping/ Core journal entry engine and all entry generators engine.ts Draft/commit workflow, balance validation, voucher numbering - invoice-entries.ts Sales invoice journal entries + invoice-entries.ts Sales invoice journal entries (supports per-line VAT rates) transaction-entries.ts Bank transaction journal entries supplier-invoice-entries.ts Purchase invoice journal entries category-mapping.ts Category-to-BAS-account mapping mapping-engine.ts Rule-based auto-categorization (MCC codes, merchant patterns) vat-entries.ts VAT line generation + bas-reference.ts BAS account catalog (~180 accounts with metadata, SRU codes) + account-descriptions.ts Human-readable account name lookup + validate-period-duration.ts Fiscal period duration validation (BFL 3 kap.) core/ bookkeeping/ Period service, storno reversal, year-end closing documents/ Document archive (upload, versioning, SHA-256 integrity) @@ -74,23 +83,28 @@ lib/ events/ Event bus (bus.ts, types.ts) extensions/ Extension registry, loader, types import/ SIE and bank file parser - invoice/ VAT rules for invoicing - invoices/ Invoice business logic helpers + bank-file/ Bank file parser with format modules + formats/ camt053, generic-csv, handelsbanken, nordea, seb, swedbank + invoice/ VAT rules, invoice matching (vat-rules.ts, invoice-matching.ts) + invoices/ Invoice business logic (reminder-processor) + reconciliation/ Bank reconciliation engine (4-pass matching algorithm) reports/ Financial reports (trial-balance, income-statement, balance-sheet, vat-declaration, sie-export, supplier-ledger, supplier-reconciliation, general-ledger, journal-register, - ar-ledger, ar-reconciliation) - supabase/ Client setup (client.ts = browser, server.ts = server/admin) + ar-ledger, ar-reconciliation, monthly-breakdown) + supabase/ Client setup (client.ts = browser, server.ts = server/admin, + fetch-all.ts = pagination helper for large queries) tax/ Tax calculations, deadlines, Swedish holidays transactions/ Transaction processing helpers init.ts Extension loader (idempotent, called by API routes) utils.ts Shared utility functions -types/index.ts Canonical type definitions (110+ types, single source of truth) +types/index.ts Canonical type definitions (120+ types, single source of truth) types/chat.ts Chat-specific type definitions tests/helpers.ts Mock factories and fixture builders supabase/migrations/ SQL migration files +scripts/ Utility scripts (clear-user-data.sql) dev_docs/ Extensive project documentation (PRD, architecture, BAS guide, etc.) ``` @@ -118,10 +132,10 @@ The bookkeeping engine (`lib/bookkeeping/engine.ts`) is the most critical system | Function | File | Purpose | |----------|------|---------| -| `createInvoiceJournalEntry()` | `invoice-entries.ts` | Debit 1510, Credit 30xx + 26xx VAT | +| `createInvoiceJournalEntry()` | `invoice-entries.ts` | Debit 1510, Credit 30xx + 26xx VAT (per-line VAT rates) | | `createInvoicePaymentJournalEntry()` | `invoice-entries.ts` | Debit 1930, Credit 1510 | -| `createCreditNoteJournalEntry()` | `invoice-entries.ts` | Reverses original invoice entry | -| `createInvoiceCashEntry()` | `invoice-entries.ts` | Cash method: revenue + VAT at payment | +| `createCreditNoteJournalEntry()` | `invoice-entries.ts` | Reverses original invoice entry (per-rate lines) | +| `createInvoiceCashEntry()` | `invoice-entries.ts` | Cash method: revenue + VAT at payment (per-rate) | | `createTransactionJournalEntry()` | `transaction-entries.ts` | Maps bank transactions via MappingResult | | `createSupplierInvoiceRegistrationEntry()` | `supplier-invoice-entries.ts` | Debit expense + 2641, Credit 2440 | | `createSupplierInvoicePaymentEntry()` | `supplier-invoice-entries.ts` | Debit 2440, Credit 1930 | @@ -147,6 +161,23 @@ The bookkeeping engine (`lib/bookkeeping/engine.ts`) is the most critical system `standard_25`, `reduced_12`, `reduced_6`, `reverse_charge`, `export`, `exempt` +### Per-Line VAT + +Invoice items support individual `vat_rate` values, enabling mixed-rate invoices. The helper `generatePerRateLines()` in `invoice-entries.ts` groups items by VAT rate and creates separate revenue + VAT account lines per rate group. Available rates depend on customer type — use `getAvailableVatRates(customerType, vatNumberValidated)` from `lib/invoice/vat-rules.ts`. + +### Bank Reconciliation + +The reconciliation engine (`lib/reconciliation/bank-reconciliation.ts`) matches bank transactions to journal entry lines on account 1930 using a 4-pass algorithm: + +| Pass | Method | Confidence | Match Criteria | +|------|--------|------------|----------------| +| 1 | `auto_exact` | 0.95 | Exact amount + exact date | +| 2 | `auto_reference` | 0.90 | Exact amount + reference/description match | +| 3 | `auto_date_range` | 0.85 | Exact amount + date within ±3 days | +| 4 | `auto_fuzzy` | 0.75 | Fuzzy amount (±0.01) + exact date | + +Manual linking (`manual` method) is also supported. Only SEK transactions are reconciled. Greedy assignment prevents double-matching. + --- ## Accounting Guard Rails @@ -251,6 +282,7 @@ All defined in `lib/events/types.ts`: | `receipt.extracted` | `{ receipt, documentId, confidence, userId }` | | `receipt.matched` | `{ receipt, transaction, confidence, autoMatched, userId }` | | `receipt.confirmed` | `{ receipt, businessTotal, privateTotal, userId }` | +| `transaction.reconciled` | `{ transaction, journalEntryId, method, userId }` | | `audit.security_event` | `{ event, userId }` | ### Event Bus Behavior @@ -305,10 +337,17 @@ mockResult({ data: makeTransaction(), error: null }) ### Reference Tests - `lib/bookkeeping/__tests__/engine.test.ts` — Balance validation +- `lib/bookkeeping/__tests__/invoice-entries.test.ts` — Per-line VAT, mixed-rate invoices, credit notes - `lib/core/bookkeeping/__tests__/storno-service.test.ts` — Complex mock queues - `lib/core/documents/__tests__/document-service.test.ts` — Storage mocking - `lib/events/__tests__/bus.test.ts` — Event bus behavior - `lib/extensions/__tests__/registry.test.ts` — Extension registration +- `lib/import/__tests__/sie-parser.test.ts` — SIE file parsing +- `lib/import/bank-file/__tests__/parser.test.ts` — Bank file format parsing +- `lib/reconciliation/__tests__/bank-reconciliation.test.ts` — Reconciliation matching algorithm +- `lib/reports/__tests__/vat-declaration.test.ts` — VAT declaration report +- `lib/tax/__tests__/deadline-config.test.ts` — Tax deadline configuration +- `lib/transactions/__tests__/ingest.test.ts` — Transaction ingestion and dedup --- @@ -316,11 +355,11 @@ mockResult({ data: makeTransaction(), error: null }) ### Location -`supabase/migrations/` — currently 28 files numbered `20240101000001` through `20240101000028`. +`supabase/migrations/` — currently 32 files numbered `20240101000001` through `20240101000032`. ### Naming Convention -`YYYYMMDD00NNNN_descriptive_name.sql` — next migration: `20240101000029_*.sql` +`YYYYMMDD00NNNN_descriptive_name.sql` — next migration: `20240101000033_*.sql` ### Migration Rules @@ -354,6 +393,12 @@ mockResult({ data: makeTransaction(), error: null }) - `set_committed_at` — Auto-sets timestamp on draft-to-posted transition - `calculate_retention_expiry` — Auto-sets `retention_expires_at = period_end + 7 years` +### Recent Migrations + +- **Migration 030 (`bank_reconciliation`)** — Adds `reconciliation_method` column to `transactions` (CHECK constraint for method types), indexes for unmatched transaction lookup, and RPC `get_unlinked_1930_lines()` for finding unreconciled GL lines. +- **Migration 031 (`invoice_document_type`)** — Adds `document_type` column to `invoices` (CHECK: invoice/proforma/delivery_note, default 'invoice') and `converted_from_id` FK for tracking proforma-to-invoice conversions. +- **Migration 032 (`add_accounting_method`)** — Adds `accounting_method` column to `company_settings` (CHECK: accrual/cash, default 'accrual') to support kontantmetoden vs faktureringsmetoden. + --- ## Type System diff --git a/app/(dashboard)/bookkeeping/page.tsx b/app/(dashboard)/bookkeeping/page.tsx index fa16d229..0c5c7254 100644 --- a/app/(dashboard)/bookkeeping/page.tsx +++ b/app/(dashboard)/bookkeeping/page.tsx @@ -6,7 +6,7 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' import { Button } from '@/components/ui/button' import JournalEntryList from '@/components/bookkeeping/JournalEntryList' import JournalEntryForm from '@/components/bookkeeping/JournalEntryForm' -import ChartOfAccounts from '@/components/bookkeeping/ChartOfAccounts' +import ChartOfAccountsManager from '@/components/bookkeeping/ChartOfAccountsManager' import { Lock } from 'lucide-react' export default function BookkeepingPage() { @@ -45,7 +45,7 @@ export default function BookkeepingPage() { - +
diff --git a/app/(dashboard)/import/page.tsx b/app/(dashboard)/import/page.tsx index a0719c2e..83ee858d 100644 --- a/app/(dashboard)/import/page.tsx +++ b/app/(dashboard)/import/page.tsx @@ -133,8 +133,10 @@ function BankFileImportWizard() { description: `${txCount} transaktioner hittades`, }) } else if (data.data.parse_result.format === 'generic_csv' || !data.data.detected_format) { - // Unrecognized format — show upload step with error setBankError('Kunde inte identifiera bankformatet. Välj bank manuellt eller använd "Annan CSV".') + } else { + // Format detected but no transactions parsed — parser couldn't extract rows + setBankError('Filen kunde läsas men inga transaktioner hittades. Kontrollera att filen innehåller transaktionsdata och inte bara rubriker.') } } catch (err) { setBankError(err instanceof Error ? err.message : 'Kunde inte läsa filen') @@ -293,8 +295,6 @@ function BankFileImportWizard() { // SIE Import Wizard (unchanged, extracted into component) // ============================================================ -const SIE_STEPS: ImportWizardStep[] = ['upload', 'preview', 'mapping', 'review', 'result'] - const SIE_STEP_LABELS: Record = { upload: 'Ladda upp', preview: 'Förhandsgranskning', @@ -320,8 +320,14 @@ function SIEImportWizard() { const [_sieAccounts, setSieAccounts] = useState<{ number: string; name: string }[]>([]) const [isCreatingAccounts, setIsCreatingAccounts] = useState(false) - const currentStepIndex = SIE_STEPS.indexOf(step) - const progress = ((currentStepIndex + 1) / SIE_STEPS.length) * 100 + // Skip the mapping step when all accounts are already mapped + const hasUnmapped = mappings.some((m) => !m.targetAccount) + const sieSteps: ImportWizardStep[] = hasUnmapped + ? ['upload', 'preview', 'mapping', 'review', 'result'] + : ['upload', 'preview', 'review', 'result'] + + const currentStepIndex = sieSteps.indexOf(step) + const progress = ((currentStepIndex + 1) / sieSteps.length) * 100 const handleFileSelect = useCallback(async (selectedFile: File) => { setFile(selectedFile) @@ -490,7 +496,7 @@ function SIEImportWizard() { }, [file, mappings, toast]) const goToStep = (targetStep: ImportWizardStep) => { setStep(targetStep); setError(null) } - const goBack = () => { const i = SIE_STEPS.indexOf(step); if (i > 0) setStep(SIE_STEPS[i - 1]) } + const goBack = () => { const i = sieSteps.indexOf(step); if (i > 0) setStep(sieSteps[i - 1]) } const handleNewImport = () => { setStep('upload'); setFile(null); setParsed(null); setMappings([]) @@ -504,7 +510,7 @@ function SIEImportWizard() {
- {SIE_STEPS.map((s, i) => ( + {sieSteps.map((s, i) => ( {SIE_STEP_LABELS[s]} @@ -519,7 +525,7 @@ function SIEImportWizard() { {step === 'preview' && preview && ( goToStep('mapping')} onBack={goBack} /> + onContinue={() => goToStep(hasUnmapped ? 'mapping' : 'review')} onBack={goBack} /> )} {step === 'mapping' && ( = { draft: { label: 'Utkast', variant: 'secondary', icon: FileText }, @@ -63,6 +63,8 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st const [reminders, setReminders] = useState([]) const [creditNote, setCreditNote] = useState(null) const [originalInvoice, setOriginalInvoice] = useState(null) + const [convertedFromInvoice, setConvertedFromInvoice] = useState(null) + const [isConverting, setIsConverting] = useState(false) const [isLoading, setIsLoading] = useState(true) const [isUpdating, setIsUpdating] = useState(false) const [isDownloading, setIsDownloading] = useState(false) @@ -139,6 +141,19 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st } } + // If this invoice was converted from a proforma, fetch it + if (data.converted_from_id) { + const { data: convertedData } = await supabase + .from('invoices') + .select('id, invoice_number') + .eq('id', data.converted_from_id) + .single() + + if (convertedData) { + setConvertedFromInvoice(convertedData as Invoice) + } + } + setIsLoading(false) } @@ -235,6 +250,38 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st setIsSendingEmail(false) } + async function convertToInvoice() { + if (!invoice) return + setIsConverting(true) + + try { + const response = await fetch(`/api/invoices/${invoice.id}/convert`, { + method: 'POST', + }) + + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Kunde inte konvertera proformafakturan') + } + + toast({ + title: 'Konverterad till faktura', + description: `Faktura ${data.data.invoice_number} har skapats`, + }) + + router.push(`/invoices/${data.data.id}`) + } catch (error) { + toast({ + title: 'Fel', + description: error instanceof Error ? error.message : 'Kunde inte konvertera', + variant: 'destructive', + }) + } + + setIsConverting(false) + } + async function downloadPDF() { if (!invoice) return @@ -288,6 +335,11 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st const StatusIcon = status.icon const customer = invoice.customer const customerHasEmail = !!customer.email + const docType = ((invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice') as InvoiceDocumentType + const isProforma = docType === 'proforma' + const isDeliveryNote = docType === 'delivery_note' + const isRealInvoice = docType === 'invoice' + const docLabel = isProforma ? 'Proformafaktura' : isDeliveryNote ? 'Följesedel' : 'Faktura' return (
@@ -300,6 +352,12 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st

{invoice.invoice_number}

+ {isProforma && ( + Proforma + )} + {isDeliveryNote && ( + Följesedel + )} {status.label} @@ -314,7 +372,17 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st {/* Actions */}
- {invoice.status === 'draft' && ( + {isProforma && invoice.status !== 'cancelled' && ( + + )} + {invoice.status === 'draft' && !isDeliveryNote && ( customerHasEmail ? ( ) )} - {(invoice.status === 'sent' || invoice.status === 'overdue') && ( + {isDeliveryNote && invoice.status === 'draft' && ( + + )} + {(invoice.status === 'sent' || invoice.status === 'overdue') && isRealInvoice && ( + + + + )} + {/* Status actions */} {invoice.status !== 'cancelled' && invoice.status !== 'credited' && !invoice.credited_invoice_id && ( @@ -663,9 +760,34 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st Åtgärder - {invoice.status === 'draft' && ( + {isProforma && ( <> - {customerHasEmail ? ( + + + + )} + {!isProforma && invoice.status === 'draft' && ( + <> + {!isDeliveryNote && customerHasEmail ? ( )} - {(invoice.status === 'sent' || invoice.status === 'overdue') && ( + {(invoice.status === 'sent' || invoice.status === 'overdue') && isRealInvoice && ( <>
-

Ny faktura

-

Skapa en ny faktura

+

+ {watchDocumentType === 'proforma' ? 'Ny proformafaktura' : watchDocumentType === 'delivery_note' ? 'Ny följesedel' : 'Ny faktura'} +

+

+ {watchDocumentType === 'proforma' ? 'Skapa en proformafaktura (ingen bokföring)' : watchDocumentType === 'delivery_note' ? 'Skapa en följesedel (utan priser)' : 'Skapa en ny faktura'} +

@@ -300,7 +391,7 @@ export default function NewInvoicePage() {
{fields.map((field, index) => (
-
+
)}
-
+
+
+ + ( + + )} + /> +
+ } > ({ + ...item, + vat_rate: item.vat_rate ?? (vatRules?.rate || 25), + }))} subtotal={subtotal} vatRate={vatRules.rate} vatAmount={vatAmount} diff --git a/app/(dashboard)/invoices/page.tsx b/app/(dashboard)/invoices/page.tsx index 630187f6..f10a5a15 100644 --- a/app/(dashboard)/invoices/page.tsx +++ b/app/(dashboard)/invoices/page.tsx @@ -11,7 +11,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { PageHeader } from '@/components/ui/page-header' import { useToast } from '@/components/ui/use-toast' import { formatCurrency, formatDate } from '@/lib/utils' -import { Plus, Search, Receipt, FileText, Send, CheckCircle, Clock, XCircle, ReceiptText, AlertTriangle } from 'lucide-react' +import { Plus, Search, Receipt, FileText, Send, CheckCircle, Clock, XCircle, ReceiptText, AlertTriangle, FileQuestion, Truck } from 'lucide-react' import { EmptyInvoices } from '@/components/ui/empty-state' import type { Invoice, InvoiceStatus } from '@/types' @@ -82,11 +82,14 @@ export default function InvoicesPage() { (invoice.customer as { name: string })?.name?.toLowerCase().includes(searchTerm.toLowerCase()) const isCreditNote = !!invoice.credited_invoice_id + const docType = (invoice as Invoice & { document_type?: string }).document_type || 'invoice' const matchesTab = activeTab === 'all' || - (activeTab === 'unpaid' && ['sent', 'overdue'].includes(invoice.status) && !isCreditNote) || + (activeTab === 'unpaid' && ['sent', 'overdue'].includes(invoice.status) && !isCreditNote && docType === 'invoice') || (activeTab === 'credit' && isCreditNote) || - invoice.status === activeTab + (activeTab === 'proforma' && docType === 'proforma') || + (activeTab === 'delivery_note' && docType === 'delivery_note') || + (activeTab !== 'proforma' && activeTab !== 'delivery_note' && invoice.status === activeTab) return matchesSearch && matchesTab }) @@ -181,6 +184,8 @@ export default function InvoicesPage() { Obetalda Betalda Utkast + Proforma + Följesedel Kredit @@ -232,9 +237,12 @@ export default function InvoicesPage() { {filteredInvoices.map((invoice) => { const status = statusConfig[invoice.status] const isCreditNote = !!invoice.credited_invoice_id - const StatusIcon = isCreditNote ? ReceiptText : status.icon + const docType = (invoice as Invoice & { document_type?: string }).document_type || 'invoice' + const isProforma = docType === 'proforma' + const isDeliveryNote = docType === 'delivery_note' + const StatusIcon = isCreditNote ? ReceiptText : isProforma ? FileQuestion : isDeliveryNote ? Truck : status.icon const relativeTime = invoice.due_date ? getRelativeTimeLabel(invoice.due_date, invoice.status) : null - const borderClass = isCreditNote ? 'border-l-4 border-l-destructive/50' : `border-l-4 ${status.borderColor}` + const borderClass = isCreditNote ? 'border-l-4 border-l-destructive/50' : isProforma ? 'border-l-4 border-l-blue-400' : isDeliveryNote ? 'border-l-4 border-l-emerald-400' : `border-l-4 ${status.borderColor}` return ( @@ -253,6 +261,16 @@ export default function InvoicesPage() { Kredit )} + {isProforma && ( + + Proforma + + )} + {isDeliveryNote && ( + + Följesedel + + )} {status.label} diff --git a/app/(dashboard)/reports/page.tsx b/app/(dashboard)/reports/page.tsx index 14120498..825f0d8e 100644 --- a/app/(dashboard)/reports/page.tsx +++ b/app/(dashboard)/reports/page.tsx @@ -6,10 +6,11 @@ import { Button } from '@/components/ui/button' import { Label } from '@/components/ui/label' import { Badge } from '@/components/ui/badge' import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' -import { Download, FileText, FileDown, TrendingUp, Scale, AlertCircle, Receipt, Briefcase, Building2, BookOpen, List, Users, ChevronDown, ChevronRight } from 'lucide-react' +import { Download, FileText, FileDown, TrendingUp, Scale, AlertCircle, Receipt, Briefcase, Building2, BookOpen, List, Users, ChevronDown, ChevronRight, ArrowLeftRight } from 'lucide-react' import { AccountNumber } from '@/components/ui/account-number' import { NEDeclarationView } from '@/extensions/ne-bilaga/NEDeclarationView' import { SRUExportView } from '@/extensions/sru-export/SRUExportView' +import { BankReconciliationView } from '@/components/reports/BankReconciliationView' import { TrialBalanceChart } from '@/components/reports/TrialBalanceChart' import { VatCompositionChart } from '@/components/reports/VatCompositionChart' import { IncomeExpenseChart } from '@/components/reports/IncomeExpenseChart' @@ -82,7 +83,7 @@ export default function ReportsPage() { > {periods.map((p) => ( ))} @@ -146,6 +147,10 @@ export default function ReportsPage() { Lev.reskontra + + + Bankavstämning +
@@ -182,6 +187,9 @@ export default function ReportsPage() { + + + ) : ( diff --git a/app/(dashboard)/settings/page.tsx b/app/(dashboard)/settings/page.tsx index 579379db..0068f4a4 100644 --- a/app/(dashboard)/settings/page.tsx +++ b/app/(dashboard)/settings/page.tsx @@ -6,6 +6,7 @@ import { createClient } from '@/lib/supabase/client' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' import { Label } from '@/components/ui/label' import { Badge } from '@/components/ui/badge' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' @@ -134,6 +135,7 @@ export default function SettingsPage() { next_invoice_number: parseInt(formData.get('next_invoice_number') as string) || 1, invoice_default_days: parseInt(formData.get('invoice_default_days') as string) || 30, accounting_method: formData.get('accounting_method') as string || 'accrual', + invoice_default_notes: (formData.get('invoice_default_notes') as string) || null, } try { @@ -457,37 +459,35 @@ export default function SettingsPage() {
- {settings?.entity_type === 'aktiebolag' ? ( - <> - -
- - - Obligatorisk för aktiebolag - -
- - ) : ( - - )} +

{settings?.entity_type === 'aktiebolag' - ? 'Aktiebolag måste använda faktureringsmetoden enligt BFL.' + ? 'Aktiebolag med omsättning över 3 MSEK måste använda faktureringsmetoden enligt BFL. Mindre aktiebolag kan välja kontantmetoden.' : 'Kontantmetoden är tillgänglig för enskild firma med omsättning under 3 MSEK.'}

+ +
+ +