diff --git a/app/(dashboard)/deadlines/page.tsx b/app/(dashboard)/deadlines/page.tsx
new file mode 100644
index 00000000..1be6fde3
--- /dev/null
+++ b/app/(dashboard)/deadlines/page.tsx
@@ -0,0 +1,241 @@
+'use client'
+
+import { useState, useEffect, useCallback } from 'react'
+import Link from 'next/link'
+import { createClient } from '@/lib/supabase/client'
+import { useToast } from '@/components/ui/use-toast'
+import { DeadlineList } from '@/components/deadlines/DeadlineList'
+import { Card, CardContent } from '@/components/ui/card'
+import { Badge } from '@/components/ui/badge'
+import { AlertTriangle, ArrowRight } from 'lucide-react'
+import type { Deadline } from '@/types'
+
+export default function DeadlinesPage() {
+ const [deadlines, setDeadlines] = useState([])
+ const [customers, setCustomers] = useState<{ id: string; name: string }[]>([])
+ const [overdueInvoices, setOverdueInvoices] = useState<{ count: number; total: number }>({ count: 0, total: 0 })
+ const [isLoading, setIsLoading] = useState(true)
+ const { toast } = useToast()
+ const supabase = createClient()
+
+ const fetchData = useCallback(async () => {
+ setIsLoading(true)
+
+ try {
+ // Fetch deadlines with customer names
+ const { data: deadlinesData, error: deadlinesError } = await supabase
+ .from('deadlines')
+ .select('*, customer:customers(name)')
+ .order('due_date', { ascending: true })
+
+ if (deadlinesError) throw deadlinesError
+
+ // Fetch customers for the form
+ const { data: customersData, error: customersError } = await supabase
+ .from('customers')
+ .select('id, name')
+ .order('name', { ascending: true })
+
+ if (customersError) throw customersError
+
+ // Fetch overdue invoices summary
+ const today = new Date().toISOString().split('T')[0]
+ const { data: overdueData, error: overdueError } = await supabase
+ .from('invoices')
+ .select('total_sek, total')
+ .in('status', ['sent', 'unpaid'])
+ .lt('due_date', today)
+
+ if (overdueError) throw overdueError
+
+ const overdueCount = overdueData?.length || 0
+ const overdueTotal = (overdueData || []).reduce(
+ (sum, inv) => sum + (inv.total_sek || inv.total || 0),
+ 0
+ )
+
+ setDeadlines(deadlinesData || [])
+ setCustomers(customersData || [])
+ setOverdueInvoices({ count: overdueCount, total: overdueTotal })
+ } catch {
+ toast({
+ title: 'Fel',
+ description: 'Kunde inte hamta data',
+ variant: 'destructive',
+ })
+ } finally {
+ setIsLoading(false)
+ }
+ }, [supabase, toast])
+
+ useEffect(() => {
+ fetchData()
+ }, [fetchData])
+
+ const handleDeadlineCreate = async (
+ data: Omit
+ ) => {
+ try {
+ const response = await fetch('/api/deadlines', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(data),
+ })
+
+ if (!response.ok) {
+ const result = await response.json()
+ throw new Error(result.error || 'Failed to create deadline')
+ }
+
+ toast({
+ title: 'Deadline skapad',
+ description: 'Din deadline har sparats',
+ })
+
+ fetchData()
+ } catch (error) {
+ toast({
+ title: 'Fel',
+ description: error instanceof Error ? error.message : 'Kunde inte skapa deadline',
+ variant: 'destructive',
+ })
+ throw error
+ }
+ }
+
+ const handleDeadlineToggle = async (deadline: Deadline) => {
+ try {
+ const response = await fetch(`/api/deadlines/${deadline.id}/complete`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ is_completed: !deadline.is_completed }),
+ })
+
+ if (!response.ok) {
+ const result = await response.json()
+ throw new Error(result.error || 'Failed to toggle deadline')
+ }
+
+ toast({
+ title: deadline.is_completed ? 'Markerad som ej klar' : 'Markerad som klar',
+ })
+
+ fetchData()
+ } catch (error) {
+ toast({
+ title: 'Fel',
+ description: error instanceof Error ? error.message : 'Kunde inte uppdatera deadline',
+ variant: 'destructive',
+ })
+ }
+ }
+
+ const handleDeadlineEdit = async (deadline: Deadline) => {
+ try {
+ const response = await fetch(`/api/deadlines/${deadline.id}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(deadline),
+ })
+
+ if (!response.ok) {
+ const result = await response.json()
+ throw new Error(result.error || 'Failed to edit deadline')
+ }
+
+ toast({
+ title: 'Deadline uppdaterad',
+ description: 'Dina andringar har sparats',
+ })
+
+ fetchData()
+ } catch (error) {
+ toast({
+ title: 'Fel',
+ description: error instanceof Error ? error.message : 'Kunde inte uppdatera deadline',
+ variant: 'destructive',
+ })
+ }
+ }
+
+ const handleDeadlineDelete = async (deadline: Deadline) => {
+ try {
+ const response = await fetch(`/api/deadlines/${deadline.id}`, {
+ method: 'DELETE',
+ })
+
+ if (!response.ok) {
+ const result = await response.json()
+ throw new Error(result.error || 'Failed to delete deadline')
+ }
+
+ toast({
+ title: 'Deadline borttagen',
+ })
+
+ fetchData()
+ } catch (error) {
+ toast({
+ title: 'Fel',
+ description: error instanceof Error ? error.message : 'Kunde inte ta bort deadline',
+ variant: 'destructive',
+ })
+ }
+ }
+
+ if (isLoading) {
+ return (
+
+ )
+ }
+
+ return (
+
+
+
Deadlines
+
+
+ {overdueInvoices.count > 0 && (
+
+
+
+
+
+
+
+
Forfallna fakturor
+
+ {overdueInvoices.count} st totalt{' '}
+ {overdueInvoices.total.toLocaleString('sv-SE')} kr
+
+
+
+
+
{overdueInvoices.count}
+
+
+
+
+
+
+ )}
+
+
+
+ )
+}
diff --git a/app/(dashboard)/page.tsx b/app/(dashboard)/page.tsx
index 2c8c6102..52f46137 100644
--- a/app/(dashboard)/page.tsx
+++ b/app/(dashboard)/page.tsx
@@ -252,6 +252,13 @@ export default async function DashboardPage() {
streak_count: streakCount,
}
+ // Fetch enabled extension toggles
+ const { data: enabledToggles } = await supabase
+ .from('extension_toggles')
+ .select('sector_slug, extension_slug')
+ .eq('user_id', user.id)
+ .eq('enabled', true)
+
return (
)
}
diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx
index 5bba42f0..f41ff124 100644
--- a/app/(dashboard)/transactions/page.tsx
+++ b/app/(dashboard)/transactions/page.tsx
@@ -19,11 +19,12 @@ import InboxZeroState from '@/components/transactions/InboxZeroState'
import InvoiceMatchDialog from '@/components/transactions/InvoiceMatchDialog'
import TransactionBookingDialog from '@/components/transactions/TransactionBookingDialog'
import QuickReviewDialog from '@/components/transactions/QuickReviewDialog'
+import DescribeTransactionDialog from '@/components/transactions/DescribeTransactionDialog'
import { EXPENSE_CATEGORIES, INCOME_CATEGORIES } from '@/components/transactions/transaction-types'
import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from '@/lib/bookkeeping/category-mapping'
import type { TransactionWithInvoice, ViewMode, CategorizeHandler } from '@/components/transactions/transaction-types'
import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, VatTreatment } from '@/types'
-import type { SuggestedCategory } from '@/lib/transactions/category-suggestions'
+import type { SuggestedCategory, SuggestedTemplate } from '@/lib/transactions/category-suggestions'
export default function TransactionsPage() {
const [transactions, setTransactions] = useState([])
@@ -33,6 +34,7 @@ export default function TransactionsPage() {
const [isCreating, setIsCreating] = useState(false)
const [showSwipeView, setShowSwipeView] = useState(false)
const [categorySuggestions, setCategorySuggestions] = useState>({})
+ const [templateSuggestions, setTemplateSuggestions] = useState>({})
const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false)
const [processingId, setProcessingId] = useState(null)
@@ -57,6 +59,10 @@ export default function TransactionsPage() {
const [quickReviewCategory, setQuickReviewCategory] = useState(null)
const [quickReviewLabel, setQuickReviewLabel] = useState('')
+ // Describe dialog
+ const [describeDialogOpen, setDescribeDialogOpen] = useState(false)
+ const [describeDialogTransaction, setDescribeDialogTransaction] = useState(null)
+
// Entity type for tooltip context
const [entityType, setEntityType] = useState('enskild_firma')
@@ -130,6 +136,9 @@ export default function TransactionsPage() {
if (data.suggestions) {
setCategorySuggestions(data.suggestions)
}
+ if (data.template_suggestions) {
+ setTemplateSuggestions(data.template_suggestions)
+ }
} catch {
// Non-critical
}
@@ -409,13 +418,29 @@ export default function TransactionsPage() {
async function handleBatchCategorize(category: TransactionCategory, vatTreatment?: VatTreatment) {
const ids = Array.from(selectedIds)
setBatchProgress({ done: 0, total: ids.length })
+ let successes = 0
+ const failures: string[] = []
for (let i = 0; i < ids.length; i++) {
- await handleCategorize(ids[i], true, category, vatTreatment)
+ const result = await handleCategorize(ids[i], true, category, vatTreatment)
+ if (result) {
+ successes++
+ } else {
+ const tx = transactions.find((t) => t.id === ids[i])
+ failures.push(tx?.description || ids[i])
+ }
setBatchProgress({ done: i + 1, total: ids.length })
}
setBatchProgress(null)
setShowBatchSelector(false)
- toast({ title: 'Klart', description: `${ids.length} transaktioner bokförda` })
+ if (failures.length === 0) {
+ toast({ title: 'Klart', description: `${successes} transaktioner bokförda` })
+ } else {
+ toast({
+ title: 'Delvis klart',
+ description: `${successes} lyckades, ${failures.length} misslyckades: ${failures.slice(0, 3).join(', ')}${failures.length > 3 ? '...' : ''}`,
+ variant: 'destructive',
+ })
+ }
exitBatchMode()
}
@@ -468,12 +493,40 @@ export default function TransactionsPage() {
return journalEntryId
}
+ function openDescribeDialog(transaction: TransactionWithInvoice) {
+ setDescribeDialogTransaction(transaction)
+ setDescribeDialogOpen(true)
+ }
+
+ function handleDescribeCategorized(transactionId: string, journalEntryId: string | null) {
+ setExitingIds((prev) => new Set(prev).add(transactionId))
+ setTimeout(() => {
+ setTransactions((prev) =>
+ prev.map((t) =>
+ t.id === transactionId
+ ? { ...t, is_business: true, journal_entry_id: journalEntryId }
+ : t
+ )
+ )
+ setExitingIds((prev) => {
+ const next = new Set(prev)
+ next.delete(transactionId)
+ return next
+ })
+ }, 350)
+ }
+
+ function handleBatchApplied() {
+ fetchTransactions()
+ }
+
// Swipe view
if (showSwipeView && uncategorizedTransactions.length > 0) {
return (
setShowSwipeView(false)}
@@ -527,6 +580,7 @@ export default function TransactionsPage() {
key={transaction.id}
transaction={transaction}
suggestions={categorySuggestions[transaction.id]}
+ templateSuggestions={templateSuggestions[transaction.id]}
processingId={processingId}
isBatchMode={isBatchMode}
isSelected={selectedIds.has(transaction.id)}
@@ -535,6 +589,7 @@ export default function TransactionsPage() {
onMarkPrivate={handleMarkPrivate}
onOpenMatchDialog={openMatchDialog}
onOpenCategoryDialog={openCategoryDialog}
+ onOpenDescribe={openDescribeDialog}
onOpenQuickReview={handleOpenQuickReview}
onToggleSelect={toggleBatchSelect}
/>
@@ -603,6 +658,14 @@ export default function TransactionsPage() {
onConfirm={handleQuickReviewConfirm}
/>
+
+
)}
-
+
diff --git a/components/calendar/UpcomingDeadlinesWidget.tsx b/components/deadlines/UpcomingDeadlinesWidget.tsx
similarity index 98%
rename from components/calendar/UpcomingDeadlinesWidget.tsx
rename to components/deadlines/UpcomingDeadlinesWidget.tsx
index 2f347de2..5cd60601 100644
--- a/components/calendar/UpcomingDeadlinesWidget.tsx
+++ b/components/deadlines/UpcomingDeadlinesWidget.tsx
@@ -201,9 +201,9 @@ export function UpcomingDeadlinesWidget({ deadlines, maxItems = 5, onStatusChang
)
})}
-
+
diff --git a/components/deadlines/index.ts b/components/deadlines/index.ts
new file mode 100644
index 00000000..3118eff9
--- /dev/null
+++ b/components/deadlines/index.ts
@@ -0,0 +1,6 @@
+export { DeadlineCard } from './DeadlineCard'
+export { DeadlineFilters } from './DeadlineFilters'
+export { DeadlineForm } from './DeadlineForm'
+export { DeadlineList } from './DeadlineList'
+export { UpcomingDeadlinesWidget } from './UpcomingDeadlinesWidget'
+export { TaxTodoWidget } from './TaxTodoWidget'
diff --git a/app/(dashboard)/calendar/page.tsx b/components/extensions/general/CalendarWorkspace.tsx
similarity index 74%
rename from app/(dashboard)/calendar/page.tsx
rename to components/extensions/general/CalendarWorkspace.tsx
index 8759de79..2dc563ba 100644
--- a/app/(dashboard)/calendar/page.tsx
+++ b/components/extensions/general/CalendarWorkspace.tsx
@@ -3,10 +3,11 @@
import { useState, useEffect, useCallback } from 'react'
import { createClient } from '@/lib/supabase/client'
import { useToast } from '@/components/ui/use-toast'
-import { PaymentCalendar } from '@/components/calendar/PaymentCalendar'
+import { PaymentCalendar } from '@/extensions/general/calendar/components/PaymentCalendar'
+import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
import type { Invoice, Deadline } from '@/types'
-export default function CalendarPage() {
+export default function CalendarWorkspace({ userId }: WorkspaceComponentProps) {
const [invoices, setInvoices] = useState([])
const [deadlines, setDeadlines] = useState([])
const [customers, setCustomers] = useState<{ id: string; name: string }[]>([])
@@ -18,7 +19,6 @@ export default function CalendarPage() {
setIsLoading(true)
try {
- // Fetch invoices with customer names
const { data: invoicesData, error: invoicesError } = await supabase
.from('invoices')
.select('*, customer:customers(name)')
@@ -26,7 +26,6 @@ export default function CalendarPage() {
if (invoicesError) throw invoicesError
- // Fetch deadlines with customer names
const { data: deadlinesData, error: deadlinesError } = await supabase
.from('deadlines')
.select('*, customer:customers(name)')
@@ -34,7 +33,6 @@ export default function CalendarPage() {
if (deadlinesError) throw deadlinesError
- // Fetch customers for the form
const { data: customersData, error: customersError } = await supabase
.from('customers')
.select('id, name')
@@ -45,10 +43,10 @@ export default function CalendarPage() {
setInvoices(invoicesData || [])
setDeadlines(deadlinesData || [])
setCustomers(customersData || [])
- } catch (error) {
+ } catch {
toast({
title: 'Fel',
- description: 'Kunde inte hämta data',
+ description: 'Kunde inte hamta data',
variant: 'destructive',
})
} finally {
@@ -101,7 +99,7 @@ export default function CalendarPage() {
})
fetchData()
- } catch (error) {
+ } catch {
toast({
title: 'Fel',
description: 'Kunde inte uppdatera deadline',
@@ -112,31 +110,20 @@ export default function CalendarPage() {
if (isLoading) {
return (
-
-
-
Kalender
-
-
+
)
}
return (
-
+
)
}
diff --git a/components/extensions/general/UserDescriptionMatchWorkspace.tsx b/components/extensions/general/UserDescriptionMatchWorkspace.tsx
new file mode 100644
index 00000000..5d49b6d1
--- /dev/null
+++ b/components/extensions/general/UserDescriptionMatchWorkspace.tsx
@@ -0,0 +1,15 @@
+'use client'
+
+import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
+import EmptyExtensionState from '@/components/extensions/shared/EmptyExtensionState'
+import { TextSearch } from 'lucide-react'
+
+export default function UserDescriptionMatchWorkspace({ userId }: WorkspaceComponentProps) {
+ return (
+
}
+ />
+ )
+}
diff --git a/components/transactions/DescribeTransactionDialog.tsx b/components/transactions/DescribeTransactionDialog.tsx
new file mode 100644
index 00000000..45b40d84
--- /dev/null
+++ b/components/transactions/DescribeTransactionDialog.tsx
@@ -0,0 +1,539 @@
+'use client'
+
+import { useState } from 'react'
+import { Button } from '@/components/ui/button'
+import { Badge } from '@/components/ui/badge'
+import { Card, CardContent } from '@/components/ui/card'
+import { Textarea } from '@/components/ui/textarea'
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+} from '@/components/ui/dialog'
+import { useToast } from '@/components/ui/use-toast'
+import { formatCurrency, formatDate } from '@/lib/utils'
+import {
+ ArrowUpRight,
+ ArrowDownRight,
+ Loader2,
+ Search,
+ ArrowLeft,
+ Check,
+ CheckCircle2,
+ AlertTriangle,
+} from 'lucide-react'
+import JournalEntryPreview from './JournalEntryPreview'
+import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
+import type { TransactionWithInvoice } from './transaction-types'
+
+interface TemplateMatch {
+ template_id: string
+ name_sv: string
+ name_en: string
+ group: string
+ debit_account: string
+ credit_account: string
+ confidence: number
+ description_sv: string
+ vat_rate: number
+ vat_treatment: string | null
+ deductibility: 'full' | 'non_deductible' | 'conditional'
+ deductibility_note_sv: string | null
+ special_rules_sv: string | null
+ risk_level: string
+}
+
+interface DescribeResult {
+ templates: TemplateMatch[]
+ needs_more_detail: boolean
+ user_description: string
+ batch_candidate_count: number
+ merchant_name: string | null
+}
+
+interface DescribeTransactionDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ transaction: TransactionWithInvoice | null
+ onCategorized: (transactionId: string, journalEntryId: string | null) => void
+ onBatchApplied?: (count: number) => void
+}
+
+type Step = 'describe' | 'pick' | 'batch'
+
+function getExamplePrompts(transaction: TransactionWithInvoice): string[] {
+ const desc = (transaction.description || '').toLowerCase()
+ const isExpense = transaction.amount < 0
+
+ if (!isExpense) {
+ return ['Konsultarvode', 'Forsaljning av varor', 'Aterbetalning']
+ }
+
+ // Contextual suggestions based on description keywords
+ if (desc.includes('restaurang') || desc.includes('lunch') || desc.includes('middag') || desc.includes('mat')) {
+ return ['Lunch med kund', 'Personalmiddag', 'Fika till kontoret']
+ }
+ if (desc.includes('hotel') || desc.includes('hotell') || desc.includes('boende') || desc.includes('resa')) {
+ return ['Tjansteresa', 'Hotell konferens', 'Flygbiljett']
+ }
+ if (desc.includes('uber') || desc.includes('taxi') || desc.includes('bolt') || desc.includes('sj ')) {
+ return ['Taxi till kund', 'Tjansteresa', 'Pendling']
+ }
+ if (desc.includes('google') || desc.includes('meta') || desc.includes('facebook') || desc.includes('linkedin')) {
+ return ['Online-annonsering', 'SaaS-prenumeration', 'Marknadsforingskampanj']
+ }
+ if (desc.includes('amazon') || desc.includes('aws') || desc.includes('azure') || desc.includes('cloud')) {
+ return ['Serverhosting', 'SaaS-prenumeration', 'Kontorsmaterial']
+ }
+
+ // Generic expense suggestions
+ return ['Kontorsmaterial', 'SaaS-prenumeration', 'Konsulttjanst', 'Reklam']
+}
+
+export default function DescribeTransactionDialog({
+ open,
+ onOpenChange,
+ transaction,
+ onCategorized,
+ onBatchApplied,
+}: DescribeTransactionDialogProps) {
+ const { toast } = useToast()
+ const [step, setStep] = useState
('describe')
+ const [description, setDescription] = useState('')
+ const [isSearching, setIsSearching] = useState(false)
+ const [isBooking, setIsBooking] = useState(false)
+ const [isBatchApplying, setIsBatchApplying] = useState(false)
+ const [describeResult, setDescribeResult] = useState(null)
+ const [selectedTemplateId, setSelectedTemplateId] = useState(null)
+
+ function resetState() {
+ setStep('describe')
+ setDescription('')
+ setIsSearching(false)
+ setIsBooking(false)
+ setIsBatchApplying(false)
+ setDescribeResult(null)
+ setSelectedTemplateId(null)
+ }
+
+ function handleOpenChange(isOpen: boolean) {
+ if (!isOpen) {
+ resetState()
+ }
+ onOpenChange(isOpen)
+ }
+
+ async function handleSearch() {
+ if (!transaction || description.trim().length < 3) return
+
+ setIsSearching(true)
+ try {
+ const response = await fetch(`/api/transactions/${transaction.id}/describe`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ description: description.trim() }),
+ })
+ const result = await response.json()
+ if (!response.ok) {
+ toast({
+ title: 'Fel',
+ description: result.error || 'Kunde inte soka mallar',
+ variant: 'destructive',
+ })
+ setIsSearching(false)
+ return
+ }
+
+ setDescribeResult(result.data)
+ setSelectedTemplateId(null)
+ setStep('pick')
+ } catch {
+ toast({
+ title: 'Fel',
+ description: 'Nagot gick fel vid sokning',
+ variant: 'destructive',
+ })
+ }
+ setIsSearching(false)
+ }
+
+ async function handleBook() {
+ if (!transaction || !selectedTemplateId || !describeResult) return
+
+ setIsBooking(true)
+ try {
+ const response = await fetch(`/api/transactions/${transaction.id}/categorize`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ is_business: true,
+ template_id: selectedTemplateId,
+ user_description: describeResult.user_description,
+ }),
+ })
+ const result = await response.json()
+ if (!response.ok) {
+ toast({
+ title: 'Fel',
+ description: result.error || 'Kunde inte bokfora transaktion',
+ variant: 'destructive',
+ })
+ setIsBooking(false)
+ return
+ }
+
+ if (describeResult.batch_candidate_count > 0) {
+ setStep('batch')
+ setIsBooking(false)
+ onCategorized(transaction.id, result.journal_entry_id || null)
+ } else {
+ toast({ title: 'Bokford', description: 'Transaktion bokford och verifikation skapad' })
+ onCategorized(transaction.id, result.journal_entry_id || null)
+ handleOpenChange(false)
+ }
+ } catch {
+ toast({
+ title: 'Fel',
+ description: 'Nagot gick fel vid bokforing',
+ variant: 'destructive',
+ })
+ setIsBooking(false)
+ }
+ }
+
+ async function handleBatchApply() {
+ if (!describeResult || !selectedTemplateId) return
+
+ setIsBatchApplying(true)
+ try {
+ const response = await fetch('/api/transactions/batch-describe', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ merchant_name: describeResult.merchant_name,
+ template_id: selectedTemplateId,
+ is_business: true,
+ user_description: describeResult.user_description,
+ }),
+ })
+ const result = await response.json()
+ if (!response.ok) {
+ toast({
+ title: 'Fel',
+ description: result.error || 'Kunde inte bokfora batch',
+ variant: 'destructive',
+ })
+ setIsBatchApplying(false)
+ return
+ }
+
+ const applied = result.data?.applied || 0
+ const errors = result.data?.errors || []
+ if (errors.length > 0) {
+ toast({
+ title: 'Delvis klart',
+ description: `${applied} lyckades, ${errors.length} misslyckades`,
+ variant: 'destructive',
+ })
+ } else {
+ toast({
+ title: 'Klart',
+ description: `${applied} transaktioner bokforda`,
+ })
+ }
+ onBatchApplied?.(applied)
+ handleOpenChange(false)
+ } catch {
+ toast({
+ title: 'Fel',
+ description: 'Nagot gick fel vid batchbokforing',
+ variant: 'destructive',
+ })
+ setIsBatchApplying(false)
+ }
+ }
+
+ function handleSkipBatch() {
+ toast({ title: 'Bokford', description: 'Transaktion bokford och verifikation skapad' })
+ handleOpenChange(false)
+ }
+
+ if (!transaction) return null
+
+ const isIncome = transaction.amount > 0
+
+ return (
+
+ )
+}
diff --git a/components/transactions/InvoiceMatchDialog.tsx b/components/transactions/InvoiceMatchDialog.tsx
index dc224511..1d2eddf8 100644
--- a/components/transactions/InvoiceMatchDialog.tsx
+++ b/components/transactions/InvoiceMatchDialog.tsx
@@ -3,6 +3,7 @@
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
import { formatCurrency, formatDate } from '@/lib/utils'
+import { CheckCircle2, AlertTriangle } from 'lucide-react'
import type { TransactionWithInvoice } from './transaction-types'
interface InvoiceMatchDialogProps {
@@ -66,6 +67,37 @@ export default function InvoiceMatchDialog({
+ {/* Amount comparison */}
+ {(() => {
+ const txAmount = transaction.amount
+ const invAmount = transaction.potential_invoice!.total
+ const sameCurrency = transaction.currency === transaction.potential_invoice!.currency
+ const amountsMatch = sameCurrency && Math.abs(txAmount - invAmount) < 0.01
+
+ if (amountsMatch) {
+ return (
+
+ )
+ }
+
+ const diff = Math.abs(txAmount - invAmount)
+ return (
+
+
+
+
Beloppen skiljer sig
+
+ Differens: {formatCurrency(diff, transaction.currency)}
+ {!sameCurrency && ' (olika valutor)'}
+
+
+
+ )
+ })()}
+
{/* What will happen */}
Vid bekräftelse:
diff --git a/components/transactions/JournalEntryPreview.tsx b/components/transactions/JournalEntryPreview.tsx
new file mode 100644
index 00000000..f98b2029
--- /dev/null
+++ b/components/transactions/JournalEntryPreview.tsx
@@ -0,0 +1,114 @@
+'use client'
+
+import { useMemo } from 'react'
+import { formatCurrency } from '@/lib/utils'
+import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
+import { getVatRate, extractVatAmount, extractNetAmount } from '@/lib/bookkeeping/vat-entries'
+import { getCategoryAccountMapping } from '@/lib/bookkeeping/category-mapping'
+import type { TransactionCategory, VatTreatment } from '@/types'
+
+interface PreviewLine {
+ side: 'debet' | 'kredit'
+ account: string
+ amount: number
+}
+
+interface JournalEntryPreviewProps {
+ amount: number
+ currency?: string
+ category?: TransactionCategory
+ vatTreatment?: VatTreatment | 'none'
+ accountOverride?: string
+ /** For template-based bookings — overrides category mapping */
+ templateDebitAccount?: string
+ templateCreditAccount?: string
+ templateVatRate?: number
+}
+
+export default function JournalEntryPreview({
+ amount,
+ currency = 'SEK',
+ category,
+ vatTreatment,
+ accountOverride,
+ templateDebitAccount,
+ templateCreditAccount,
+ templateVatRate,
+}: JournalEntryPreviewProps) {
+ const lines = useMemo(() => {
+ const result: PreviewLine[] = []
+ const absAmount = Math.abs(amount)
+
+ // Template-based preview
+ if (templateDebitAccount && templateCreditAccount) {
+ const vatRate = templateVatRate ?? 0
+ const vatAmt = extractVatAmount(absAmount, vatRate)
+ const netAmt = extractNetAmount(absAmount, vatRate)
+
+ result.push({ side: 'debet', account: templateDebitAccount, amount: netAmt })
+ if (vatAmt > 0) {
+ result.push({ side: 'debet', account: '2641', amount: vatAmt })
+ }
+ result.push({ side: 'kredit', account: templateCreditAccount, amount: absAmount })
+ return result
+ }
+
+ // Category-based preview
+ if (!category) return result
+
+ const resolvedVat = vatTreatment === 'none' ? undefined : vatTreatment
+ const mapping = getCategoryAccountMapping(category, amount, category !== 'private', 'enskild_firma', resolvedVat)
+
+ const debitAccount = accountOverride && amount < 0 ? accountOverride : mapping.debitAccount
+ const creditAccount = accountOverride && amount > 0 ? accountOverride : mapping.creditAccount
+
+ const treatment = mapping.vatTreatment as VatTreatment | null
+ const vatRate = treatment ? getVatRate(treatment) : 0
+ const vatAmt = vatRate > 0 ? extractVatAmount(absAmount, vatRate) : 0
+ const netAmt = vatRate > 0 ? extractNetAmount(absAmount, vatRate) : absAmount
+
+ if (amount < 0) {
+ // Expense: Debit expense + VAT, Credit bank
+ result.push({ side: 'debet', account: debitAccount, amount: netAmt })
+ if (vatAmt > 0 && mapping.vatDebitAccount) {
+ result.push({ side: 'debet', account: mapping.vatDebitAccount, amount: vatAmt })
+ }
+ result.push({ side: 'kredit', account: creditAccount, amount: absAmount })
+ } else {
+ // Income: Debit bank, Credit revenue + VAT
+ result.push({ side: 'debet', account: debitAccount, amount: absAmount })
+ if (vatAmt > 0 && mapping.vatCreditAccount) {
+ result.push({ side: 'kredit', account: mapping.vatCreditAccount, amount: vatAmt })
+ }
+ result.push({ side: 'kredit', account: creditAccount, amount: netAmt })
+ }
+
+ // Reverse charge: add offsetting lines
+ if (treatment === 'reverse_charge' && amount < 0) {
+ const rcVatAmt = Math.round(absAmount * 0.25 * 100) / 100
+ result.push({ side: 'debet', account: '2645', amount: rcVatAmt })
+ result.push({ side: 'kredit', account: '2614', amount: rcVatAmt })
+ }
+
+ return result
+ }, [amount, category, vatTreatment, accountOverride, templateDebitAccount, templateCreditAccount, templateVatRate])
+
+ if (lines.length === 0) return null
+
+ return (
+
+
Verifikation
+
+ {lines.map((line, i) => (
+
+
+ {line.side === 'debet' ? 'Debet' : 'Kredit'}
+
+ {formatAccountWithName(line.account)}
+ {formatCurrency(line.amount, currency)}
+
+ ))}
+
+
+ )
+}
diff --git a/components/transactions/QuickReviewDialog.tsx b/components/transactions/QuickReviewDialog.tsx
index 86d0872b..24e9056b 100644
--- a/components/transactions/QuickReviewDialog.tsx
+++ b/components/transactions/QuickReviewDialog.tsx
@@ -8,10 +8,12 @@ import { useToast } from '@/components/ui/use-toast'
import { formatCurrency, formatDate } from '@/lib/utils'
import { ArrowUpRight, ArrowDownRight, Check, Paperclip, ChevronDown, ChevronUp } from 'lucide-react'
import { getDefaultAccountForCategory } from '@/lib/bookkeeping/category-mapping'
+import JournalEntryPreview from './JournalEntryPreview'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
import VatTreatmentSelect from './VatTreatmentSelect'
+import { VAT_TREATMENT_OPTIONS } from './transaction-types'
import type { TransactionWithInvoice } from './transaction-types'
import type { TransactionCategory, VatTreatment, BASAccount } from '@/types'
@@ -49,6 +51,7 @@ export default function QuickReviewDialog({
const [error, setError] = useState
(null)
const [uploadedFiles, setUploadedFiles] = useState([])
const [showUploadZone, setShowUploadZone] = useState(false)
+ const [showVatDropdown, setShowVatDropdown] = useState(false)
// Handle account changes — clear VAT for liability/equity accounts (class 2)
const handleAccountChange = useCallback((account: string) => {
@@ -174,6 +177,15 @@ export default function QuickReviewDialog({
+ {/* Journal entry preview */}
+
+
{/* Account */}
@@ -190,14 +202,26 @@ export default function QuickReviewDialog({
-
- {isLiabilityAccount && (
-
- Ingen moms för skuld-/eget kapital-konton
+ {isLiabilityAccount ? (
+
+ Ingen moms for skuld-/eget kapital-konton
+
+ ) : showVatDropdown ? (
+
+ ) : (
+
+ {VAT_TREATMENT_OPTIONS.find(o => o.value === vatTreatment)?.label || 'Ingen moms'}
+ {' '}
+
)}
diff --git a/components/transactions/SwipeCategorizationView.tsx b/components/transactions/SwipeCategorizationView.tsx
index 9db0c5d8..cf227023 100644
--- a/components/transactions/SwipeCategorizationView.tsx
+++ b/components/transactions/SwipeCategorizationView.tsx
@@ -10,18 +10,22 @@ import VatTreatmentSelect from './VatTreatmentSelect'
import { formatCurrency, formatDate } from '@/lib/utils'
import { checkExpenseWarnings } from '@/lib/tax/expense-warnings'
import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from '@/lib/bookkeeping/category-mapping'
+import JournalEntryPreview from './JournalEntryPreview'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
-import { X, ArrowLeft, ArrowRight, Building, AlertTriangle, Check, FileText, Link2, Receipt as ReceiptIcon, SkipForward, Paperclip, ChevronDown, ChevronUp } from 'lucide-react'
+import { X, ArrowLeft, ArrowRight, Building, AlertTriangle, Check, FileText, Link2, Receipt as ReceiptIcon, SkipForward, Paperclip, ChevronDown, ChevronUp, MessageSquareText } from 'lucide-react'
+import DescribeTransactionDialog from './DescribeTransactionDialog'
+import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
import type { TransactionCategory, VatTreatment, BASAccount } from '@/types'
-import type { SuggestedCategory } from '@/lib/transactions/category-suggestions'
+import type { SuggestedCategory, SuggestedTemplate } from '@/lib/transactions/category-suggestions'
import type { TransactionWithInvoice, CategorizeHandler, MatchInvoiceHandler } from './transaction-types'
-import { EXPENSE_CATEGORIES, INCOME_CATEGORIES } from './transaction-types'
+import { EXPENSE_CATEGORIES, INCOME_CATEGORIES, VAT_TREATMENT_OPTIONS } from './transaction-types'
interface SwipeCategorizationViewProps {
transactions: TransactionWithInvoice[]
suggestions?: Record
+ templateSuggestions?: Record
onCategorize: CategorizeHandler
onMatchInvoice?: MatchInvoiceHandler
onClose: () => void
@@ -33,6 +37,7 @@ const incomeCategories = INCOME_CATEGORIES
export default function SwipeCategorizationView({
transactions,
suggestions,
+ templateSuggestions,
onCategorize,
onMatchInvoice,
onClose,
@@ -52,6 +57,8 @@ export default function SwipeCategorizationView({
const [accounts, setAccounts] = useState([])
const [uploadedFiles, setUploadedFiles] = useState([])
const [showUploadZone, setShowUploadZone] = useState(false)
+ const [showDescribeDialog, setShowDescribeDialog] = useState(false)
+ const [showVatDropdown, setShowVatDropdown] = useState(false)
// Clear VAT treatment when switching to a liability/equity account (class 2)
useEffect(() => {
@@ -113,6 +120,7 @@ export default function SwipeCategorizationView({
setPendingCategory(category)
setAccountOverride(defaultAccount)
setVatTreatment(defaultVat ?? 'none')
+ setShowVatDropdown(false)
setShowCategorySelect(false)
setShowReviewStep(true)
setError(null)
@@ -366,6 +374,15 @@ export default function SwipeCategorizationView({
+ {/* Journal entry preview */}
+
+
{/* Account override */}
@@ -382,15 +399,27 @@ export default function SwipeCategorizationView({
-
- {isLiabilityAccount && (
-
+ {isLiabilityAccount ? (
+
Ingen moms for skuld-/eget kapital-konton
+ ) : showVatDropdown ? (
+
+ ) : (
+
+ {VAT_TREATMENT_OPTIONS.find(o => o.value === vatTreatment)?.label || 'Ingen moms'}
+ {' '}
+
+
)}
@@ -641,7 +670,7 @@ export default function SwipeCategorizationView({
{suggestion.label}
{suggestion.account && (
- {suggestion.account}
+ {formatAccountWithName(suggestion.account)}
)}
@@ -650,6 +679,45 @@ export default function SwipeCategorizationView({
)}
+ {/* Fallback templates when no strong suggestion */}
+ {(() => {
+ const txSuggestions = suggestions?.[currentTransaction.id]
+ const topConfidence = txSuggestions?.[0]?.confidence ?? 0
+ const templates = templateSuggestions?.[currentTransaction.id]
+ if (topConfidence < 0.55 && templates && templates.length > 0) {
+ return (
+
+
Osaker? Prova dessa mallar:
+
+ {templates.slice(0, 3).map((tmpl) => (
+
+ ))}
+
+
+ )
+ }
+ return null
+ })()}
+
+ {/* Describe transaction button */}
+
+
{/* Categorization button */}
)}
+ {/* Fallback templates when no strong suggestion */}
+ {showTemplateFallback && !hasInvoiceMatch && (
+ <>
+ Osaker? Prova:
+ {templateSuggestions!.slice(0, 3).map((tmpl) => (
+
+ ))}
+ >
+ )}
+
{/* Private button */}
@@ -200,6 +236,20 @@ export default function TransactionInboxCard({
+ {/* Describe transaction */}
+ {onOpenDescribe && (
+
+ )}
+
{/* Open category dialog */}