From d0c0d8a7d2eb103382310e98c65ffc0f79fdcc6e Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Fri, 20 Feb 2026 17:16:22 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20UX=20improvements=20=E2=80=94=20nav,=20?= =?UTF-8?q?reports=20tabs,=20dashboard=20alerts,=20transaction=20hints,=20?= =?UTF-8?q?settings=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);