feat: add 4 new Swedish bank CSV parsers and improve transaction categorization
Add auto-detecting CSV parsers for Länsförsäkringar, ICA Banken, Skandia, and Lunar. Refine SEB detection to avoid false matches. Update bank file upload UI with new bank options and export instructions. Include booking templates, improved AI categorization, and transaction review enhancements. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
431f385b4b
commit
bbb82866ee
@@ -27,6 +27,10 @@ const FORMAT_NAMES: Record<string, string> = {
|
||||
seb: 'SEB',
|
||||
swedbank: 'Swedbank',
|
||||
handelsbanken: 'Handelsbanken',
|
||||
lansforsakringar: 'Länsförsäkringar',
|
||||
ica_banken: 'ICA Banken',
|
||||
skandia: 'Skandia',
|
||||
lunar: 'Lunar',
|
||||
generic_csv: 'CSV (manuell mappning)',
|
||||
camt053: 'ISO 20022 camt.053',
|
||||
}
|
||||
@@ -122,6 +126,10 @@ export default function BankFileUploadStep({
|
||||
<SelectItem value="seb">SEB</SelectItem>
|
||||
<SelectItem value="swedbank">Swedbank</SelectItem>
|
||||
<SelectItem value="handelsbanken">Handelsbanken</SelectItem>
|
||||
<SelectItem value="lansforsakringar">Länsförsäkringar</SelectItem>
|
||||
<SelectItem value="ica_banken">ICA Banken</SelectItem>
|
||||
<SelectItem value="skandia">Skandia</SelectItem>
|
||||
<SelectItem value="lunar">Lunar</SelectItem>
|
||||
<SelectItem value="camt053">ISO 20022 camt.053 (XML)</SelectItem>
|
||||
<SelectItem value="generic_csv">Annan CSV (manuell mappning)</SelectItem>
|
||||
</SelectContent>
|
||||
@@ -229,6 +237,30 @@ export default function BankFileUploadStep({
|
||||
Logga in → Konton → Transaktioner → Ladda ner (CSV)
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Länsförsäkringar</p>
|
||||
<p className="text-muted-foreground">
|
||||
Logga in → Konton → Kontoutdrag → Exportera (CSV)
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">ICA Banken</p>
|
||||
<p className="text-muted-foreground">
|
||||
Logga in → Konton → Transaktioner → Exportera till fil (CSV)
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Skandia</p>
|
||||
<p className="text-muted-foreground">
|
||||
Logga in → Konton → Transaktioner → Exportera (CSV)
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Lunar</p>
|
||||
<p className="text-muted-foreground">
|
||||
Logga in → Konto → Transaktioner → Exportera (CSV)
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 { Paperclip } from 'lucide-react'
|
||||
import VatTreatmentSelect from './VatTreatmentSelect'
|
||||
import { EXPENSE_CATEGORIES, INCOME_CATEGORIES } from './transaction-types'
|
||||
import type { TransactionCategory, VatTreatment } from '@/types'
|
||||
@@ -59,6 +60,14 @@ export default function BatchCategorySelector({
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 py-2">
|
||||
{/* Underlag reminder */}
|
||||
<div className="flex items-start gap-2 rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 p-3">
|
||||
<Paperclip className="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
|
||||
<p className="text-xs text-amber-800 dark:text-amber-300">
|
||||
Underlag behover bifogas separat for varje transaktion efter bokforing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-muted-foreground mb-2">Momsbehandling</h4>
|
||||
<VatTreatmentSelect
|
||||
|
||||
@@ -4,10 +4,13 @@ import { useState, useEffect, useCallback } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
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, Check } from 'lucide-react'
|
||||
import { ArrowUpRight, ArrowDownRight, Check, Paperclip, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { getDefaultAccountForCategory } from '@/lib/bookkeeping/category-mapping'
|
||||
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
|
||||
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
|
||||
import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
|
||||
import VatTreatmentSelect from './VatTreatmentSelect'
|
||||
import type { TransactionWithInvoice } from './transaction-types'
|
||||
import type { TransactionCategory, VatTreatment, BASAccount } from '@/types'
|
||||
@@ -25,7 +28,7 @@ interface QuickReviewDialogProps {
|
||||
category: TransactionCategory,
|
||||
vatTreatment: VatTreatment | undefined,
|
||||
accountOverride: string | undefined
|
||||
) => Promise<void>
|
||||
) => Promise<string | null>
|
||||
}
|
||||
|
||||
export default function QuickReviewDialog({
|
||||
@@ -38,11 +41,14 @@ export default function QuickReviewDialog({
|
||||
defaultVat,
|
||||
onConfirm,
|
||||
}: QuickReviewDialogProps) {
|
||||
const { toast } = useToast()
|
||||
const [accountOverride, setAccountOverride] = useState(defaultAccount)
|
||||
const [vatTreatment, setVatTreatment] = useState<VatTreatment | 'none'>(defaultVat)
|
||||
const [accounts, setAccounts] = useState<BASAccount[]>([])
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
|
||||
const [showUploadZone, setShowUploadZone] = useState(false)
|
||||
|
||||
// Handle account changes — clear VAT for liability/equity accounts (class 2)
|
||||
const handleAccountChange = useCallback((account: string) => {
|
||||
@@ -85,7 +91,34 @@ export default function QuickReviewDialog({
|
||||
? accountOverride
|
||||
: undefined
|
||||
|
||||
await onConfirm(transaction.id, category, resolvedVat, override)
|
||||
const journalEntryId = await onConfirm(transaction.id, category, resolvedVat, override)
|
||||
|
||||
// Link uploaded documents to the journal entry
|
||||
if (journalEntryId && uploadedFiles.length > 0) {
|
||||
const filesToLink = uploadedFiles.filter((f) => f.status === 'uploaded' && f.id)
|
||||
let linkFailCount = 0
|
||||
for (const file of filesToLink) {
|
||||
try {
|
||||
await fetch(`/api/documents/${file.id}/link`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ journal_entry_id: journalEntryId }),
|
||||
})
|
||||
} catch {
|
||||
linkFailCount++
|
||||
}
|
||||
}
|
||||
if (linkFailCount > 0) {
|
||||
toast({
|
||||
title: 'Underlag kunde inte bifogas',
|
||||
description: `${linkFailCount} fil(er) kunde inte lankas till verifikationen.`,
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
setUploadedFiles([])
|
||||
setShowUploadZone(false)
|
||||
} catch {
|
||||
setError('Ett fel uppstod vid bokföring.')
|
||||
setIsProcessing(false)
|
||||
@@ -93,7 +126,13 @@ export default function QuickReviewDialog({
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={isProcessing ? undefined : onOpenChange}>
|
||||
<Dialog open={open} onOpenChange={isProcessing ? undefined : (o) => {
|
||||
if (!o) {
|
||||
setUploadedFiles([])
|
||||
setShowUploadZone(false)
|
||||
}
|
||||
onOpenChange(o)
|
||||
}}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Granska bokföring</DialogTitle>
|
||||
@@ -164,6 +203,39 @@ export default function QuickReviewDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Document upload */}
|
||||
<div className="rounded-lg border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowUploadZone(!showUploadZone)}
|
||||
className="flex items-center justify-between w-full px-3 py-2.5 text-sm hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Paperclip className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">Underlag</span>
|
||||
{uploadedFiles.filter((f) => f.status === 'uploaded').length > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{uploadedFiles.filter((f) => f.status === 'uploaded').length} bifogade
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{showUploadZone ? (
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
{showUploadZone && (
|
||||
<div className="px-3 pb-3">
|
||||
<DocumentUploadZone
|
||||
files={uploadedFiles}
|
||||
onFilesChange={setUploadedFiles}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
|
||||
{error}
|
||||
|
||||
@@ -5,12 +5,15 @@ import { motion, useMotionValue, useTransform, AnimatePresence, type PanInfo } f
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
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 AccountCombobox from '@/components/bookkeeping/AccountCombobox'
|
||||
import { X, ArrowLeft, ArrowRight, Building, AlertTriangle, Check, FileText, Link2, Receipt as ReceiptIcon, SkipForward } from 'lucide-react'
|
||||
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 type { TransactionCategory, VatTreatment, BASAccount } from '@/types'
|
||||
import type { SuggestedCategory } from '@/lib/transactions/category-suggestions'
|
||||
import type { TransactionWithInvoice, CategorizeHandler, MatchInvoiceHandler } from './transaction-types'
|
||||
@@ -34,6 +37,7 @@ export default function SwipeCategorizationView({
|
||||
onMatchInvoice,
|
||||
onClose,
|
||||
}: SwipeCategorizationViewProps) {
|
||||
const { toast } = useToast()
|
||||
const [showAllCategories, setShowAllCategories] = useState(false)
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [showCategorySelect, setShowCategorySelect] = useState(false)
|
||||
@@ -46,6 +50,8 @@ export default function SwipeCategorizationView({
|
||||
const [accountOverride, setAccountOverride] = useState('')
|
||||
const [vatTreatment, setVatTreatment] = useState<VatTreatment | 'none'>('standard_25')
|
||||
const [accounts, setAccounts] = useState<BASAccount[]>([])
|
||||
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
|
||||
const [showUploadZone, setShowUploadZone] = useState(false)
|
||||
|
||||
// Clear VAT treatment when switching to a liability/equity account (class 2)
|
||||
useEffect(() => {
|
||||
@@ -143,6 +149,11 @@ export default function SwipeCategorizationView({
|
||||
[isProcessing, currentTransaction, handleCategorySelect, x, moveToNext]
|
||||
)
|
||||
|
||||
const resetUploadState = useCallback(() => {
|
||||
setUploadedFiles([])
|
||||
setShowUploadZone(false)
|
||||
}, [])
|
||||
|
||||
const handleReviewConfirm = async () => {
|
||||
if (!pendingCategory) return
|
||||
|
||||
@@ -156,14 +167,39 @@ export default function SwipeCategorizationView({
|
||||
? accountOverride
|
||||
: undefined
|
||||
|
||||
const success = await onCategorize(
|
||||
const journalEntryId = await onCategorize(
|
||||
currentTransaction.id,
|
||||
true,
|
||||
pendingCategory,
|
||||
resolvedVat,
|
||||
override
|
||||
)
|
||||
if (success) {
|
||||
if (journalEntryId) {
|
||||
// Link uploaded documents to the journal entry
|
||||
if (uploadedFiles.length > 0) {
|
||||
const filesToLink = uploadedFiles.filter((f) => f.status === 'uploaded' && f.id)
|
||||
let linkFailCount = 0
|
||||
for (const file of filesToLink) {
|
||||
try {
|
||||
await fetch(`/api/documents/${file.id}/link`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ journal_entry_id: journalEntryId }),
|
||||
})
|
||||
} catch {
|
||||
linkFailCount++
|
||||
}
|
||||
}
|
||||
if (linkFailCount > 0) {
|
||||
toast({
|
||||
title: 'Underlag kunde inte bifogas',
|
||||
description: `${linkFailCount} fil(er) kunde inte lankas till verifikationen.`,
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
resetUploadState()
|
||||
setShowReviewStep(false)
|
||||
setPendingCategory(null)
|
||||
moveToNext()
|
||||
@@ -204,8 +240,9 @@ export default function SwipeCategorizationView({
|
||||
setShowCategorySelect(false)
|
||||
setShowReviewStep(false)
|
||||
setPendingCategory(null)
|
||||
resetUploadState()
|
||||
moveToNext()
|
||||
}, [moveToNext])
|
||||
}, [moveToNext, resetUploadState])
|
||||
|
||||
if (!currentTransaction) {
|
||||
return (
|
||||
@@ -352,12 +389,45 @@ export default function SwipeCategorizationView({
|
||||
/>
|
||||
{isLiabilityAccount && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Ingen moms för skuld-/eget kapital-konton
|
||||
Ingen moms for skuld-/eget kapital-konton
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Document upload */}
|
||||
<div className="rounded-lg border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowUploadZone(!showUploadZone)}
|
||||
className="flex items-center justify-between w-full px-3 py-2.5 text-sm hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Paperclip className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">Underlag</span>
|
||||
{uploadedFiles.filter((f) => f.status === 'uploaded').length > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{uploadedFiles.filter((f) => f.status === 'uploaded').length} bifogade
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{showUploadZone ? (
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
{showUploadZone && (
|
||||
<div className="px-3 pb-3">
|
||||
<DocumentUploadZone
|
||||
files={uploadedFiles}
|
||||
onFilesChange={setUploadedFiles}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
|
||||
{error}
|
||||
@@ -373,7 +443,7 @@ export default function SwipeCategorizationView({
|
||||
disabled={isProcessing || !accountOverride}
|
||||
>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
{isProcessing ? 'Bokför...' : 'Bokför'}
|
||||
{isProcessing ? 'Bokfor...' : 'Bokfor'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -10,13 +10,14 @@ export type ViewMode = 'inbox' | 'history'
|
||||
export type HistoryFilter = 'all' | 'business' | 'private'
|
||||
|
||||
// Handler types
|
||||
// Returns the journal_entry_id on success, null on failure
|
||||
export type CategorizeHandler = (
|
||||
id: string,
|
||||
isBusiness: boolean,
|
||||
category?: TransactionCategory,
|
||||
vatTreatment?: VatTreatment,
|
||||
accountOverride?: string
|
||||
) => Promise<boolean>
|
||||
) => Promise<string | null>
|
||||
|
||||
export type MatchInvoiceHandler = (
|
||||
transactionId: string,
|
||||
|
||||
Reference in New Issue
Block a user