Merge remote-tracking branch 'origin/main' into fixing-extensions-bug

This commit is contained in:
Emil
2026-02-23 09:46:32 +01:00
58 changed files with 5693 additions and 860 deletions
@@ -1,35 +1,22 @@
'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'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { EXPENSE_CATEGORIES, INCOME_CATEGORIES, VAT_TREATMENT_OPTIONS } from './transaction-types'
import type { TransactionCategory, VatTreatment } 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' },
]
const expenseCategories = EXPENSE_CATEGORIES
const incomeCategories = INCOME_CATEGORIES
const vatTreatmentOptions = VAT_TREATMENT_OPTIONS
interface BatchCategorySelectorProps {
open: boolean
onOpenChange: (open: boolean) => void
selectedCount: number
onSelectCategory: (category: TransactionCategory) => void
onSelectCategory: (category: TransactionCategory, vatTreatment?: VatTreatment) => void
progress: { done: number; total: number } | null
}
@@ -40,8 +27,14 @@ export default function BatchCategorySelector({
onSelectCategory,
progress,
}: BatchCategorySelectorProps) {
const [vatTreatment, setVatTreatment] = useState<VatTreatment | 'none'>('standard_25')
const isProcessing = progress !== null
const handleSelectCategory = (category: TransactionCategory) => {
const resolvedVat = vatTreatment === 'none' ? undefined : vatTreatment
onSelectCategory(category, resolvedVat)
}
return (
<Dialog open={open} onOpenChange={isProcessing ? undefined : onOpenChange}>
<DialogContent className="max-w-md">
@@ -67,6 +60,24 @@ export default function BatchCategorySelector({
</div>
) : (
<div className="space-y-4 py-2">
<div>
<h4 className="text-sm font-medium text-muted-foreground mb-2">Momsbehandling</h4>
<Select
value={vatTreatment}
onValueChange={(v) => setVatTreatment(v as VatTreatment | 'none')}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{vatTreatmentOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<h4 className="text-sm font-medium text-muted-foreground mb-2">Kostnader</h4>
<div className="grid grid-cols-2 gap-1.5">
@@ -76,7 +87,7 @@ export default function BatchCategorySelector({
variant="outline"
size="sm"
className="justify-start text-xs"
onClick={() => onSelectCategory(cat.value)}
onClick={() => handleSelectCategory(cat.value)}
>
{cat.label}
</Button>
@@ -92,7 +103,7 @@ export default function BatchCategorySelector({
variant="outline"
size="sm"
className="justify-start text-xs"
onClick={() => onSelectCategory(cat.value)}
onClick={() => handleSelectCategory(cat.value)}
>
{cat.label}
</Button>
@@ -0,0 +1,105 @@
'use client'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { formatCurrency, formatDate } from '@/lib/utils'
import { ArrowUpRight, ArrowDownRight } from 'lucide-react'
import { EXPENSE_CATEGORIES, INCOME_CATEGORIES } from './transaction-types'
import type { TransactionWithInvoice } from './transaction-types'
import type { TransactionCategory } from '@/types'
interface CategoryExpandedDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
transaction: TransactionWithInvoice | null
onSelectCategory: (category: TransactionCategory) => void
isProcessing: boolean
}
export default function CategoryExpandedDialog({
open,
onOpenChange,
transaction,
onSelectCategory,
isProcessing,
}: CategoryExpandedDialogProps) {
if (!transaction) return null
const isIncome = transaction.amount > 0
return (
<Dialog open={open} onOpenChange={isProcessing ? undefined : onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Välj kategori</DialogTitle>
<DialogDescription>
Välj rätt kategori för att bokföra transaktionen
</DialogDescription>
</DialogHeader>
{/* Transaction summary */}
<div className="flex items-center gap-3 rounded-lg border p-3">
<div
className={`h-9 w-9 rounded-full flex items-center justify-center flex-shrink-0 ${
isIncome
? 'bg-success/10 text-success'
: 'bg-destructive/10 text-destructive'
}`}
>
{isIncome ? (
<ArrowUpRight className="h-4 w-4" />
) : (
<ArrowDownRight className="h-4 w-4" />
)}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{transaction.description}</p>
<p className="text-xs text-muted-foreground">{formatDate(transaction.date)}</p>
</div>
<p className={`font-medium text-sm flex-shrink-0 ${isIncome ? 'text-success' : ''}`}>
{isIncome ? '+' : ''}
{formatCurrency(transaction.amount, transaction.currency)}
</p>
</div>
{/* Category grid */}
<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">
{EXPENSE_CATEGORIES.map((cat) => (
<Button
key={cat.value}
variant="outline"
size="sm"
className="justify-start text-xs"
onClick={() => onSelectCategory(cat.value)}
disabled={isProcessing}
>
{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">
{INCOME_CATEGORIES.map((cat) => (
<Button
key={cat.value}
variant="outline"
size="sm"
className="justify-start text-xs"
onClick={() => onSelectCategory(cat.value)}
disabled={isProcessing}
>
{cat.label}
</Button>
))}
</div>
</div>
</div>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,69 @@
'use client'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Check, Upload, Plus } from 'lucide-react'
import Link from 'next/link'
interface InboxZeroStateProps {
hasTransactions: boolean
onCreateTransaction: () => void
}
export default function InboxZeroState({ hasTransactions, onCreateTransaction }: InboxZeroStateProps) {
if (!hasTransactions) {
// No transactions at all
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<div className="p-5 rounded-full bg-muted mb-6">
<Upload className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-lg font-medium mb-2">Inga transaktioner</h3>
<p className="text-sm text-muted-foreground text-center max-w-sm mb-6">
Importera kontoutdrag från din bank eller lägg till transaktioner manuellt för att komma igång.
</p>
<div className="flex gap-2">
<Button asChild>
<Link href="/import">
<Upload className="mr-2 h-4 w-4" />
Importera transaktioner
</Link>
</Button>
<Button variant="outline" onClick={onCreateTransaction}>
<Plus className="mr-2 h-4 w-4" />
Lägg till manuellt
</Button>
</div>
</CardContent>
</Card>
)
}
// All transactions categorized - inbox zero!
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<div className="h-16 w-16 rounded-full bg-success/10 flex items-center justify-center mb-4">
<Check className="h-8 w-8 text-success" />
</div>
<h3 className="text-lg font-bold">Alla transaktioner bokförda!</h3>
<p className="text-muted-foreground text-center mt-1 max-w-sm">
Bra jobbat! Alla dina transaktioner är bokförda. Importera fler eller växla till historik.
</p>
<div className="flex gap-2 mt-6">
<Button asChild variant="outline">
<Link href="/import">
<Upload className="mr-2 h-4 w-4" />
Importera fler
</Link>
</Button>
<Button variant="outline" onClick={onCreateTransaction}>
<Plus className="mr-2 h-4 w-4" />
Ny transaktion
</Button>
</div>
</CardContent>
</Card>
)
}
@@ -0,0 +1,99 @@
'use client'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
import { formatCurrency, formatDate } from '@/lib/utils'
import type { TransactionWithInvoice } from './transaction-types'
interface InvoiceMatchDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
transaction: TransactionWithInvoice | null
isConfirming: boolean
onConfirm: () => void
}
export default function InvoiceMatchDialog({
open,
onOpenChange,
transaction,
isConfirming,
onConfirm,
}: InvoiceMatchDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Bekräfta fakturamatchning</DialogTitle>
<DialogDescription>
Vill du koppla denna transaktion till fakturan? Fakturan kommer att markeras som betald.
</DialogDescription>
</DialogHeader>
{transaction?.potential_invoice && (
<div className="space-y-4">
{/* Transaction details */}
<div className="rounded-lg border p-4 space-y-2">
<p className="text-sm font-medium text-muted-foreground">Transaktion</p>
<p className="font-medium">{transaction.description}</p>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{formatDate(transaction.date)}</span>
<span className="font-medium text-success">
+{formatCurrency(transaction.amount, transaction.currency)}
</span>
</div>
</div>
{/* Invoice details */}
<div className="rounded-lg border p-4 space-y-2">
<p className="text-sm font-medium text-muted-foreground">Faktura</p>
<p className="font-medium">
Faktura {transaction.potential_invoice.invoice_number}
</p>
<p className="text-sm text-muted-foreground">
{transaction.potential_invoice.customer?.name || 'Okänd kund'}
</p>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
Förfaller: {formatDate(transaction.potential_invoice.due_date)}
</span>
<span className="font-medium">
{formatCurrency(
transaction.potential_invoice.total,
transaction.potential_invoice.currency
)}
</span>
</div>
</div>
{/* What will happen */}
<div className="rounded-lg bg-muted/50 p-4 space-y-2">
<p className="text-sm font-medium">Vid bekräftelse:</p>
<ul className="text-sm text-muted-foreground space-y-1">
<li>• Transaktionen kopplas till fakturan</li>
<li>• Fakturan markeras som betald</li>
<li>• Bokföringsverifikation skapas automatiskt</li>
</ul>
</div>
</div>
)}
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isConfirming}
>
Avbryt
</Button>
<Button
onClick={onConfirm}
disabled={isConfirming}
>
{isConfirming ? 'Bekräftar...' : 'Bekräfta matchning'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -1,47 +1,32 @@
'use client'
import { useState, useCallback } from 'react'
import { useState, useCallback, useEffect } from 'react'
import { motion, useMotionValue, useTransform, AnimatePresence, type PanInfo } from 'framer-motion'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { formatCurrency, formatDate } from '@/lib/utils'
import { checkExpenseWarnings } from '@/lib/tax/expense-warnings'
import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from '@/lib/bookkeeping/category-mapping'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import { X, ArrowLeft, ArrowRight, Building, AlertTriangle, Check, FileText, Link2, Receipt as ReceiptIcon, SkipForward } from 'lucide-react'
import type { Transaction, TransactionCategory, Invoice, Customer } from '@/types'
import type { TransactionCategory, VatTreatment, BASAccount } from '@/types'
import type { SuggestedCategory } from '@/lib/transactions/category-suggestions'
interface TransactionWithInvoice extends Transaction {
potential_invoice?: Invoice & { customer?: Customer }
}
import type { TransactionWithInvoice, CategorizeHandler, MatchInvoiceHandler } from './transaction-types'
import { EXPENSE_CATEGORIES, INCOME_CATEGORIES, VAT_TREATMENT_OPTIONS } from './transaction-types'
interface SwipeCategorizationViewProps {
transactions: TransactionWithInvoice[]
suggestions?: Record<string, SuggestedCategory[]>
onCategorize: (id: string, isBusiness: boolean, category?: TransactionCategory) => Promise<boolean>
onMatchInvoice?: (transactionId: string, invoiceId: string) => Promise<boolean>
onCategorize: CategorizeHandler
onMatchInvoice?: MatchInvoiceHandler
onClose: () => void
}
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' },
]
const vatTreatmentOptions = VAT_TREATMENT_OPTIONS
const expenseCategories = EXPENSE_CATEGORIES
const incomeCategories = INCOME_CATEGORIES
export default function SwipeCategorizationView({
transactions,
@@ -56,6 +41,29 @@ export default function SwipeCategorizationView({
const [isProcessing, setIsProcessing] = useState(false)
const [error, setError] = useState<string | null>(null)
// Review step state
const [showReviewStep, setShowReviewStep] = useState(false)
const [pendingCategory, setPendingCategory] = useState<TransactionCategory | null>(null)
const [accountOverride, setAccountOverride] = useState('')
const [vatTreatment, setVatTreatment] = useState<VatTreatment | 'none'>('standard_25')
const [accounts, setAccounts] = useState<BASAccount[]>([])
// Fetch accounts on mount
useEffect(() => {
async function fetchAccounts() {
try {
const res = await fetch('/api/bookkeeping/accounts')
const data = await res.json()
if (data.accounts) {
setAccounts(data.accounts)
}
} catch {
// Non-critical, AccountCombobox will just be empty
}
}
fetchAccounts()
}, [])
const currentTransaction = transactions[currentIndex]
const warnings = currentTransaction
? checkExpenseWarnings(currentTransaction.description)
@@ -85,6 +93,19 @@ export default function SwipeCategorizationView({
[isProcessing, x]
)
const handleCategorySelect = useCallback((category: TransactionCategory) => {
// Set up review step with defaults for this category
const defaultAccount = getDefaultAccountForCategory(category)
const defaultVat = getDefaultVatTreatmentForCategory(category)
setPendingCategory(category)
setAccountOverride(defaultAccount)
setVatTreatment(defaultVat ?? 'none')
setShowCategorySelect(false)
setShowReviewStep(true)
setError(null)
}, [])
const handleDragEnd = useCallback(
async (_event: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
if (isProcessing || !currentTransaction) return
@@ -101,24 +122,9 @@ export default function SwipeCategorizationView({
setShowCategorySelect(true)
x.set(0)
} else {
setIsProcessing(true)
setError(null)
try {
const success = await onCategorize(
currentTransaction.id,
true,
'income_other'
)
if (success) {
moveToNext()
} else {
setError('Kunde inte bokföra. Tryck "Hoppa över" för att gå vidare.')
}
} catch {
setError('Ett fel uppstod. Tryck "Hoppa över" för att gå vidare.')
} finally {
setIsProcessing(false)
}
// Income: go to review step with income_other default
handleCategorySelect('income_other')
x.set(0)
}
} else {
// Swipe left = skip
@@ -128,16 +134,32 @@ export default function SwipeCategorizationView({
x.set(0)
}
},
[isProcessing, currentTransaction, onCategorize, x, moveToNext]
[isProcessing, currentTransaction, handleCategorySelect, x, moveToNext]
)
const handleCategorySelect = async (category: TransactionCategory) => {
const handleReviewConfirm = async () => {
if (!pendingCategory) return
setIsProcessing(true)
setError(null)
try {
const success = await onCategorize(currentTransaction.id, true, category)
const resolvedVat = vatTreatment === 'none' ? undefined : vatTreatment
const defaultAccount = getDefaultAccountForCategory(pendingCategory)
// Only send override if it differs from the default
const override = accountOverride && accountOverride !== defaultAccount
? accountOverride
: undefined
const success = await onCategorize(
currentTransaction.id,
true,
pendingCategory,
resolvedVat,
override
)
if (success) {
setShowCategorySelect(false)
setShowReviewStep(false)
setPendingCategory(null)
moveToNext()
} else {
setError('Kunde inte bokföra. Tryck "Hoppa över" för att gå vidare.')
@@ -174,6 +196,8 @@ export default function SwipeCategorizationView({
const handleSkip = useCallback(() => {
setError(null)
setShowCategorySelect(false)
setShowReviewStep(false)
setPendingCategory(null)
moveToNext()
}, [moveToNext])
@@ -253,6 +277,123 @@ export default function SwipeCategorizationView({
)
}
if (showReviewStep && pendingCategory) {
const categoryLabel = [...expenseCategories, ...incomeCategories].find(
(c) => c.value === pendingCategory
)?.label || pendingCategory
// Auto-clear VAT when a class 2 (liability/equity) account is selected
const isLiabilityAccount = accountOverride.startsWith('2')
return (
<div className="fixed inset-0 bg-background z-50 flex flex-col">
<div className="flex items-center justify-between p-4 border-b">
<Button
variant="ghost"
size="icon"
onClick={() => {
setShowReviewStep(false)
setShowCategorySelect(true)
}}
>
<ArrowLeft className="h-5 w-5" />
</Button>
<h1 className="font-semibold">Granska bokföring</h1>
<div className="w-10" />
</div>
<div className="flex-1 overflow-auto p-4 space-y-4">
{/* Transaction summary */}
<Card>
<CardContent className="pt-4 space-y-1">
<p className="font-medium">{currentTransaction.description}</p>
<p className="text-sm text-muted-foreground">{formatDate(currentTransaction.date)}</p>
<p className="text-2xl font-bold mt-2">
{currentTransaction.amount > 0 ? '+' : ''}
{formatCurrency(currentTransaction.amount, currentTransaction.currency)}
</p>
</CardContent>
</Card>
{/* Selected category */}
<div>
<label className="text-sm font-medium text-muted-foreground">Kategori</label>
<div className="mt-1">
<Badge variant="outline" className="text-sm py-1 px-3">{categoryLabel}</Badge>
</div>
</div>
{/* Account override */}
<div>
<label className="text-sm font-medium text-muted-foreground">Konto</label>
<div className="mt-1">
<AccountCombobox
value={accountOverride}
accounts={accounts}
onChange={setAccountOverride}
/>
</div>
</div>
{/* VAT treatment */}
<div>
<label className="text-sm font-medium text-muted-foreground">Momsbehandling</label>
<div className="mt-1">
<Select
value={isLiabilityAccount ? 'none' : vatTreatment}
onValueChange={(v) => setVatTreatment(v as VatTreatment | 'none')}
disabled={isLiabilityAccount}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{vatTreatmentOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
{isLiabilityAccount && (
<p className="text-xs text-muted-foreground mt-1">
Ingen moms för skuld-/eget kapital-konton
</p>
)}
</div>
</div>
{error && (
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
{error}
</div>
)}
</div>
{/* Actions */}
<div className="p-4 border-t space-y-2">
<Button
className="w-full"
onClick={handleReviewConfirm}
disabled={isProcessing}
>
<Check className="mr-2 h-4 w-4" />
{isProcessing ? 'Bokför...' : 'Bokför'}
</Button>
<Button
variant="ghost"
className="w-full text-muted-foreground"
onClick={handleSkip}
disabled={isProcessing}
>
<SkipForward className="mr-2 h-4 w-4" />
Hoppa över
</Button>
</div>
</div>
)
}
return (
<div className="fixed inset-0 bg-background z-50 flex flex-col">
{/* Header */}
@@ -0,0 +1,181 @@
'use client'
import { useState } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { formatCurrency, formatDate } from '@/lib/utils'
import { getCategoryDisplayName } from '@/lib/tax/expense-warnings'
import { Search, ArrowUpRight, ArrowDownRight, ArrowLeftRight, Check, Link2, FileText } from 'lucide-react'
import type { TransactionWithInvoice } from './transaction-types'
import type { HistoryFilter } from './transaction-types'
interface TransactionHistoryListProps {
transactions: TransactionWithInvoice[]
onOpenMatchDialog: (transaction: TransactionWithInvoice) => void
}
export default function TransactionHistoryList({
transactions,
onOpenMatchDialog,
}: TransactionHistoryListProps) {
const [searchTerm, setSearchTerm] = useState('')
const [filter, setFilter] = useState<HistoryFilter>('all')
const filtered = transactions.filter((t) => {
const matchesSearch = t.description.toLowerCase().includes(searchTerm.toLowerCase())
const matchesFilter =
filter === 'all' ||
(filter === 'business' && t.is_business === true) ||
(filter === 'private' && t.is_business === false)
return matchesSearch && matchesFilter
})
return (
<div className="space-y-4">
{/* Search + filter pills */}
<div className="flex flex-col sm:flex-row gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Sök transaktioner..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
<div className="flex gap-1.5">
{(['all', 'business', 'private'] as const).map((f) => (
<Button
key={f}
size="sm"
variant={filter === f ? 'default' : 'outline'}
className="h-9"
onClick={() => setFilter(f)}
>
{f === 'all' ? 'Alla' : f === 'business' ? 'Företag' : 'Privat'}
</Button>
))}
</div>
</div>
{/* Transaction list */}
{filtered.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<ArrowLeftRight className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium">Inga transaktioner</h3>
<p className="text-muted-foreground text-center mt-1">
{searchTerm
? 'Inga transaktioner matchar din sökning'
: 'Inga transaktioner att visa med valt filter'}
</p>
</CardContent>
</Card>
) : (
<div className="space-y-2">
{filtered.map((transaction) => (
<Card key={transaction.id} className="hover:border-primary/50 transition-colors">
<CardContent className="py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div
className={`h-10 w-10 rounded-full flex items-center justify-center ${
transaction.amount > 0
? 'bg-success/10 text-success'
: 'bg-destructive/10 text-destructive'
}`}
>
{transaction.amount > 0 ? (
<ArrowUpRight className="h-5 w-5" />
) : (
<ArrowDownRight className="h-5 w-5" />
)}
</div>
<div>
<p className="font-medium">{transaction.description}</p>
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span>{formatDate(transaction.date)}</span>
{transaction.is_business !== null &&
!(
transaction.is_business &&
transaction.category === 'uncategorized' &&
transaction.journal_entry_id
) && (
<>
<span>·</span>
<Badge
variant={transaction.is_business ? 'default' : 'secondary'}
>
{transaction.is_business
? getCategoryDisplayName(transaction.category)
: 'Privat'}
</Badge>
</>
)}
{transaction.invoice_id && (
<>
<span>·</span>
<Badge variant="outline" className="text-blue-600 border-blue-600">
<Link2 className="h-3 w-3 mr-1" />
Kopplad till faktura
</Badge>
</>
)}
{transaction.journal_entry_id ? (
<>
<span>·</span>
<Badge variant="outline" className="text-success border-success">
<Check className="h-3 w-3 mr-1" />
Bokförd
</Badge>
</>
) : transaction.is_business === null ? (
<>
<span>·</span>
<Badge variant="outline" className="text-warning border-warning">
Ej bokförd
</Badge>
</>
) : null}
{transaction.potential_invoice && !transaction.invoice_id && (
<>
<span>·</span>
<Badge
variant="outline"
className="text-blue-600 border-blue-600 cursor-pointer hover:bg-blue-50"
onClick={() => onOpenMatchDialog(transaction)}
>
<FileText className="h-3 w-3 mr-1" />
Möjlig match: Faktura {transaction.potential_invoice.invoice_number}
</Badge>
</>
)}
</div>
</div>
</div>
<div className="text-right">
<p
className={`font-medium ${
transaction.amount > 0 ? 'text-success' : ''
}`}
>
{transaction.amount > 0 ? '+' : ''}
{formatCurrency(transaction.amount, transaction.currency)}
</p>
{transaction.currency !== 'SEK' && transaction.amount_sek && (
<p className="text-sm text-muted-foreground">
{formatCurrency(transaction.amount_sek)}
</p>
)}
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
)
}
@@ -0,0 +1,197 @@
'use client'
import { motion } from 'framer-motion'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { formatCurrency, formatDate } from '@/lib/utils'
import { ArrowUpRight, ArrowDownRight, FileText, MoreHorizontal, Loader2 } from 'lucide-react'
import type { TransactionWithInvoice, CategorizeHandler } from './transaction-types'
import type { SuggestedCategory } from '@/lib/transactions/category-suggestions'
interface TransactionInboxCardProps {
transaction: TransactionWithInvoice
suggestions?: SuggestedCategory[]
processingId: string | null
isBatchMode: boolean
isSelected: boolean
onCategorize: CategorizeHandler
onMarkPrivate: (id: string) => void
onOpenMatchDialog: (transaction: TransactionWithInvoice) => void
onOpenCategoryDialog: (transaction: TransactionWithInvoice) => void
onToggleSelect: (id: string) => void
onAnimationComplete?: (id: string) => void
}
export default function TransactionInboxCard({
transaction,
suggestions,
processingId,
isBatchMode,
isSelected,
onCategorize,
onMarkPrivate,
onOpenMatchDialog,
onOpenCategoryDialog,
onToggleSelect,
onAnimationComplete,
}: TransactionInboxCardProps) {
const isProcessing = processingId === transaction.id
const isDisabled = processingId !== null && processingId !== transaction.id
const isIncome = transaction.amount > 0
const hasInvoiceMatch = !!transaction.potential_invoice && !transaction.invoice_id
const topSuggestion = suggestions?.[0]
const isUncategorized = transaction.is_business === null && !transaction.journal_entry_id
const showCheckbox = isBatchMode && isUncategorized
async function handleSuggestionClick(suggestion: SuggestedCategory) {
await onCategorize(transaction.id, true, suggestion.category)
}
return (
<motion.div
layout
initial={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0, marginBottom: 0 }}
transition={{ duration: 0.3, ease: 'easeInOut' }}
onAnimationComplete={(definition) => {
// Only call on exit animation
if (typeof definition === 'object' && 'opacity' in definition && definition.opacity === 0) {
onAnimationComplete?.(transaction.id)
}
}}
>
<Card
className={`transition-colors ${
hasInvoiceMatch ? 'border-blue-500/50' : 'border-warning/50'
} ${isSelected ? 'border-primary bg-primary/[0.02]' : ''} ${
isDisabled ? 'opacity-50' : ''
}`}
onClick={showCheckbox ? () => onToggleSelect(transaction.id) : undefined}
>
<CardContent className="py-4">
<div className="flex items-start justify-between gap-4">
{/* Left: checkbox + icon + info */}
<div className="flex items-start gap-3 min-w-0 flex-1">
{showCheckbox && (
<Checkbox
checked={isSelected}
onCheckedChange={() => onToggleSelect(transaction.id)}
onClick={(e) => e.stopPropagation()}
className="mt-1"
/>
)}
<div
className={`h-10 w-10 rounded-full flex items-center justify-center flex-shrink-0 ${
isIncome
? 'bg-success/10 text-success'
: 'bg-destructive/10 text-destructive'
}`}
>
{isIncome ? (
<ArrowUpRight className="h-5 w-5" />
) : (
<ArrowDownRight className="h-5 w-5" />
)}
</div>
<div className="min-w-0">
<p className="font-medium truncate">{transaction.description}</p>
<p className="text-sm text-muted-foreground">{formatDate(transaction.date)}</p>
</div>
</div>
{/* Right: amount */}
<div className="text-right flex-shrink-0">
<p className={`font-medium ${isIncome ? 'text-success' : ''}`}>
{isIncome ? '+' : ''}
{formatCurrency(transaction.amount, transaction.currency)}
</p>
{transaction.currency !== 'SEK' && transaction.amount_sek && (
<p className="text-sm text-muted-foreground">
{formatCurrency(transaction.amount_sek)}
</p>
)}
</div>
</div>
{/* Inline action buttons - only shown when not in batch mode */}
{!isBatchMode && (
<div className="flex flex-wrap items-center gap-2 mt-3 pt-3 border-t">
{/* Primary action: invoice match or top suggestion */}
{hasInvoiceMatch ? (
<Button
size="sm"
variant="default"
className="h-8 text-xs"
onClick={() => onOpenMatchDialog(transaction)}
disabled={isProcessing || isDisabled}
>
{isProcessing ? (
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
) : (
<FileText className="mr-1.5 h-3 w-3" />
)}
Matcha Faktura {transaction.potential_invoice!.invoice_number}
</Button>
) : topSuggestion ? (
<Button
size="sm"
variant="default"
className="h-8 text-xs"
onClick={() => handleSuggestionClick(topSuggestion)}
disabled={isProcessing || isDisabled}
>
{isProcessing ? (
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
) : null}
{topSuggestion.label}
{topSuggestion.confidence >= 0.8 && (
<Badge variant="secondary" className="ml-1.5 text-[10px] px-1 py-0">
{Math.round(topSuggestion.confidence * 100)}%
</Badge>
)}
</Button>
) : null}
{/* Secondary suggestions (up to 1 more) */}
{!hasInvoiceMatch && suggestions && suggestions.length > 1 && (
<Button
size="sm"
variant="outline"
className="h-8 text-xs"
onClick={() => handleSuggestionClick(suggestions[1])}
disabled={isProcessing || isDisabled}
>
{suggestions[1].label}
</Button>
)}
{/* Private button */}
<Button
size="sm"
variant="ghost"
className="h-8 text-xs text-muted-foreground"
onClick={() => onMarkPrivate(transaction.id)}
disabled={isProcessing || isDisabled}
>
Privat
</Button>
{/* More options */}
<Button
size="sm"
variant="ghost"
className="h-8 w-8 p-0 text-muted-foreground"
onClick={() => onOpenCategoryDialog(transaction)}
disabled={isProcessing || isDisabled}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</div>
)}
</CardContent>
</Card>
</motion.div>
)
}
@@ -0,0 +1,118 @@
'use client'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import Link from 'next/link'
import { Upload, Sparkles, Plus, CheckSquare, FileText } from 'lucide-react'
import type { ViewMode } from './transaction-types'
interface TransactionStatusBarProps {
uncategorizedCount: number
invoiceMatchCount: number
mode: ViewMode
onModeChange: (mode: ViewMode) => void
onOpenSwipeView: () => void
onOpenCreateDialog: () => void
isLoadingSuggestions: boolean
isBatchMode: boolean
onToggleBatchMode: () => void
}
export default function TransactionStatusBar({
uncategorizedCount,
invoiceMatchCount,
mode,
onModeChange,
onOpenSwipeView,
onOpenCreateDialog,
isLoadingSuggestions,
isBatchMode,
onToggleBatchMode,
}: TransactionStatusBarProps) {
return (
<div className="space-y-4">
{/* Header with title + actions */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold tracking-tight">Transaktioner</h1>
{uncategorizedCount > 0 && mode === 'inbox' && (
<p className="text-muted-foreground mt-1">
<span className="text-foreground font-semibold">{uncategorizedCount}</span> att bokföra
{invoiceMatchCount > 0 && (
<span className="ml-2">
· <FileText className="inline h-3.5 w-3.5 text-blue-500" />{' '}
<span className="text-blue-600">{invoiceMatchCount} fakturamatchningar</span>
</span>
)}
</p>
)}
{mode === 'history' && (
<p className="text-muted-foreground">Alla dina transaktioner</p>
)}
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" asChild>
<Link href="/import">
<Upload className="mr-2 h-4 w-4" />
Importera
</Link>
</Button>
{mode === 'inbox' && uncategorizedCount > 0 && (
<>
<Button
variant="outline"
size="sm"
onClick={onOpenSwipeView}
disabled={isLoadingSuggestions}
>
<Sparkles className="mr-2 h-4 w-4" />
{isLoadingSuggestions ? 'Laddar...' : 'Bokför alla'}
</Button>
<Button
variant={isBatchMode ? 'default' : 'outline'}
size="sm"
onClick={onToggleBatchMode}
>
<CheckSquare className="mr-2 h-4 w-4" />
{isBatchMode ? 'Avsluta' : 'Välj flera'}
</Button>
</>
)}
<Button size="sm" onClick={onOpenCreateDialog}>
<Plus className="mr-2 h-4 w-4" />
Ny transaktion
</Button>
</div>
</div>
{/* Mode toggle - segmented control style */}
<div className="inline-flex rounded-lg border bg-muted p-1">
<Button
variant={mode === 'inbox' ? 'default' : 'ghost'}
size="sm"
className="h-8 rounded-md"
onClick={() => onModeChange('inbox')}
>
Att bokföra
{uncategorizedCount > 0 && (
<Badge
variant={mode === 'inbox' ? 'secondary' : 'outline'}
className="ml-2 text-xs"
>
{uncategorizedCount}
</Badge>
)}
</Button>
<Button
variant={mode === 'history' ? 'default' : 'ghost'}
size="sm"
className="h-8 rounded-md"
onClick={() => onModeChange('history')}
>
Alla transaktioner
</Button>
</div>
</div>
)
}
@@ -0,0 +1,61 @@
import type { Transaction, TransactionCategory, Invoice, Customer, VatTreatment } from '@/types'
// Shared transaction type with potential invoice data
export interface TransactionWithInvoice extends Transaction {
potential_invoice?: Invoice & { customer?: Customer }
}
// Page view modes
export type ViewMode = 'inbox' | 'history'
export type HistoryFilter = 'all' | 'business' | 'private'
// Handler types
export type CategorizeHandler = (
id: string,
isBusiness: boolean,
category?: TransactionCategory,
vatTreatment?: VatTreatment,
accountOverride?: string
) => Promise<boolean>
export type MatchInvoiceHandler = (
transactionId: string,
invoiceId: string
) => Promise<boolean>
// Category option type
export interface CategoryOption {
value: TransactionCategory
label: string
}
// Shared category arrays
export const EXPENSE_CATEGORIES: CategoryOption[] = [
{ 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' },
]
export const INCOME_CATEGORIES: CategoryOption[] = [
{ value: 'income_services', label: 'Tjänster' },
{ value: 'income_products', label: 'Produkter' },
{ value: 'income_other', label: 'Övrigt' },
]
export const VAT_TREATMENT_OPTIONS: { value: VatTreatment | 'none'; label: string }[] = [
{ value: 'standard_25', label: 'Moms 25%' },
{ value: 'reduced_12', label: 'Moms 12%' },
{ value: 'reduced_6', label: 'Moms 6%' },
{ value: 'reverse_charge', label: 'Omvänd skattskyldighet' },
{ value: 'export', label: 'Export' },
{ value: 'exempt', label: 'Momsfri' },
{ value: 'none', label: 'Ingen moms' },
]