Merge remote-tracking branch 'origin/main' into fixing-extensions-bug
This commit is contained in:
@@ -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' },
|
||||
]
|
||||
Reference in New Issue
Block a user