feat: UX improvements — nav, reports tabs, dashboard alerts, transaction hints, settings layout

- 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 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-02-20 17:16:22 +01:00
parent ba94f60d06
commit d0c0d8a7d2
23 changed files with 2065 additions and 302 deletions
+81 -2
View File
@@ -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<Customer | null>(null)
const [showReview, setShowReview] = useState(false)
const [pendingData, setPendingData] = useState<FormData | null>(null)
const [createdInvoiceId, setCreatedInvoiceId] = useState<string | null>(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 (
<div className="flex items-center justify-center h-64">
@@ -465,6 +507,43 @@ export default function NewInvoicePage() {
/>
</ConfirmationDialog>
)}
{/* Send now prompt dialog */}
<Dialog open={showSendPrompt} onOpenChange={(open) => {
if (!open && createdInvoiceId) {
setShowSendPrompt(false)
router.push(`/invoices/${createdInvoiceId}`)
}
}}>
<DialogContent>
<DialogHeader>
<DialogTitle>Skicka fakturan nu?</DialogTitle>
<DialogDescription>
Fakturan skapades. Vill du skicka den till {selectedCustomer?.email} direkt?
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => {
setShowSendPrompt(false)
if (createdInvoiceId) router.push(`/invoices/${createdInvoiceId}`)
}}
disabled={isSending}
>
Skicka senare
</Button>
<Button onClick={handleSendNow} disabled={isSending}>
{isSending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Send className="mr-2 h-4 w-4" />
)}
{isSending ? 'Skickar...' : 'Skicka nu'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
+51 -19
View File
@@ -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 ? (
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="trial-balance">
<div className="relative">
<TabsList className="w-full justify-start overflow-x-auto flex-nowrap scrollbar-hide">
<TabsTrigger value="trial-balance">
<Scale className="h-4 w-4 mr-1" />
Saldobalans
</TabsTrigger>
@@ -141,7 +146,9 @@ export default function ReportsPage() {
<Building2 className="h-4 w-4 mr-1" />
Lev.reskontra
</TabsTrigger>
</TabsList>
</TabsList>
<div className="absolute right-0 top-0 bottom-0 w-8 bg-gradient-to-l from-background to-transparent pointer-events-none md:hidden" />
</div>
<TabsContent value="trial-balance">
<TrialBalanceView periodId={selectedPeriod} />
@@ -248,22 +255,24 @@ function TrialBalanceView({ periodId }: { periodId: string }) {
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Saldobalans</CardTitle>
{data.isBalanced ? (
<Badge className="bg-green-100 text-green-800">Balanserad</Badge>
) : (
<Badge variant="destructive">Ej balanserad</Badge>
)}
</div>
</CardHeader>
<CardContent>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 w-20">Konto</th>
<div className="space-y-4">
<TrialBalanceChart rows={data.rows} />
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Saldobalans</CardTitle>
{data.isBalanced ? (
<Badge className="bg-green-100 text-green-800">Balanserad</Badge>
) : (
<Badge variant="destructive">Ej balanserad</Badge>
)}
</div>
</CardHeader>
<CardContent>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 w-20">Konto</th>
<th className="py-2">Namn</th>
<th className="py-2 w-28 text-right">Period debet</th>
<th className="py-2 w-28 text-right">Period kredit</th>
@@ -311,17 +320,22 @@ function TrialBalanceView({ periodId }: { periodId: string }) {
</table>
</CardContent>
</Card>
</div>
)
}
function IncomeStatementView({ periodId }: { periodId: string }) {
const [data, setData] = useState<IncomeStatementReport | null>(null)
const [monthlyData, setMonthlyData] = useState<MonthlyDataPoint[]>([])
const [monthlyLoading, setMonthlyLoading] = useState(false)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(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 (
<div className="space-y-4">
{!monthlyLoading && monthlyData.length > 0 && (
<IncomeExpenseChart months={monthlyData} />
)}
{/* Revenue */}
<Card>
<CardHeader>
@@ -741,6 +771,8 @@ function VatDeclarationView() {
{data && (
<>
<VatCompositionChart rutor={data.rutor} />
{/* Summary */}
<Card className="border-2">
<CardHeader>
+198 -173
View File
@@ -317,191 +317,216 @@ export default function SettingsPage() {
{/* Company settings */}
<TabsContent value="company">
<form onSubmit={handleSaveSettings}>
<Card>
<CardHeader>
<CardTitle>Företagsuppgifter</CardTitle>
<CardDescription>
Dessa uppgifter visas dina fakturor
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="company_name">Företagsnamn</Label>
<Input
id="company_name"
name="company_name"
defaultValue={settings?.company_name || ''}
/>
</div>
<div className="space-y-2">
<Label htmlFor="org_number">Organisationsnummer</Label>
<Input
id="org_number"
name="org_number"
defaultValue={settings?.org_number || ''}
/>
</div>
</div>
<form onSubmit={handleSaveSettings} className="space-y-6">
{/* Företagsuppgifter */}
<Card>
<CardHeader>
<CardTitle>Företagsuppgifter</CardTitle>
<CardDescription>
Namn, organisationsnummer och adress
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="address_line1">Adress</Label>
<Label htmlFor="company_name">Företagsnamn</Label>
<Input
id="address_line1"
name="address_line1"
defaultValue={settings?.address_line1 || ''}
id="company_name"
name="company_name"
defaultValue={settings?.company_name || ''}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="postal_code">Postnummer</Label>
<Input
id="postal_code"
name="postal_code"
defaultValue={settings?.postal_code || ''}
/>
</div>
<div className="space-y-2">
<Label htmlFor="city">Ort</Label>
<Input
id="city"
name="city"
defaultValue={settings?.city || ''}
/>
</div>
<div className="space-y-2">
<Label htmlFor="org_number">Organisationsnummer</Label>
<Input
id="org_number"
name="org_number"
defaultValue={settings?.org_number || ''}
/>
</div>
</div>
<div className="pt-4 border-t">
<h3 className="font-medium mb-4">Bankuppgifter för fakturor</h3>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="bank_name">Bank</Label>
<Input
id="bank_name"
name="bank_name"
defaultValue={settings?.bank_name || ''}
/>
</div>
<div className="space-y-2">
<Label htmlFor="clearing_number">Clearing</Label>
<Input
id="clearing_number"
name="clearing_number"
defaultValue={settings?.clearing_number || ''}
/>
</div>
<div className="space-y-2">
<Label htmlFor="account_number">Kontonummer</Label>
<Input
id="account_number"
name="account_number"
defaultValue={settings?.account_number || ''}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="address_line1">Adress</Label>
<Input
id="address_line1"
name="address_line1"
defaultValue={settings?.address_line1 || ''}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="postal_code">Postnummer</Label>
<Input
id="postal_code"
name="postal_code"
defaultValue={settings?.postal_code || ''}
/>
</div>
<div className="pt-4 border-t">
<h3 className="font-medium mb-4">Fakturainställningar</h3>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="invoice_prefix">Fakturaprefix</Label>
<Input
id="invoice_prefix"
name="invoice_prefix"
placeholder="t.ex. F-"
defaultValue={settings?.invoice_prefix || ''}
/>
</div>
<div className="space-y-2">
<Label htmlFor="next_invoice_number">Nästa fakturanummer</Label>
<Input
id="next_invoice_number"
name="next_invoice_number"
type="number"
min="1"
defaultValue={settings?.next_invoice_number || 1}
/>
</div>
<div className="space-y-2">
<Label htmlFor="invoice_default_days">Betalningsvillkor (dagar)</Label>
<Input
id="invoice_default_days"
name="invoice_default_days"
type="number"
min="0"
defaultValue={settings?.invoice_default_days || 30}
/>
</div>
</div>
<div className="mt-4 space-y-2">
<Label htmlFor="accounting_method">Bokföringsmetod</Label>
{settings?.entity_type === 'aktiebolag' ? (
<>
<input type="hidden" name="accounting_method" value="accrual" />
<div className="flex items-center gap-2">
<Input
value="Faktureringsmetoden"
disabled
className="max-w-xs"
/>
<span className="text-sm text-muted-foreground">
Obligatorisk för aktiebolag
</span>
</div>
</>
) : (
<select
id="accounting_method"
name="accounting_method"
defaultValue={settings?.accounting_method || 'accrual'}
className="flex h-10 w-full max-w-xs rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<option value="accrual">Faktureringsmetoden</option>
<option value="cash">Kontantmetoden</option>
</select>
)}
<p className="text-xs text-muted-foreground">
{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.'}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="city">Ort</Label>
<Input
id="city"
name="city"
defaultValue={settings?.city || ''}
/>
</div>
</div>
</CardContent>
</Card>
<div className="pt-4 border-t">
<h3 className="font-medium mb-4">Skatteinställningar</h3>
<div className="space-y-2">
<Label htmlFor="preliminary_tax_monthly">
Månatlig preliminärskatt (F-skatt)
</Label>
<Input
id="preliminary_tax_monthly"
name="preliminary_tax_monthly"
type="number"
defaultValue={settings?.preliminary_tax_monthly || ''}
/>
</div>
{/* Bankuppgifter */}
<Card>
<CardHeader>
<CardTitle>Bankuppgifter</CardTitle>
<CardDescription>
Betalningsuppgifter som visas dina fakturor
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="bank_name">Bank</Label>
<Input
id="bank_name"
name="bank_name"
defaultValue={settings?.bank_name || ''}
/>
</div>
<div className="space-y-2">
<Label htmlFor="clearing_number">Clearing</Label>
<Input
id="clearing_number"
name="clearing_number"
defaultValue={settings?.clearing_number || ''}
/>
</div>
<div className="space-y-2">
<Label htmlFor="account_number">Kontonummer</Label>
<Input
id="account_number"
name="account_number"
defaultValue={settings?.account_number || ''}
/>
</div>
</div>
</CardContent>
</Card>
<div className="flex justify-end">
<Button type="submit" disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar...
</>
) : (
'Spara ändringar'
)}
</Button>
{/* Fakturainställningar */}
<Card>
<CardHeader>
<CardTitle>Fakturainställningar</CardTitle>
<CardDescription>
Numrering, betalningsvillkor och bokföringsmetod
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="invoice_prefix">Fakturaprefix</Label>
<Input
id="invoice_prefix"
name="invoice_prefix"
placeholder="t.ex. F-"
defaultValue={settings?.invoice_prefix || ''}
/>
</div>
</CardContent>
</Card>
</form>
<div className="space-y-2">
<Label htmlFor="next_invoice_number">Nästa fakturanummer</Label>
<Input
id="next_invoice_number"
name="next_invoice_number"
type="number"
min="1"
defaultValue={settings?.next_invoice_number || 1}
/>
</div>
<div className="space-y-2">
<Label htmlFor="invoice_default_days">Betalningsvillkor (dagar)</Label>
<Input
id="invoice_default_days"
name="invoice_default_days"
type="number"
min="0"
defaultValue={settings?.invoice_default_days || 30}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="accounting_method">Bokföringsmetod</Label>
{settings?.entity_type === 'aktiebolag' ? (
<>
<input type="hidden" name="accounting_method" value="accrual" />
<div className="flex items-center gap-2">
<Input
value="Faktureringsmetoden"
disabled
className="max-w-xs"
/>
<span className="text-sm text-muted-foreground">
Obligatorisk för aktiebolag
</span>
</div>
</>
) : (
<select
id="accounting_method"
name="accounting_method"
defaultValue={settings?.accounting_method || 'accrual'}
className="flex h-10 w-full max-w-xs rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<option value="accrual">Faktureringsmetoden</option>
<option value="cash">Kontantmetoden</option>
</select>
)}
<p className="text-xs text-muted-foreground">
{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.'}
</p>
</div>
</CardContent>
</Card>
{/* Skatteinställningar */}
<Card>
<CardHeader>
<CardTitle>Skatteinställningar</CardTitle>
<CardDescription>
Preliminärskatt och F-skatt
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label htmlFor="preliminary_tax_monthly">
Månatlig preliminärskatt (F-skatt)
</Label>
<Input
id="preliminary_tax_monthly"
name="preliminary_tax_monthly"
type="number"
defaultValue={settings?.preliminary_tax_monthly || ''}
/>
</div>
</CardContent>
</Card>
<div className="flex justify-end">
<Button type="submit" disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar...
</>
) : (
'Spara ändringar'
)}
</Button>
</div>
</form>
</TabsContent>
{/* Banking settings (only shown when extension is active or connections exist) */}
+154 -9
View File
@@ -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<Record<string, SuggestedCategory[]>>({})
const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false)
const [isBatchMode, setIsBatchMode] = useState(false)
const [selectedIds, setSelectedIds] = useState<Set<string>>(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() {
</Link>
</Button>
{uncategorizedTransactions.length > 0 && (
<Button variant="outline" onClick={openSwipeView} disabled={isLoadingSuggestions}>
<Sparkles className="mr-2 h-4 w-4" />
{isLoadingSuggestions ? 'Laddar...' : `Kategorisera (${uncategorizedTransactions.length})`}
</Button>
<>
<Button variant="outline" onClick={openSwipeView} disabled={isLoadingSuggestions}>
<Sparkles className="mr-2 h-4 w-4" />
{isLoadingSuggestions ? 'Laddar...' : `Kategorisera (${uncategorizedTransactions.length})`}
</Button>
<Button
variant={isBatchMode ? 'default' : 'outline'}
onClick={() => isBatchMode ? exitBatchMode() : setIsBatchMode(true)}
>
<CheckSquare className="mr-2 h-4 w-4" />
{isBatchMode ? 'Avsluta' : 'Välj flera'}
</Button>
</>
)}
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
@@ -474,6 +546,26 @@ export default function TransactionsPage() {
</Tabs>
</div>
{/* Feature discovery hint */}
{!hideCategorizationHint && uncategorizedTransactions.length > 0 && (
<div className="flex items-start gap-3 px-4 py-3 rounded-lg border border-primary/20 bg-primary/[0.03]">
<Sparkles className="h-4 w-4 text-primary flex-shrink-0 mt-0.5" />
<p className="text-sm text-muted-foreground flex-1">
<span className="font-medium text-foreground">Tips:</span> Klicka &quot;Kategorisera&quot; ovan för att snabbt svepkategorisera transaktioner en i taget. Använd &quot;Välj flera&quot; för att hantera flera samtidigt.
</p>
<button
onClick={() => {
localStorage.setItem('hideCategorizationHint', 'true')
setHideCategorizationHint(true)
}}
className="text-muted-foreground hover:text-foreground transition-colors flex-shrink-0"
aria-label="Stäng tips"
>
<X className="h-4 w-4" />
</button>
</div>
)}
{/* Transaction list */}
{isLoading ? (
<div className="space-y-2">
@@ -519,16 +611,31 @@ export default function TransactionsPage() {
</Card>
) : (
<div className="space-y-2">
{filteredTransactions.map((transaction) => (
{filteredTransactions.map((transaction) => {
const isUncategorized = transaction.is_business === null
const isSelected = selectedIds.has(transaction.id)
const showCheckbox = isBatchMode && isUncategorized
return (
<Card
key={transaction.id}
className={`hover:border-primary/50 transition-colors ${
transaction.is_business === null ? 'border-warning/50' : ''
} ${transaction.potential_invoice && !transaction.invoice_id ? 'border-blue-500/50' : ''}`}
isUncategorized ? 'border-warning/50' : ''
} ${transaction.potential_invoice && !transaction.invoice_id ? 'border-blue-500/50' : ''} ${
isSelected ? 'border-primary bg-primary/[0.02]' : ''
}`}
onClick={showCheckbox ? () => toggleBatchSelect(transaction.id) : undefined}
>
<CardContent className="py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{showCheckbox && (
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleBatchSelect(transaction.id)}
onClick={(e) => e.stopPropagation()}
/>
)}
<div
className={`h-10 w-10 rounded-full flex items-center justify-center ${
transaction.amount > 0
@@ -618,10 +725,48 @@ export default function TransactionsPage() {
</div>
</CardContent>
</Card>
))}
)
})}
</div>
)}
{/* Batch mode floating action bar */}
{isBatchMode && selectedIds.size > 0 && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 flex items-center gap-3 bg-background border rounded-xl shadow-lg px-4 py-3">
<Badge variant="secondary">{selectedIds.size} valda</Badge>
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedIds(new Set())}
>
<X className="mr-1 h-3 w-3" />
Avmarkera
</Button>
<Button
variant="outline"
size="sm"
onClick={handleBatchMarkPrivate}
>
Markera som privat
</Button>
<Button
size="sm"
onClick={() => setShowBatchSelector(true)}
>
Kategorisera {selectedIds.size} st
</Button>
</div>
)}
{/* Batch Category Selector */}
<BatchCategorySelector
open={showBatchSelector}
onOpenChange={setShowBatchSelector}
selectedCount={selectedIds.size}
onSelectCategory={handleBatchCategorize}
progress={batchProgress}
/>
{/* Invoice Match Confirmation Dialog */}
<Dialog open={matchDialogOpen} onOpenChange={setMatchDialogOpen}>
<DialogContent>
+55
View File
@@ -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<string, number> = {}
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 })
}
@@ -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,
},
@@ -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 })
}
}
@@ -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')
+9
View File
@@ -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);
@@ -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<HTMLInputElement>(null)
const uploadFile = useCallback(async (file: UploadedFile): Promise<UploadedFile> => {
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<HTMLInputElement>) => {
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 (
<div className="space-y-2">
{/* Drop zone */}
<div
className={`
relative border-2 border-dashed rounded-lg text-center transition-colors
${compact ? 'p-3' : 'p-5'}
${isDragging ? 'border-primary bg-primary/5' : 'border-muted-foreground/25'}
${disabled ? 'pointer-events-none opacity-50' : 'cursor-pointer hover:border-primary/50'}
`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => inputRef.current?.click()}
>
<input
ref={inputRef}
type="file"
multiple
accept={ACCEPTED_EXTENSIONS}
className="hidden"
onChange={handleInputChange}
disabled={disabled}
/>
<div className={compact ? 'flex items-center justify-center gap-2' : 'space-y-2'}>
<Upload className={compact ? 'h-4 w-4 text-muted-foreground' : 'mx-auto h-8 w-8 text-muted-foreground'} />
<div>
<p className={compact ? 'text-sm text-muted-foreground' : 'text-sm font-medium'}>
{compact ? 'Dra och släpp eller klicka' : 'Dra och släpp filer här'}
</p>
{!compact && (
<p className="text-xs text-muted-foreground">
PDF, bilder (max 10 MB)
</p>
)}
</div>
</div>
</div>
{/* File list */}
{files.length > 0 && (
<div className="space-y-1">
{files.map((file, index) => (
<div
key={`${file.fileName}-${index}`}
className="flex items-center gap-2 text-sm py-1.5 px-2 rounded bg-muted/50"
>
{isImageType(file.file.type) ? (
<ImageIcon className="h-4 w-4 text-muted-foreground shrink-0" />
) : (
<FileText className="h-4 w-4 text-muted-foreground shrink-0" />
)}
<span className="truncate flex-1">{file.fileName}</span>
<span className="text-xs text-muted-foreground shrink-0">
{formatFileSize(file.fileSize)}
</span>
{file.status === 'uploading' && (
<Loader2 className="h-3.5 w-3.5 animate-spin text-primary shrink-0" />
)}
{file.status === 'uploaded' && (
<Badge variant="success" className="text-xs px-1.5 py-0">
Uppladdad
</Badge>
)}
{file.status === 'error' && (
<Badge variant="destructive" className="text-xs px-1.5 py-0" title={file.error}>
Fel
</Badge>
)}
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 shrink-0"
onClick={(e) => {
e.stopPropagation()
removeFile(index)
}}
disabled={file.status === 'uploading'}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
{isUploading && (
<p className="text-xs text-muted-foreground">Laddar upp...</p>
)}
</div>
)
}
@@ -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<DocumentRecord[]>([])
const [loading, setLoading] = useState(true)
const [expandedImage, setExpandedImage] = useState<string | null>(null)
const [showUpload, setShowUpload] = useState(false)
const [uploadFiles, setUploadFiles] = useState<UploadedFile[]>([])
const fetchDocuments = useCallback(async () => {
try {
const res = await fetch(
`/api/documents?journal_entry_id=${journalEntryId}&current_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 (
<div className="py-2 text-sm text-muted-foreground">
Laddar underlag...
</div>
)
}
return (
<div className="border-t pt-3 mt-3">
<div className="flex items-center justify-between mb-2">
<h4 className="text-sm font-medium">
Underlag {documents.length > 0 && `(${documents.length})`}
</h4>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => setShowUpload(!showUpload)}
>
<Plus className="h-3 w-3 mr-1" />
Lägg till underlag
</Button>
</div>
{/* Upload zone */}
{showUpload && (
<div className="mb-3">
<DocumentUploadZone
files={uploadFiles}
onFilesChange={setUploadFiles}
journalEntryId={journalEntryId}
compact
/>
</div>
)}
{/* Document list */}
{documents.length === 0 && !showUpload ? (
<p className="text-sm text-muted-foreground py-1">
Inga underlag bifogade.
</p>
) : (
<div className="space-y-1">
{documents.map((doc) => (
<div key={doc.id}>
<div className="flex items-center gap-2 text-sm py-1.5 px-2 rounded bg-muted/50">
{isImageType(doc.mime_type) ? (
<button
onClick={() => handlePreviewToggle(doc)}
className="shrink-0 hover:text-primary transition-colors"
>
{expandedImage === doc.id ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
) : (
<FileText className="h-4 w-4 text-muted-foreground shrink-0" />
)}
{isImageType(doc.mime_type) && expandedImage !== doc.id && (
<ImageIcon className="h-4 w-4 text-muted-foreground shrink-0" />
)}
<span className="truncate flex-1">{doc.file_name}</span>
<span className="text-xs text-muted-foreground shrink-0">
{formatFileSize(doc.file_size_bytes)}
</span>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 shrink-0"
onClick={() => handleDownload(doc.id)}
title="Ladda ner"
>
<Download className="h-3 w-3" />
</Button>
</div>
{/* Image preview */}
{expandedImage === doc.id && doc.download_url && (
<div className="px-2 py-2">
<img
src={doc.download_url}
alt={doc.file_name}
className="max-h-48 rounded-lg object-contain"
/>
</div>
)}
</div>
))}
</div>
)}
</div>
)
}
+34 -1
View File
@@ -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<UploadedFile[]>([])
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) {
</Button>
</div>
{/* Document attachments */}
<div>
<Label className="mb-2 block">Underlag</Label>
<DocumentUploadZone
files={uploadedFiles}
onFilesChange={setUploadedFiles}
/>
</div>
{!isBalanced && totalDebit > 0 && (
<p className="text-sm text-red-600">
Differens: {Math.abs(totalDebit - totalCredit).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} kr
@@ -284,7 +316,7 @@ export default function JournalEntryForm({ onCreated }: Props) {
<div className="flex justify-end">
<Button
onClick={handleReview}
disabled={!isBalanced || !description || !selectedPeriod || isSubmitting}
disabled={!isBalanced || !description || !selectedPeriod || isSubmitting || isUploading}
>
Granska & skapa
</Button>
@@ -305,6 +337,7 @@ export default function JournalEntryForm({ onCreated }: Props) {
lines={lines}
totalDebit={totalDebit}
totalCredit={totalCredit}
attachmentCount={uploadedFiles.filter((f) => f.status === 'uploaded').length}
/>
</ConfirmationDialog>
</CardContent>
+90 -4
View File
@@ -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<string | null>(null)
const [count, setCount] = useState(0)
const [page, setPage] = useState(0)
const [attachmentCounts, setAttachmentCounts] = useState<Record<string, number>>({})
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 (
<div className="space-y-4">
{/* Missing attachment filter */}
<div className="flex items-center gap-2">
<Switch
id="missing-attachments"
checked={showMissingOnly}
onCheckedChange={setShowMissingOnly}
/>
<Label htmlFor="missing-attachments" className="text-sm cursor-pointer">
Visa saknade underlag
</Label>
{showMissingOnly && (
<Badge variant="secondary" className="text-xs">
{filteredEntries.length}
</Badge>
)}
</div>
<div className="space-y-2">
{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}
</span>
<span className="flex-1 truncate">{entry.description}</span>
{/* Attachment indicator */}
{attachmentCounts[entry.id] ? (
<span className="flex items-center gap-0.5 text-muted-foreground mr-1" title={`${attachmentCounts[entry.id]} underlag`}>
<Paperclip className="h-3.5 w-3.5" />
<span className="text-xs">{attachmentCounts[entry.id]}</span>
</span>
) : (
NEEDS_ATTACHMENT.has(entry.source_type) && entry.status === 'posted' && (
<span className="mr-1" title="Underlag saknas">
<AlertTriangle className="h-3.5 w-3.5 text-amber-500" />
</span>
)
)}
<Badge variant="outline" className="text-xs mr-2">
{sourceLabel(entry.source_type)}
</Badge>
@@ -178,6 +259,11 @@ export default function JournalEntryList({ periodId }: Props) {
</tr>
</tfoot>
</table>
<JournalEntryAttachments
journalEntryId={entry.id}
onCountChange={(c) => handleAttachmentCountChange(entry.id, c)}
/>
</CardContent>
)}
</Card>
@@ -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({
<CheckCircle2 className="h-3 w-3 mr-1" />
Debet = Kredit
</Badge>
{attachmentCount != null && attachmentCount > 0 && (
<Badge variant="outline">
<Paperclip className="h-3 w-3 mr-1" />
{attachmentCount} {attachmentCount === 1 ? 'underlag' : 'underlag'}
</Badge>
)}
</div>
{/* Debit/Credit table */}
+226 -88
View File
@@ -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
</section>
)}
{/* Upcoming deadlines */}
{summary.deadlines && summary.deadlines.length > 0 && (
<section className="mb-10">
<UpcomingDeadlinesWidget deadlines={summary.deadlines} maxItems={8} />
</section>
)}
{/* 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 (
<section className="mb-10">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{/* Card 1: Resultat */}
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-2">
<TrendingUp className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground">Resultat</span>
</div>
<p className={cn(
'font-display text-xl font-medium tabular-nums leading-tight',
summary.mtd.net >= 0 ? 'text-success' : 'text-destructive'
)}>
{formatLargeNumber(summary.mtd.net)}
<span className="text-sm ml-0.5 text-muted-foreground font-normal">kr</span>
</p>
<p className="text-[11px] text-muted-foreground mt-1">
{formatCurrency(summary.ytd.net)} i år
</p>
</CardContent>
</Card>
{/* Card 2: Att få betalt */}
<Link href="/invoices?status=unpaid">
<Card className="h-full hover:border-primary/50 transition-colors">
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-2">
<Receipt className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground">Att betalt</span>
</div>
<p className="font-display text-xl font-medium tabular-nums leading-tight">
{summary.unpaidInvoicesCount}
<span className="text-sm ml-0.5 text-muted-foreground font-normal">st</span>
</p>
<p className="text-[11px] text-muted-foreground mt-1">
{formatCurrency(summary.unpaidInvoicesTotal)}
</p>
</CardContent>
</Card>
</Link>
{/* Card 3: Banksaldo */}
{summary.bankBalance !== null ? (
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-2">
<Landmark className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground">Banksaldo</span>
</div>
<p className="font-display text-xl font-medium tabular-nums leading-tight">
{formatLargeNumber(summary.bankBalance)}
<span className="text-sm ml-0.5 text-muted-foreground font-normal">kr</span>
</p>
</CardContent>
</Card>
) : (
<Link href="/import">
<Card className="h-full hover:border-primary/50 transition-colors">
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-2">
<Landmark className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground">Banksaldo</span>
</div>
<p className="text-sm font-medium text-primary">Koppla bank</p>
<p className="text-[11px] text-muted-foreground mt-1">Importera transaktioner</p>
</CardContent>
</Card>
</Link>
)}
{/* Card 4: Att göra */}
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-2">
<ClipboardList className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground">Att göra</span>
</div>
{todoCount > 0 ? (
<>
<p className="font-display text-xl font-medium tabular-nums leading-tight text-warning-foreground">
{todoCount}
<span className="text-sm ml-0.5 text-muted-foreground font-normal">st</span>
</p>
<p className="text-[11px] text-muted-foreground mt-1">
Åtgärder att hantera
</p>
</>
) : (
<>
<div className="flex items-center gap-1.5">
<CheckCircle2 className="h-4 w-4 text-success" />
<p className="text-sm font-medium text-success">Allt klart!</p>
</div>
</>
)}
</CardContent>
</Card>
</div>
</section>
)
})()}
{/* Quick actions */}
<section id="quick-actions" className="mb-10">
@@ -294,15 +403,14 @@ export default function DashboardContent({ firstName, settings, summary, onboard
</div>
</section>
{/* F-skatt warning */}
<section id="fskatt-section" className="mb-10">
<FSkattWarningCard
warningStatus={taxWarning}
onAdjustClick={() => { window.location.href = '/settings' }}
/>
</section>
{/* Upcoming deadlines — always visible */}
{summary.deadlines && summary.deadlines.length > 0 && (
<section className="mb-10">
<UpcomingDeadlinesWidget deadlines={summary.deadlines} maxItems={8} />
</section>
)}
{/* Alerts section */}
{/* Alerts section — always visible */}
{alertItems.length > 0 && (
<section id="alerts-section" className="mb-10">
<h2 className="font-display text-lg font-medium mb-4">Att hantera</h2>
@@ -321,82 +429,112 @@ export default function DashboardContent({ firstName, settings, summary, onboard
</section>
)}
{/* Uncategorized transactions warning */}
{summary.uncategorizedCount > 0 && (summary.uncategorizedIncome > 0 || summary.uncategorizedExpenses > 0) && (
<section className="mb-10">
<Link href="/transactions?tab=uncategorized" className="group">
<div className="flex items-start gap-3 px-4 py-3.5 rounded-xl border border-warning/30 bg-warning/[0.03] hover:bg-warning/[0.06] transition-colors">
<ArrowLeftRight className="h-4 w-4 text-warning-foreground flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="font-medium text-sm">
{summary.uncategorizedCount} okategoriserade transaktioner
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{summary.uncategorizedIncome > 0 && (
<span>{formatCurrency(summary.uncategorizedIncome)} intäkter</span>
)}
{summary.uncategorizedIncome > 0 && summary.uncategorizedExpenses > 0 && ', '}
{summary.uncategorizedExpenses > 0 && (
<span>{formatCurrency(summary.uncategorizedExpenses)} kostnader</span>
)}
{' '}saknas i resultatet
</p>
</div>
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground/50 group-hover:text-muted-foreground flex-shrink-0 mt-0.5 transition-colors" />
{/* Collapsible details section */}
<button
onClick={() => setShowMore(!showMore)}
className="mb-6 text-sm text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1.5"
>
{showMore ? (
<>
Dölj detaljer
<ChevronUp className="h-3.5 w-3.5" />
</>
) : (
<>
Visa detaljer
<ChevronDown className="h-3.5 w-3.5" />
</>
)}
</button>
{showMore && (
<div>
{/* F-skatt warning */}
<section id="fskatt-section" className="mb-10">
<FSkattWarningCard
warningStatus={taxWarning}
onAdjustClick={() => { window.location.href = '/settings' }}
/>
</section>
{/* Uncategorized transactions warning */}
{summary.uncategorizedCount > 0 && (summary.uncategorizedIncome > 0 || summary.uncategorizedExpenses > 0) && (
<section className="mb-10">
<Link href="/transactions?tab=uncategorized" className="group">
<div className="flex items-start gap-3 px-4 py-3.5 rounded-xl border border-warning/30 bg-warning/[0.03] hover:bg-warning/[0.06] transition-colors">
<ArrowLeftRight className="h-4 w-4 text-warning-foreground flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="font-medium text-sm">
{summary.uncategorizedCount} okategoriserade transaktioner
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{summary.uncategorizedIncome > 0 && (
<span>{formatCurrency(summary.uncategorizedIncome)} intäkter</span>
)}
{summary.uncategorizedIncome > 0 && summary.uncategorizedExpenses > 0 && ', '}
{summary.uncategorizedExpenses > 0 && (
<span>{formatCurrency(summary.uncategorizedExpenses)} kostnader</span>
)}
{' '}saknas i resultatet
</p>
</div>
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground/50 group-hover:text-muted-foreground flex-shrink-0 mt-0.5 transition-colors" />
</div>
</Link>
</section>
)}
{/* Income/Expenses */}
<section className="mb-10">
<h2 className="font-display text-lg font-medium mb-4">Resultat</h2>
<div className="grid md:grid-cols-2 gap-3">
<Card>
<CardContent className="p-5">
<div className="flex items-center gap-2 mb-3">
<TrendingUp className="h-3.5 w-3.5 text-success" />
<span className="text-sm text-muted-foreground">Intäkter</span>
</div>
<div>
<p className="font-display text-2xl font-medium tabular-nums leading-tight">
{formatLargeNumber(summary.mtd.income)}
<span className="text-base ml-1 text-muted-foreground font-normal">kr</span>
</p>
<p className="text-xs text-muted-foreground mt-0.5">denna månad</p>
</div>
<div className="mt-4 pt-3 border-t border-border/30">
<div className="flex items-baseline justify-between">
<p className="text-xs text-muted-foreground">I år</p>
<p className="text-sm font-medium tabular-nums">{formatCurrency(summary.ytd.income)}</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-5">
<div className="flex items-center gap-2 mb-3">
<TrendingDown className="h-3.5 w-3.5 text-destructive" />
<span className="text-sm text-muted-foreground">Kostnader</span>
</div>
<div>
<p className="font-display text-2xl font-medium tabular-nums leading-tight">
{formatLargeNumber(summary.mtd.expenses)}
<span className="text-base ml-1 text-muted-foreground font-normal">kr</span>
</p>
<p className="text-xs text-muted-foreground mt-0.5">denna månad</p>
</div>
<div className="mt-4 pt-3 border-t border-border/30">
<div className="flex items-baseline justify-between">
<p className="text-xs text-muted-foreground">I år</p>
<p className="text-sm font-medium tabular-nums">{formatCurrency(summary.ytd.expenses)}</p>
</div>
</div>
</CardContent>
</Card>
</div>
</Link>
</section>
)}
{/* Income/Expenses */}
<section className="mb-10">
<h2 className="font-display text-lg font-medium mb-4">Resultat</h2>
<div className="grid md:grid-cols-2 gap-3">
<Card>
<CardContent className="p-5">
<div className="flex items-center gap-2 mb-3">
<TrendingUp className="h-3.5 w-3.5 text-success" />
<span className="text-sm text-muted-foreground">Intäkter</span>
</div>
<div>
<p className="font-display text-2xl font-medium tabular-nums leading-tight">
{formatLargeNumber(summary.mtd.income)}
<span className="text-base ml-1 text-muted-foreground font-normal">kr</span>
</p>
<p className="text-xs text-muted-foreground mt-0.5">denna månad</p>
</div>
<div className="mt-4 pt-3 border-t border-border/30">
<div className="flex items-baseline justify-between">
<p className="text-xs text-muted-foreground">I år</p>
<p className="text-sm font-medium tabular-nums">{formatCurrency(summary.ytd.income)}</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-5">
<div className="flex items-center gap-2 mb-3">
<TrendingDown className="h-3.5 w-3.5 text-destructive" />
<span className="text-sm text-muted-foreground">Kostnader</span>
</div>
<div>
<p className="font-display text-2xl font-medium tabular-nums leading-tight">
{formatLargeNumber(summary.mtd.expenses)}
<span className="text-base ml-1 text-muted-foreground font-normal">kr</span>
</p>
<p className="text-xs text-muted-foreground mt-0.5">denna månad</p>
</div>
<div className="mt-4 pt-3 border-t border-border/30">
<div className="flex items-baseline justify-between">
<p className="text-xs text-muted-foreground">I år</p>
<p className="text-sm font-medium tabular-nums">{formatCurrency(summary.ytd.expenses)}</p>
</div>
</div>
</CardContent>
</Card>
</section>
</div>
</section>
)}
</div>
)
}
+6 -3
View File
@@ -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 */}
<div className="mb-4">
<button
onClick={() => setIsOvrigtExpanded(!isOvrigtExpanded)}
onClick={() => setManualOvrigtExpanded(!isOvrigtExpanded)}
className="w-full flex items-center justify-between px-3 mb-1.5 text-[10px] font-semibold text-muted-foreground/70 uppercase tracking-[0.08em] hover:text-muted-foreground transition-colors"
>
<span>{groupLabels.övrigt}</span>
+51
View File
@@ -0,0 +1,51 @@
'use client'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
export interface MonthlyDataPoint {
label: string
income: number
expenses: number
}
interface IncomeExpenseChartProps {
months: MonthlyDataPoint[]
}
export function IncomeExpenseChart({ months }: IncomeExpenseChartProps) {
if (months.length === 0) return null
return (
<Card className="mb-4">
<CardHeader className="pb-2">
<CardTitle className="text-base">Intäkter vs Kostnader per månad</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart
data={months}
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="label" tick={{ fontSize: 12 }} />
<YAxis
tickFormatter={(v) => new Intl.NumberFormat('sv-SE', { notation: 'compact' }).format(v)}
/>
<Tooltip
formatter={(value, name) => [
new Intl.NumberFormat('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(Number(value)) + ' kr',
name === 'income' ? 'Intäkter' : 'Kostnader',
]}
/>
<Legend
formatter={(value) => (value === 'income' ? 'Intäkter' : 'Kostnader')}
/>
<Bar dataKey="income" fill="hsl(var(--chart-1))" radius={[4, 4, 0, 0]} />
<Bar dataKey="expenses" fill="hsl(var(--chart-2))" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
)
}
+69
View File
@@ -0,0 +1,69 @@
'use client'
import { useMemo } from 'react'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import type { TrialBalanceRow } from '@/types'
interface TrialBalanceChartProps {
rows: TrialBalanceRow[]
}
export function TrialBalanceChart({ rows }: TrialBalanceChartProps) {
const chartData = useMemo(() => {
return rows
.map((row) => ({
name: `${row.account_number} ${row.account_name}`,
account: row.account_number,
net: Math.round((row.closing_debit - row.closing_credit) * 100) / 100,
}))
.sort((a, b) => Math.abs(b.net) - Math.abs(a.net))
.slice(0, 10)
}, [rows])
if (chartData.length === 0) return null
return (
<Card className="mb-4">
<CardHeader className="pb-2">
<CardTitle className="text-base">Topp 10 konton (nettosaldo)</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart
data={chartData}
layout="vertical"
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" horizontal={false} />
<XAxis
type="number"
tickFormatter={(v) => new Intl.NumberFormat('sv-SE', { notation: 'compact' }).format(v)}
/>
<YAxis
type="category"
dataKey="account"
width={50}
tick={{ fontSize: 12 }}
/>
<Tooltip
formatter={(value) => [
new Intl.NumberFormat('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(Number(value)) + ' kr',
'Netto',
]}
labelFormatter={(label) => chartData.find((d) => d.account === String(label))?.name || String(label)}
/>
<Bar dataKey="net" radius={[0, 4, 4, 0]}>
{chartData.map((entry) => (
<Cell
key={entry.account}
fill={entry.net >= 0 ? 'hsl(var(--chart-1))' : 'hsl(var(--chart-2))'}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
)
}
@@ -0,0 +1,64 @@
'use client'
import { useMemo } from 'react'
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer, Legend } from 'recharts'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import type { VatDeclarationRutor } from '@/types'
interface VatCompositionChartProps {
rutor: VatDeclarationRutor
}
const COLORS = [
'hsl(var(--chart-1))',
'hsl(var(--chart-2))',
'hsl(var(--chart-3))',
'hsl(var(--chart-4))',
]
export function VatCompositionChart({ rutor }: VatCompositionChartProps) {
const chartData = useMemo(() => {
const segments = [
{ name: 'Utgående 25%', value: rutor.ruta05 },
{ name: 'Utgående 12%', value: rutor.ruta06 },
{ name: 'Utgående 6%', value: rutor.ruta07 },
{ name: 'Ingående moms', value: rutor.ruta48 },
]
return segments.filter((s) => s.value > 0)
}, [rutor])
if (chartData.length === 0) return null
return (
<Card className="mb-4">
<CardHeader className="pb-2">
<CardTitle className="text-base">Momsfördelning</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={250}>
<PieChart>
<Pie
data={chartData}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={90}
paddingAngle={2}
dataKey="value"
>
{chartData.map((_, index) => (
<Cell key={index} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip
formatter={(value) => [
new Intl.NumberFormat('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(Number(value)) + ' kr',
]}
/>
<Legend />
</PieChart>
</ResponsiveContainer>
</CardContent>
</Card>
)
}
@@ -0,0 +1,108 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { Progress } from '@/components/ui/progress'
import type { TransactionCategory } from '@/types'
const expenseCategories: { value: TransactionCategory; label: string }[] = [
{ value: 'expense_equipment', label: 'Utrustning' },
{ value: 'expense_software', label: 'Programvara' },
{ value: 'expense_travel', label: 'Resor' },
{ value: 'expense_office', label: 'Kontor' },
{ value: 'expense_marketing', label: 'Marknadsföring' },
{ value: 'expense_professional_services', label: 'Konsulter' },
{ value: 'expense_education', label: 'Utbildning' },
{ value: 'expense_bank_fees', label: 'Bankavgift' },
{ value: 'expense_card_fees', label: 'Kortavgift' },
{ value: 'expense_currency_exchange', label: 'Valutaväxling' },
{ value: 'expense_other', label: 'Övrigt' },
]
const incomeCategories: { value: TransactionCategory; label: string }[] = [
{ value: 'income_services', label: 'Tjänster' },
{ value: 'income_products', label: 'Produkter' },
{ value: 'income_other', label: 'Övrigt' },
]
interface BatchCategorySelectorProps {
open: boolean
onOpenChange: (open: boolean) => void
selectedCount: number
onSelectCategory: (category: TransactionCategory) => void
progress: { done: number; total: number } | null
}
export default function BatchCategorySelector({
open,
onOpenChange,
selectedCount,
onSelectCategory,
progress,
}: BatchCategorySelectorProps) {
const isProcessing = progress !== null
return (
<Dialog open={open} onOpenChange={isProcessing ? undefined : onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{isProcessing
? `Kategoriserar ${progress.done}/${progress.total}...`
: `Kategorisera ${selectedCount} transaktioner`}
</DialogTitle>
<DialogDescription>
{isProcessing
? 'Vänta medan transaktionerna kategoriseras'
: 'Välj en kategori som ska tillämpas på alla valda transaktioner'}
</DialogDescription>
</DialogHeader>
{isProcessing ? (
<div className="py-4">
<Progress value={(progress.done / progress.total) * 100} />
<p className="text-sm text-muted-foreground mt-2 text-center">
{progress.done} av {progress.total} klara
</p>
</div>
) : (
<div className="space-y-4 py-2">
<div>
<h4 className="text-sm font-medium text-muted-foreground mb-2">Kostnader</h4>
<div className="grid grid-cols-2 gap-1.5">
{expenseCategories.map((cat) => (
<Button
key={cat.value}
variant="outline"
size="sm"
className="justify-start text-xs"
onClick={() => onSelectCategory(cat.value)}
>
{cat.label}
</Button>
))}
</div>
</div>
<div>
<h4 className="text-sm font-medium text-muted-foreground mb-2">Intäkter</h4>
<div className="grid grid-cols-2 gap-1.5">
{incomeCategories.map((cat) => (
<Button
key={cat.value}
variant="outline"
size="sm"
className="justify-start text-xs"
onClick={() => onSelectCategory(cat.value)}
>
{cat.label}
</Button>
))}
</div>
</div>
</div>
)}
</DialogContent>
</Dialog>
)
}
@@ -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)
})
})
+124
View File
@@ -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<MonthlyBreakdown> {
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<number, { income: number; expenses: number }>()
// 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 }
}
@@ -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);