feat: BAS data overhaul, currency revaluation, expenses, UI polish, and cleanup

- Update BAS account catalog with comprehensive SRU codes and K2 flags
- Add currency revaluation service with tests and API route
- Add expenses page and account deletion API
- Enhance booking templates with new patterns and improved tests
- Improve transaction categorization with template picker and description matching
- Polish dashboard, onboarding, import, and transaction UIs
- Refactor year-end service for multi-step closing
- Move SRU generator to ne-bilaga, remove standalone SRU export
- Remove unused dev docs, mock data, and extension hooks
- Add invoice delivery note sequences migration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-03-04 14:19:56 +01:00
co-authored by Claude Opus 4.6
parent a9bf24ce0b
commit 66a4027f1e
104 changed files with 4964 additions and 5501 deletions
@@ -64,7 +64,7 @@ export default function BatchCategorySelector({
<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.
Underlag behöver bifogas separat för varje transaktion efter bokföring.
</p>
</div>
@@ -82,26 +82,26 @@ function getExamplePrompts(transaction: TransactionWithInvoice): string[] {
const isExpense = transaction.amount < 0
if (!isExpense) {
return ['Konsultarvode', 'Forsaljning av varor', 'Aterbetalning']
return ['Konsultarvode', 'Försäljning av varor', 'Återbetalning']
}
if (desc.includes('restaurang') || desc.includes('lunch') || desc.includes('middag') || desc.includes('mat')) {
return ['Lunch med kund', 'Personalmiddag', 'Fika till kontoret']
}
if (desc.includes('hotel') || desc.includes('hotell') || desc.includes('boende') || desc.includes('resa')) {
return ['Tjansteresa', 'Hotell konferens', 'Flygbiljett']
return ['Tjänsteresa', 'Hotell konferens', 'Flygbiljett']
}
if (desc.includes('uber') || desc.includes('taxi') || desc.includes('bolt') || desc.includes('sj ')) {
return ['Taxi till kund', 'Tjansteresa', 'Pendling']
return ['Taxi till kund', 'Tjänsteresa', 'Pendling']
}
if (desc.includes('google') || desc.includes('meta') || desc.includes('facebook') || desc.includes('linkedin')) {
return ['Online-annonsering', 'SaaS-prenumeration', 'Marknadsforingskampanj']
return ['Online-annonsering', 'SaaS-prenumeration', 'Marknadsföringskampanj']
}
if (desc.includes('amazon') || desc.includes('aws') || desc.includes('azure') || desc.includes('cloud')) {
return ['Serverhosting', 'SaaS-prenumeration', 'Kontorsmaterial']
}
return ['Kontorsmaterial', 'SaaS-prenumeration', 'Konsulttjanst', 'Reklam']
return ['Kontorsmaterial', 'SaaS-prenumeration', 'Konsulttjänst', 'Reklam']
}
function getVatRateFromTreatment(treatment: string | null): number {
@@ -163,7 +163,7 @@ export default function DescribeTransactionDialog({
if (!response.ok) {
toast({
title: 'Fel',
description: result.error || 'Kunde inte soka mallar',
description: result.error || 'Kunde inte söka mallar',
variant: 'destructive',
})
setIsSearching(false)
@@ -176,7 +176,7 @@ export default function DescribeTransactionDialog({
} catch {
toast({
title: 'Fel',
description: 'Nagot gick fel vid sokning',
description: 'Något gick fel vid sökning',
variant: 'destructive',
})
}
@@ -227,7 +227,7 @@ export default function DescribeTransactionDialog({
if (!response.ok) {
toast({
title: 'Fel',
description: result.error || 'Kunde inte bokfora transaktion',
description: result.error || 'Kunde inte bokföra transaktion',
variant: 'destructive',
})
setIsBooking(false)
@@ -239,14 +239,14 @@ export default function DescribeTransactionDialog({
setIsBooking(false)
onCategorized(transaction.id, result.journal_entry_id || null)
} else {
toast({ title: 'Bokford', description: 'Transaktion bokford och verifikation skapad' })
toast({ title: 'Bokförd', description: 'Transaktion bokförd och verifikation skapad' })
onCategorized(transaction.id, result.journal_entry_id || null)
handleOpenChange(false)
}
} catch {
toast({
title: 'Fel',
description: 'Nagot gick fel vid bokforing',
description: 'Något gick fel vid bokföring',
variant: 'destructive',
})
setIsBooking(false)
@@ -282,7 +282,7 @@ export default function DescribeTransactionDialog({
if (!response.ok) {
toast({
title: 'Fel',
description: result.error || 'Kunde inte bokfora batch',
description: result.error || 'Kunde inte bokföra batch',
variant: 'destructive',
})
setIsBatchApplying(false)
@@ -300,7 +300,7 @@ export default function DescribeTransactionDialog({
} else {
toast({
title: 'Klart',
description: `${applied} transaktioner bokforda`,
description: `${applied} transaktioner bokförda`,
})
}
onBatchApplied?.(applied)
@@ -308,7 +308,7 @@ export default function DescribeTransactionDialog({
} catch {
toast({
title: 'Fel',
description: 'Nagot gick fel vid batchbokforing',
description: 'Något gick fel vid batchbokföring',
variant: 'destructive',
})
setIsBatchApplying(false)
@@ -316,7 +316,7 @@ export default function DescribeTransactionDialog({
}
function handleSkipBatch() {
toast({ title: 'Bokford', description: 'Transaktion bokford och verifikation skapad' })
toast({ title: 'Bokförd', description: 'Transaktion bokförd och verifikation skapad' })
handleOpenChange(false)
}
@@ -337,13 +337,13 @@ export default function DescribeTransactionDialog({
<DialogHeader>
<DialogTitle>
{step === 'describe' && 'Beskriv transaktion'}
{step === 'pick' && 'Valj mall'}
{step === 'batch' && 'Bokfor liknande'}
{step === 'pick' && 'Välj mall'}
{step === 'batch' && 'Bokför liknande'}
</DialogTitle>
<DialogDescription>
{step === 'describe' && 'Beskriv vad transaktionen galler sa hittar vi ratt bokforingsmall'}
{step === 'pick' && 'Valj den mall som stammer bast'}
{step === 'batch' && 'Transaktion bokford!'}
{step === 'describe' && 'Beskriv vad transaktionen gäller så hittar vi rätt bokföringsmall'}
{step === 'pick' && 'Välj den mall som stämmer bäst'}
{step === 'batch' && 'Transaktion bokförd!'}
</DialogDescription>
</DialogHeader>
@@ -351,11 +351,7 @@ export default function DescribeTransactionDialog({
{(step === 'describe' || step === 'pick') && (
<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'
}`}
className="h-9 w-9 rounded-full flex items-center justify-center flex-shrink-0 bg-muted text-muted-foreground"
>
{isIncome ? (
<ArrowUpRight className="h-4 w-4" />
@@ -367,7 +363,7 @@ export default function DescribeTransactionDialog({
<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' : ''}`}>
<p className="font-medium text-sm flex-shrink-0">
{isIncome ? '+' : ''}
{formatCurrency(transaction.amount, transaction.currency)}
</p>
@@ -378,7 +374,7 @@ export default function DescribeTransactionDialog({
{step === 'describe' && (
<div className="space-y-4">
<Textarea
placeholder="Beskriv vad transaktionen galler, t.ex. 'lunch med kund' eller 'kontorsmaterial'"
placeholder="Beskriv vad transaktionen gäller, t.ex. 'lunch med kund' eller 'kontorsmaterial'"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
@@ -412,7 +408,7 @@ export default function DescribeTransactionDialog({
) : (
<Search className="mr-2 h-4 w-4" />
)}
{isSearching ? 'Soker...' : 'Sok'}
{isSearching ? 'Söker...' : 'Sök'}
</Button>
</div>
)}
@@ -423,7 +419,7 @@ export default function DescribeTransactionDialog({
{describeResult.needs_more_detail && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-amber-500/10 text-amber-700 dark:text-amber-400 text-sm">
<AlertTriangle className="h-4 w-4 flex-shrink-0 mt-0.5" />
<p>Resultaten ar osakra. Forsok beskriv mer detaljerat for battre traffar.</p>
<p>Resultaten är osäkra. Försök beskriv mer detaljerat för bättre träffar.</p>
</div>
)}
@@ -442,7 +438,7 @@ export default function DescribeTransactionDialog({
<div className="flex items-center gap-1.5 mb-1">
<Sparkles className="h-3.5 w-3.5 text-violet-500" />
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300">
AI-forslag
AI-förslag
</Badge>
</div>
<p className="text-sm text-muted-foreground mt-1">
@@ -495,7 +491,7 @@ export default function DescribeTransactionDialog({
{/* Template cards */}
{describeResult.templates.length === 0 && !aiSuggestion ? (
<p className="text-sm text-muted-foreground text-center py-4">
Inga matchande mallar hittades. Forsok med en annan beskrivning.
Inga matchande mallar hittades. Försök med en annan beskrivning.
</p>
) : (
describeResult.templates.map((template) => (
@@ -515,7 +511,7 @@ export default function DescribeTransactionDialog({
<p className="font-medium text-sm">{template.name_sv}</p>
{aiAgreesWithTop && template.template_id === topTemplate.template_id && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">
AI bekraftar
AI bekräftar
</Badge>
)}
</div>
@@ -636,7 +632,7 @@ export default function DescribeTransactionDialog({
) : (
<Check className="mr-2 h-4 w-4" />
)}
{isBooking ? 'Bokfor...' : 'Bokfor'}
{isBooking ? 'Bokför...' : 'Bokför'}
</Button>
</div>
</div>
@@ -647,7 +643,7 @@ export default function DescribeTransactionDialog({
<div className="space-y-4">
<div className="flex items-center gap-3 p-4 rounded-lg bg-success/10">
<CheckCircle2 className="h-6 w-6 text-success flex-shrink-0" />
<p className="text-sm font-medium">Transaktionen ar bokford!</p>
<p className="text-sm font-medium">Transaktionen är bokförd!</p>
</div>
{canBatchApply && (
@@ -656,11 +652,11 @@ export default function DescribeTransactionDialog({
<span className="font-medium text-foreground">
{describeResult.batch_candidate_count}
</span>{' '}
obokforda transaktioner fran{' '}
obokförda transaktioner från{' '}
<span className="font-medium text-foreground">
{describeResult.merchant_name}
</span>
. Anvand samma mall?
. Använd samma mall?
</p>
)}
@@ -671,7 +667,7 @@ export default function DescribeTransactionDialog({
onClick={handleSkipBatch}
disabled={isBatchApplying}
>
{canBatchApply ? 'Nej, bara den har' : 'Stang'}
{canBatchApply ? 'Nej, bara den här' : 'Stäng'}
</Button>
{canBatchApply && (
<Button
@@ -683,8 +679,8 @@ export default function DescribeTransactionDialog({
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
{isBatchApplying
? 'Bokfor...'
: `Ja, bokfor alla ${describeResult.batch_candidate_count} st`}
? 'Bokför...'
: `Ja, bokför alla ${describeResult.batch_candidate_count} st`}
</Button>
)}
</div>
+60 -9
View File
@@ -6,8 +6,9 @@ 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, Paperclip, ChevronDown, ChevronUp } from 'lucide-react'
import { ArrowUpRight, ArrowDownRight, Check, Paperclip, ChevronDown, ChevronUp, AlertTriangle } from 'lucide-react'
import { getDefaultAccountForCategory } from '@/lib/bookkeeping/category-mapping'
import type { BookingTemplate } from '@/lib/bookkeeping/booking-templates'
import JournalEntryPreview from './JournalEntryPreview'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
@@ -26,12 +27,16 @@ interface QuickReviewDialogProps {
defaultAccount: string
defaultVat: VatTreatment | 'none'
entityType?: EntityType
template?: BookingTemplate | null
templateId?: string
onConfirm: (
id: string,
category: TransactionCategory,
vatTreatment: VatTreatment | undefined,
accountOverride: string | undefined
accountOverride: string | undefined,
templateId?: string
) => Promise<string | null>
onChangeTemplate?: () => void
}
export default function QuickReviewDialog({
@@ -43,7 +48,10 @@ export default function QuickReviewDialog({
defaultAccount,
defaultVat,
entityType,
template,
templateId,
onConfirm,
onChangeTemplate,
}: QuickReviewDialogProps) {
const { toast } = useToast()
const [accountOverride, setAccountOverride] = useState(defaultAccount)
@@ -96,7 +104,7 @@ export default function QuickReviewDialog({
? accountOverride
: undefined
const journalEntryId = await onConfirm(transaction.id, category, resolvedVat, override)
const journalEntryId = await onConfirm(transaction.id, category, resolvedVat, override, templateId)
// Link uploaded documents to the journal entry
if (journalEntryId && uploadedFiles.length > 0) {
@@ -116,7 +124,7 @@ export default function QuickReviewDialog({
if (linkFailCount > 0) {
toast({
title: 'Underlag kunde inte bifogas',
description: `${linkFailCount} fil(er) kunde inte lankas till verifikationen.`,
description: `${linkFailCount} fil(er) kunde inte länkas till verifikationen.`,
variant: 'destructive',
})
}
@@ -171,14 +179,57 @@ export default function QuickReviewDialog({
</p>
</div>
{/* Category (read-only) */}
{/* Template or 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>
<label className="text-sm font-medium text-muted-foreground">
{template ? 'Mall' : 'Kategori'}
</label>
<div className="mt-1 flex items-center gap-2">
<Badge variant="outline" className="text-sm py-1 px-3">
{template ? template.name_sv : categoryLabel}
</Badge>
{onChangeTemplate && (
<button
type="button"
className="text-xs text-primary hover:underline"
onClick={onChangeTemplate}
>
Byt mall
</button>
)}
</div>
</div>
{/* Template special rules */}
{template?.special_rules_sv && (
<div className="rounded-lg border border-amber-300/50 bg-amber-50/50 dark:bg-amber-950/20 px-3 py-2">
<p className="text-xs text-amber-800 dark:text-amber-300 leading-snug">
{template.special_rules_sv}
</p>
</div>
)}
{/* Deductibility note */}
{template?.deductibility_note_sv && (
<div className="rounded-lg border border-blue-300/50 bg-blue-50/50 dark:bg-blue-950/20 px-3 py-2">
<p className="text-xs text-blue-800 dark:text-blue-300 leading-snug">
{template.deductibility_note_sv}
</p>
</div>
)}
{/* Reverse charge warning */}
{template?.requires_vat_registration_data && (
<div className="rounded-lg border border-amber-400/50 bg-amber-50/50 dark:bg-amber-950/20 px-3 py-2">
<div className="flex items-start gap-2">
<AlertTriangle className="h-3.5 w-3.5 text-amber-600 flex-shrink-0 mt-0.5" />
<p className="text-xs text-amber-800 dark:text-amber-300 leading-snug">
Omvänd skattskyldighet kräver leverantörens momsregistreringsnummer och land.
</p>
</div>
</div>
)}
{/* Journal entry preview */}
<JournalEntryPreview
amount={transaction.amount}
@@ -207,7 +258,7 @@ export default function QuickReviewDialog({
<div className="mt-1">
{isLiabilityAccount ? (
<p className="text-sm text-muted-foreground">
Ingen moms for skuld-/eget kapital-konton
Ingen moms för skuld-/eget kapital-konton
</p>
) : showVatDropdown ? (
<VatTreatmentSelect
@@ -10,7 +10,8 @@ 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 { getTemplateById } from '@/lib/bookkeeping/booking-templates'
import { getTemplateById, type BookingTemplate } from '@/lib/bookkeeping/booking-templates'
import TemplatePicker from './TemplatePicker'
import JournalEntryPreview from './JournalEntryPreview'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
@@ -47,7 +48,6 @@ export default function SwipeCategorizationView({
entityType,
}: SwipeCategorizationViewProps) {
const { toast } = useToast()
const [showAllCategories, setShowAllCategories] = useState(false)
const [currentIndex, setCurrentIndex] = useState(0)
const [showCategorySelect, setShowCategorySelect] = useState(false)
const [isProcessing, setIsProcessing] = useState(false)
@@ -134,6 +134,18 @@ export default function SwipeCategorizationView({
setError(null)
}, [])
const handlePickerTemplateSelect = useCallback((template: BookingTemplate) => {
setPendingCategory(template.fallback_category)
setAccountOverride(template.debit_account)
setVatTreatment(template.vat_treatment ?? 'none')
setPendingTemplateId(template.id)
setPendingInboxItemId(null)
setShowVatDropdown(false)
setShowCategorySelect(false)
setShowReviewStep(true)
setError(null)
}, [])
const handleTemplateSelect = useCallback((templateId: string, inboxItemId?: string) => {
const template = getTemplateById(templateId)
if (!template) return
@@ -226,7 +238,7 @@ export default function SwipeCategorizationView({
if (linkFailCount > 0) {
toast({
title: 'Underlag kunde inte bifogas',
description: `${linkFailCount} fil(er) kunde inte lankas till verifikationen.`,
description: `${linkFailCount} fil(er) kunde inte länkas till verifikationen.`,
variant: 'destructive',
})
}
@@ -301,8 +313,8 @@ export default function SwipeCategorizationView({
}
if (showCategorySelect) {
const categories =
currentTransaction.amount > 0 ? incomeCategories : expenseCategories
const direction = currentTransaction.amount > 0 ? 'income' : 'expense'
const txSuggestions = templateSuggestions?.[currentTransaction.id]
return (
<div className="fixed inset-0 bg-background z-50 flex flex-col">
@@ -310,46 +322,39 @@ export default function SwipeCategorizationView({
<Button variant="ghost" size="icon" onClick={() => setShowCategorySelect(false)}>
<ArrowLeft className="h-5 w-5" />
</Button>
<h1 className="font-semibold">Välj kategori</h1>
<h1 className="font-semibold">Välj mall</h1>
<div className="w-10" />
</div>
<div className="flex-1 overflow-auto p-4">
<Card className="mb-4">
<CardContent className="pt-4">
<p className="font-medium">{currentTransaction.description}</p>
<p className="text-2xl font-bold mt-2">
{formatCurrency(Math.abs(currentTransaction.amount), currentTransaction.currency)}
</p>
</CardContent>
</Card>
<Card className="mx-4 mt-3">
<CardContent className="pt-4">
<p className="font-medium">{currentTransaction.description}</p>
<p className="text-2xl font-bold mt-2">
{formatCurrency(Math.abs(currentTransaction.amount), currentTransaction.currency)}
</p>
</CardContent>
</Card>
{error && (
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm mb-4">
{error}
</div>
)}
<div className="space-y-2">
{categories.map((cat) => (
<Button
key={cat.value}
variant="outline"
className="w-full justify-start h-auto py-3"
onClick={() => handleCategorySelect(cat.value)}
disabled={isProcessing}
>
<div className="flex items-baseline gap-2">
<span>{cat.label}</span>
{cat.account && <span className="text-xs text-muted-foreground">{cat.account}</span>}
</div>
</Button>
))}
{error && (
<div className="mx-4 mt-2 p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
{error}
</div>
)}
<div className="flex-1 overflow-hidden mt-2">
<TemplatePicker
direction={direction}
entityType={entityType}
suggestedTemplates={txSuggestions}
onSelect={handlePickerTemplateSelect}
selectedTemplateId={pendingTemplateId ?? undefined}
/>
</div>
<div className="p-4 border-t">
<Button
variant="ghost"
className="w-full mt-4 text-muted-foreground"
className="w-full text-muted-foreground"
onClick={handleSkip}
>
<SkipForward className="mr-2 h-4 w-4" />
@@ -364,6 +369,7 @@ export default function SwipeCategorizationView({
const categoryLabel = [...expenseCategories, ...incomeCategories].find(
(c) => c.value === pendingCategory
)?.label || pendingCategory
const selectedTemplate = pendingTemplateId ? getTemplateById(pendingTemplateId) : null
// Auto-clear VAT when a class 2 (liability/equity) account is selected
const isLiabilityAccount = accountOverride.startsWith('2')
@@ -398,14 +404,58 @@ export default function SwipeCategorizationView({
</CardContent>
</Card>
{/* Selected category */}
{/* Selected template or 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>
<label className="text-sm font-medium text-muted-foreground">
{selectedTemplate ? 'Mall' : 'Kategori'}
</label>
<div className="mt-1 flex items-center gap-2">
<Badge variant="outline" className="text-sm py-1 px-3">
{selectedTemplate ? selectedTemplate.name_sv : categoryLabel}
</Badge>
<button
type="button"
className="text-xs text-primary hover:underline"
onClick={() => {
setShowReviewStep(false)
setShowCategorySelect(true)
}}
>
Byt mall
</button>
</div>
</div>
{/* Template special rules warning */}
{selectedTemplate?.special_rules_sv && (
<div className="rounded-lg border border-amber-300/50 bg-amber-50/50 dark:bg-amber-950/20 px-3 py-2.5">
<p className="text-xs text-amber-800 dark:text-amber-300 leading-snug">
{selectedTemplate.special_rules_sv}
</p>
</div>
)}
{/* Deductibility note */}
{selectedTemplate?.deductibility_note_sv && (
<div className="rounded-lg border border-blue-300/50 bg-blue-50/50 dark:bg-blue-950/20 px-3 py-2.5">
<p className="text-xs text-blue-800 dark:text-blue-300 leading-snug">
{selectedTemplate.deductibility_note_sv}
</p>
</div>
)}
{/* Reverse charge VAT registration warning */}
{selectedTemplate?.requires_vat_registration_data && (
<div className="rounded-lg border border-amber-400/50 bg-amber-50/50 dark:bg-amber-950/20 px-3 py-2.5">
<div className="flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-amber-600 flex-shrink-0 mt-0.5" />
<p className="text-xs text-amber-800 dark:text-amber-300 leading-snug">
Omvänd skattskyldighet kräver leverantörens momsregistreringsnummer och land.
</p>
</div>
</div>
)}
{/* Journal entry preview */}
<JournalEntryPreview
amount={currentTransaction.amount}
@@ -434,7 +484,7 @@ export default function SwipeCategorizationView({
<div className="mt-1">
{isLiabilityAccount ? (
<p className="text-sm text-muted-foreground">
Ingen moms for skuld-/eget kapital-konton
Ingen moms för skuld-/eget kapital-konton
</p>
) : showVatDropdown ? (
<VatTreatmentSelect
@@ -518,7 +568,7 @@ export default function SwipeCategorizationView({
disabled={isProcessing || !accountOverride}
>
<Check className="mr-2 h-4 w-4" />
{isProcessing ? 'Bokfor...' : 'Bokfor'}
{isProcessing ? 'Bokför...' : 'Bokför'}
</Button>
<Button
variant="ghost"
+329
View File
@@ -0,0 +1,329 @@
'use client'
import { useState, useMemo } from 'react'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Search, ChevronDown, ChevronUp, AlertTriangle, Info } from 'lucide-react'
import {
getCommonTemplates,
getAdvancedTemplates,
searchTemplates,
type BookingTemplate,
type TemplateGroup,
} from '@/lib/bookkeeping/booking-templates'
import type { EntityType } from '@/types'
import type { SuggestedTemplate } from '@/lib/transactions/category-suggestions'
const GROUP_ORDER: TemplateGroup[] = [
'premises', 'vehicle', 'it_software', 'office_supplies', 'marketing',
'travel', 'representation', 'insurance', 'professional_services',
'bank_finance', 'telecom', 'education', 'personnel', 'revenue',
'financial', 'private_transfers', 'equipment',
]
const GROUP_LABELS: Record<TemplateGroup, string> = {
premises: 'Lokalkostnader',
vehicle: 'Fordon',
it_software: 'IT & Programvara',
office_supplies: 'Kontorsmaterial',
marketing: 'Marknadsföring',
travel: 'Resor & Transport',
representation: 'Representation',
insurance: 'Försäkringar',
professional_services: 'Professionella tjänster',
bank_finance: 'Bank & Finans',
telecom: 'Telekom & Internet',
education: 'Utbildning',
personnel: 'Personal',
revenue: 'Intäkter',
financial: 'Finansiella poster',
private_transfers: 'Privata transaktioner',
equipment: 'Inventarier & Utrustning',
}
function getVatLabel(template: BookingTemplate): string | null {
if (!template.vat_treatment) return null
switch (template.vat_treatment) {
case 'standard_25': return '25% moms'
case 'reduced_12': return '12% moms'
case 'reduced_6': return '6% moms'
case 'reverse_charge': return 'Omvänd moms'
case 'export': return 'Momsfri (export)'
case 'exempt': return 'Momsfri'
default: return null
}
}
function groupTemplates(templates: BookingTemplate[]): Map<TemplateGroup, BookingTemplate[]> {
const grouped = new Map<TemplateGroup, BookingTemplate[]>()
for (const t of templates) {
const list = grouped.get(t.group) || []
list.push(t)
grouped.set(t.group, list)
}
return grouped
}
interface TemplateCardProps {
template: BookingTemplate
selected: boolean
onClick: () => void
compact?: boolean
}
function TemplateCard({ template, selected, onClick, compact }: TemplateCardProps) {
const vatLabel = getVatLabel(template)
return (
<button
type="button"
onClick={onClick}
className={`w-full text-left rounded-lg border px-3 py-2.5 transition-colors hover:bg-muted/50 ${
selected
? 'border-primary bg-primary/5 ring-1 ring-primary'
: 'border-border'
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<p className={`font-medium ${compact ? 'text-sm' : 'text-sm'} leading-tight`}>
{template.name_sv}
</p>
<div className="flex items-center gap-2 mt-1 flex-wrap">
<span className="text-xs font-mono text-muted-foreground">
D: {template.debit_account} &middot; K: {template.credit_account}
</span>
{vatLabel && (
<Badge
variant="secondary"
className={`text-[10px] px-1.5 py-0 ${
template.vat_treatment === 'reverse_charge'
? 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300'
: ''
}`}
>
{vatLabel}
</Badge>
)}
{template.requires_vat_registration_data && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 border-amber-400 text-amber-600 dark:text-amber-400 gap-0.5">
<AlertTriangle className="h-2.5 w-2.5" />
Kräver momsreg.nr
</Badge>
)}
</div>
</div>
{template.requires_review && (
<Info className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0 mt-0.5" />
)}
</div>
{template.special_rules_sv && !compact && (
<p className="text-[11px] text-muted-foreground mt-1.5 leading-snug">
{template.special_rules_sv}
</p>
)}
</button>
)
}
interface TemplatePickerProps {
direction: 'expense' | 'income'
entityType?: EntityType
suggestedTemplates?: SuggestedTemplate[]
recentTemplateIds?: string[]
onSelect: (template: BookingTemplate) => void
selectedTemplateId?: string
}
export default function TemplatePicker({
direction,
entityType,
suggestedTemplates,
onSelect,
selectedTemplateId,
}: TemplatePickerProps) {
const [searchQuery, setSearchQuery] = useState('')
const [showAdvanced, setShowAdvanced] = useState(false)
// Map direction to template direction filter (transfers show in both)
const templateDirection = direction === 'income' ? 'income' : 'expense'
const commonTemplates = useMemo(
() => getCommonTemplates(entityType, templateDirection),
[entityType, templateDirection]
)
const advancedTemplates = useMemo(
() => getAdvancedTemplates(entityType, templateDirection),
[entityType, templateDirection]
)
// Also include transfer templates in both directions
const commonTransfers = useMemo(
() => getCommonTemplates(entityType, 'transfer'),
[entityType]
)
const advancedTransfers = useMemo(
() => getAdvancedTemplates(entityType, 'transfer'),
[entityType]
)
const allCommon = useMemo(
() => [...commonTemplates, ...commonTransfers],
[commonTemplates, commonTransfers]
)
const allAdvanced = useMemo(
() => [...advancedTemplates, ...advancedTransfers],
[advancedTemplates, advancedTransfers]
)
// Search results
const searchResults = useMemo(() => {
if (!searchQuery.trim()) return null
return searchTemplates(searchQuery, entityType).filter((t) => {
if (t.direction === templateDirection || t.direction === 'transfer') return true
return false
})
}, [searchQuery, entityType, templateDirection])
// Group templates by group for display
const commonGrouped = useMemo(() => groupTemplates(allCommon), [allCommon])
const advancedGrouped = useMemo(() => groupTemplates(allAdvanced), [allAdvanced])
const handleSelect = (template: BookingTemplate) => {
onSelect(template)
}
// Suggested templates section
const hasSuggestions = suggestedTemplates && suggestedTemplates.length > 0
return (
<div className="flex flex-col h-full">
{/* Search bar */}
<div className="relative px-4 pt-3 pb-2">
<Search className="absolute left-7 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground mt-0.5" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Sök mall..."
className="pl-9 h-9"
/>
</div>
{/* Scrollable content */}
<div className="flex-1 overflow-auto px-4 pb-4 space-y-4">
{/* Search results */}
{searchResults !== null ? (
<div>
<p className="text-xs font-medium text-muted-foreground mb-2">
{searchResults.length === 0 ? 'Inga resultat' : `${searchResults.length} resultat`}
</p>
<div className="space-y-1.5">
{searchResults.map((t) => (
<TemplateCard
key={t.id}
template={t}
selected={selectedTemplateId === t.id}
onClick={() => handleSelect(t)}
/>
))}
</div>
</div>
) : (
<>
{/* Suggested templates */}
{hasSuggestions && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-2">Föreslagna</p>
<div className="space-y-1.5">
{suggestedTemplates!.slice(0, 5).map((s) => {
// Find the full template object
const fullTemplate = allCommon.find((t) => t.id === s.template_id) ||
allAdvanced.find((t) => t.id === s.template_id)
if (!fullTemplate) return null
return (
<TemplateCard
key={s.template_id}
template={fullTemplate}
selected={selectedTemplateId === s.template_id}
onClick={() => handleSelect(fullTemplate)}
compact
/>
)
})}
</div>
</div>
)}
{/* Common templates grouped */}
<div>
<p className="text-xs font-medium text-muted-foreground mb-2">Vanliga mallar</p>
<div className="space-y-3">
{GROUP_ORDER.filter((g) => commonGrouped.has(g)).map((group) => (
<div key={group}>
<p className="text-[11px] font-medium text-muted-foreground/70 uppercase tracking-wider mb-1">
{GROUP_LABELS[group]}
</p>
<div className="space-y-1.5">
{commonGrouped.get(group)!.map((t) => (
<TemplateCard
key={t.id}
template={t}
selected={selectedTemplateId === t.id}
onClick={() => handleSelect(t)}
compact
/>
))}
</div>
</div>
))}
</div>
</div>
{/* Advanced templates (collapsible) */}
{allAdvanced.length > 0 && (
<div>
<Button
variant="ghost"
size="sm"
className="w-full justify-between text-xs text-muted-foreground h-8"
onClick={() => setShowAdvanced(!showAdvanced)}
>
Fler mallar ({allAdvanced.length})
{showAdvanced ? (
<ChevronUp className="h-3.5 w-3.5" />
) : (
<ChevronDown className="h-3.5 w-3.5" />
)}
</Button>
{showAdvanced && (
<div className="space-y-3 mt-2">
{GROUP_ORDER.filter((g) => advancedGrouped.has(g)).map((group) => (
<div key={group}>
<p className="text-[11px] font-medium text-muted-foreground/70 uppercase tracking-wider mb-1">
{GROUP_LABELS[group]}
</p>
<div className="space-y-1.5">
{advancedGrouped.get(group)!.map((t) => (
<TemplateCard
key={t.id}
template={t}
selected={selectedTemplateId === t.id}
onClick={() => handleSelect(t)}
compact
/>
))}
</div>
</div>
))}
</div>
)}
</div>
)}
</>
)}
</div>
</div>
)
}
@@ -72,7 +72,7 @@ export default function TransactionBookingDialog({
if (linkFailCount > 0) {
toast({
title: 'Underlag kunde inte bifogas',
description: `${linkFailCount} fil(er) kunde inte lankas till verifikationen. Forsok igen via bokforingssidan.`,
description: `${linkFailCount} fil(er) kunde inte länkas till verifikationen. Försök igen via bokföringssidan.`,
variant: 'destructive',
})
}
@@ -93,9 +93,9 @@ export default function TransactionBookingDialog({
}}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Bokfor transaktion</DialogTitle>
<DialogTitle>Bokför transaktion</DialogTitle>
<DialogDescription>
Skapa en verifikation for transaktionen
Skapa en verifikation för transaktionen
</DialogDescription>
</DialogHeader>
@@ -83,11 +83,7 @@ export default function TransactionHistoryList({
<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'
}`}
className="h-10 w-10 rounded-full flex items-center justify-center bg-muted text-muted-foreground"
>
{transaction.amount > 0 ? (
<ArrowUpRight className="h-5 w-5" />
@@ -173,11 +169,7 @@ export default function TransactionHistoryList({
</Button>
)}
<div className="text-right">
<p
className={`font-medium ${
transaction.amount > 0 ? 'text-success' : ''
}`}
>
<p className="font-medium">
{transaction.amount > 0 ? '+' : ''}
{formatCurrency(transaction.amount, transaction.currency)}
</p>
@@ -9,6 +9,7 @@ import { formatCurrency, formatDate } from '@/lib/utils'
import { ArrowUpRight, ArrowDownRight, FileText, Loader2, MessageSquareText, Paperclip } from 'lucide-react'
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/info-tooltip'
import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
import { getTemplateById } from '@/lib/bookkeeping/booking-templates'
import type { TransactionWithInvoice, CategorizeHandler } from './transaction-types'
import type { SuggestedCategory, SuggestedTemplate } from '@/lib/transactions/category-suggestions'
@@ -26,6 +27,7 @@ interface TransactionInboxCardProps {
onOpenCategoryDialog: (transaction: TransactionWithInvoice) => void
onOpenDescribe?: (transaction: TransactionWithInvoice) => void
onOpenQuickReview?: (transaction: TransactionWithInvoice, suggestion: SuggestedCategory) => void
onOpenTemplateReview?: (transaction: TransactionWithInvoice, templateId: string) => void
onToggleSelect: (id: string) => void
onAnimationComplete?: (id: string) => void
}
@@ -44,6 +46,7 @@ export default function TransactionInboxCard({
onOpenCategoryDialog,
onOpenDescribe,
onOpenQuickReview,
onOpenTemplateReview,
onToggleSelect,
onAnimationComplete,
}: TransactionInboxCardProps) {
@@ -174,6 +177,36 @@ export default function TransactionInboxCard({
)}
Matcha Leverantörsfaktura {transaction.potential_supplier_invoice!.supplier_invoice_number}
</Button>
) : templateSuggestions && templateSuggestions.length > 0 ? (
<>
{templateSuggestions.slice(0, 2).map((ts, idx) => {
const tmpl = getTemplateById(ts.template_id)
return (
<Button
key={ts.template_id}
size="sm"
variant={idx === 0 ? 'default' : 'outline'}
className="h-8 text-xs"
onClick={() => {
if (onOpenTemplateReview && tmpl) {
onOpenTemplateReview(transaction, ts.template_id)
} else if (topSuggestion) {
handleSuggestionClick(topSuggestion)
}
}}
disabled={isProcessing || isDisabled}
>
{isProcessing && idx === 0 ? (
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
) : null}
{ts.name_sv}
<span className="ml-1 opacity-70 font-normal">
({ts.debit_account})
</span>
</Button>
)
})}
</>
) : topSuggestion ? (
<Button
size="sm"
@@ -199,24 +232,6 @@ export default function TransactionInboxCard({
</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}
{suggestions[1].account && (
<span className="ml-1 text-muted-foreground font-normal">
({formatAccountWithName(suggestions[1].account)})
</span>
)}
</Button>
)}
{/* Describe transaction */}
{onOpenDescribe && (
<Button
@@ -231,15 +246,17 @@ export default function TransactionInboxCard({
</Button>
)}
{/* Open category dialog */}
{/* Open category dialog / template picker */}
<Button
size="sm"
variant={!hasInvoiceMatch && !topSuggestion ? 'default' : 'outline'}
variant={!hasInvoiceMatch && !hasSupplierInvoiceMatch && !topSuggestion && (!templateSuggestions || templateSuggestions.length === 0) ? 'default' : 'outline'}
className="h-8 text-xs"
onClick={() => onOpenCategoryDialog(transaction)}
disabled={isProcessing || isDisabled}
>
{!hasInvoiceMatch && !topSuggestion ? 'Bokför' : 'Bokför manuellt...'}
{!hasInvoiceMatch && !hasSupplierInvoiceMatch && !topSuggestion && (!templateSuggestions || templateSuggestions.length === 0)
? 'Välj mall...'
: 'Välj mall...'}
</Button>
</div>
)}