Files
accounted/components/transactions/DescribeTransactionDialog.tsx
T
Jakob Wennberg f3ec634a46 feat: open-source under AGPL-3.0, redesign UI to grayscale palette, add uncategorize API, fix VAT account names
Add LICENSE (AGPL-3.0-or-later), CONTRIBUTING.md, SECURITY.md, DCO, and NOTICE files.
Rewrite README for open-source audience with self-hosting instructions.
Redesign color palette to grayscale chrome theme across all components.
Add transaction uncategorize API route with tests.
Fix VAT account name mismatches in migration 052.
Improve import page with SIE file support and loading skeleton.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 18:18:11 +01:00

693 lines
26 KiB
TypeScript

'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent } from '@/components/ui/card'
import { Textarea } from '@/components/ui/textarea'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
import { formatCurrency, formatDate } from '@/lib/utils'
import {
ArrowUpRight,
ArrowDownRight,
Loader2,
Search,
ArrowLeft,
Check,
CheckCircle2,
AlertTriangle,
Sparkles,
} from 'lucide-react'
import JournalEntryPreview from './JournalEntryPreview'
import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
import type { TransactionWithInvoice } from './transaction-types'
interface TemplateMatch {
template_id: string
name_sv: string
name_en: string
group: string
debit_account: string
credit_account: string
confidence: number
description_sv: string
vat_rate: number
vat_treatment: string | null
deductibility: 'full' | 'non_deductible' | 'conditional'
deductibility_note_sv: string | null
special_rules_sv: string | null
risk_level: string
}
interface AiSuggestion {
debit_account: string
credit_account: string
vat_treatment: string | null
category: string
confidence: number
reasoning: string
warnings: string[]
template_id: string | null
}
interface DescribeResult {
templates: TemplateMatch[]
ai_suggestion: AiSuggestion | null
needs_more_detail: boolean
user_description: string
batch_candidate_count: number
merchant_name: string | null
}
interface DescribeTransactionDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
transaction: TransactionWithInvoice | null
onCategorized: (transactionId: string, journalEntryId: string | null) => void
onBatchApplied?: (count: number) => void
}
type Step = 'describe' | 'pick' | 'batch'
type Selection = { type: 'template'; templateId: string } | { type: 'ai' }
function getExamplePrompts(transaction: TransactionWithInvoice): string[] {
const desc = (transaction.description || '').toLowerCase()
const isExpense = transaction.amount < 0
if (!isExpense) {
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 ['Tjänsteresa', 'Hotell konferens', 'Flygbiljett']
}
if (desc.includes('uber') || desc.includes('taxi') || desc.includes('bolt') || desc.includes('sj ')) {
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', 'Marknadsföringskampanj']
}
if (desc.includes('amazon') || desc.includes('aws') || desc.includes('azure') || desc.includes('cloud')) {
return ['Serverhosting', 'SaaS-prenumeration', 'Kontorsmaterial']
}
return ['Kontorsmaterial', 'SaaS-prenumeration', 'Konsulttjänst', 'Reklam']
}
function getVatRateFromTreatment(treatment: string | null): number {
switch (treatment) {
case 'standard_25': return 0.25
case 'reduced_12': return 0.12
case 'reduced_6': return 0.06
default: return 0
}
}
export default function DescribeTransactionDialog({
open,
onOpenChange,
transaction,
onCategorized,
onBatchApplied,
}: DescribeTransactionDialogProps) {
const { toast } = useToast()
const [step, setStep] = useState<Step>('describe')
const [description, setDescription] = useState('')
const [isSearching, setIsSearching] = useState(false)
const [isBooking, setIsBooking] = useState(false)
const [isBatchApplying, setIsBatchApplying] = useState(false)
const [describeResult, setDescribeResult] = useState<DescribeResult | null>(null)
const [selection, setSelection] = useState<Selection | null>(null)
const selectedTemplateId = selection?.type === 'template' ? selection.templateId : null
const isAiSelected = selection?.type === 'ai'
function resetState() {
setStep('describe')
setDescription('')
setIsSearching(false)
setIsBooking(false)
setIsBatchApplying(false)
setDescribeResult(null)
setSelection(null)
}
function handleOpenChange(isOpen: boolean) {
if (!isOpen) {
resetState()
}
onOpenChange(isOpen)
}
async function handleSearch() {
if (!transaction || description.trim().length < 3) return
setIsSearching(true)
try {
const response = await fetch(`/api/transactions/${transaction.id}/describe`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ description: description.trim() }),
})
const result = await response.json()
if (!response.ok) {
toast({
title: 'Fel',
description: result.error || 'Kunde inte söka mallar',
variant: 'destructive',
})
setIsSearching(false)
return
}
setDescribeResult(result.data)
setSelection(null)
setStep('pick')
} catch {
toast({
title: 'Fel',
description: 'Något gick fel vid sökning',
variant: 'destructive',
})
}
setIsSearching(false)
}
async function handleBook() {
if (!transaction || !describeResult || !selection) return
setIsBooking(true)
try {
// Build categorize request based on selection type
let body: Record<string, unknown>
if (selection.type === 'template') {
body = {
is_business: true,
template_id: selection.templateId,
user_description: describeResult.user_description,
}
} else {
// AI suggestion selected
const ai = describeResult.ai_suggestion!
if (ai.template_id) {
// AI matched a template — use template-based booking
body = {
is_business: true,
template_id: ai.template_id,
user_description: describeResult.user_description,
}
} else {
// AI category-based booking — category maps to the correct account
body = {
is_business: true,
category: ai.category,
vat_treatment: ai.vat_treatment || undefined,
user_description: describeResult.user_description,
}
}
}
const response = await fetch(`/api/transactions/${transaction.id}/categorize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const result = await response.json()
if (!response.ok) {
toast({
title: 'Fel',
description: result.error || 'Kunde inte bokföra transaktion',
variant: 'destructive',
})
setIsBooking(false)
return
}
if (describeResult.batch_candidate_count > 0) {
setStep('batch')
setIsBooking(false)
onCategorized(transaction.id, result.journal_entry_id || null)
} else {
toast({ title: 'Bokförd', description: 'Transaktion bokförd och verifikation skapad' })
onCategorized(transaction.id, result.journal_entry_id || null)
handleOpenChange(false)
}
} catch {
toast({
title: 'Fel',
description: 'Något gick fel vid bokföring',
variant: 'destructive',
})
setIsBooking(false)
}
}
async function handleBatchApply() {
if (!describeResult) return
// For batch apply, we need a template_id
let templateId: string | null = null
if (selection?.type === 'template') {
templateId = selection.templateId
} else if (selection?.type === 'ai' && describeResult.ai_suggestion?.template_id) {
templateId = describeResult.ai_suggestion.template_id
}
if (!templateId) return
setIsBatchApplying(true)
try {
const response = await fetch('/api/transactions/batch-describe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
merchant_name: describeResult.merchant_name,
template_id: templateId,
is_business: true,
user_description: describeResult.user_description,
}),
})
const result = await response.json()
if (!response.ok) {
toast({
title: 'Fel',
description: result.error || 'Kunde inte bokföra batch',
variant: 'destructive',
})
setIsBatchApplying(false)
return
}
const applied = result.data?.applied || 0
const errors = result.data?.errors || []
if (errors.length > 0) {
toast({
title: 'Delvis klart',
description: `${applied} lyckades, ${errors.length} misslyckades`,
variant: 'destructive',
})
} else {
toast({
title: 'Klart',
description: `${applied} transaktioner bokförda`,
})
}
onBatchApplied?.(applied)
handleOpenChange(false)
} catch {
toast({
title: 'Fel',
description: 'Något gick fel vid batchbokföring',
variant: 'destructive',
})
setIsBatchApplying(false)
}
}
function handleSkipBatch() {
toast({ title: 'Bokförd', description: 'Transaktion bokförd och verifikation skapad' })
handleOpenChange(false)
}
if (!transaction) return null
const isIncome = transaction.amount > 0
const aiSuggestion = describeResult?.ai_suggestion
// Check if AI agrees with top template
const topTemplate = describeResult?.templates[0]
const aiAgreesWithTop = aiSuggestion && topTemplate && aiSuggestion.debit_account === topTemplate.debit_account
// Determine if batch apply is available (requires a template_id)
const canBatchApply = selection?.type === 'template' || (selection?.type === 'ai' && !!describeResult?.ai_suggestion?.template_id)
return (
<Dialog open={open} onOpenChange={(isBooking || isBatchApplying) ? undefined : handleOpenChange}>
<DialogContent className="max-w-md max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle>
{step === 'describe' && 'Beskriv transaktion'}
{step === 'pick' && 'Välj mall'}
{step === 'batch' && 'Bokför liknande'}
</DialogTitle>
<DialogDescription>
{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>
{/* Transaction summary - shown in describe and pick steps */}
{(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 bg-muted text-muted-foreground"
>
{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 ? '+' : ''}
{formatCurrency(transaction.amount, transaction.currency)}
</p>
</div>
)}
{/* Step 1: Describe */}
{step === 'describe' && (
<div className="space-y-4">
<Textarea
placeholder="Beskriv vad transaktionen gäller, t.ex. 'lunch med kund' eller 'kontorsmaterial'"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && description.trim().length >= 3) {
e.preventDefault()
handleSearch()
}
}}
/>
<div className="flex flex-wrap gap-1.5">
{getExamplePrompts(transaction).map((prompt) => (
<button
key={prompt}
type="button"
className="text-xs px-2.5 py-1 rounded-full border bg-muted/50 hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setDescription(prompt)}
>
{prompt}
</button>
))}
</div>
<Button
className="w-full"
disabled={description.trim().length < 3 || isSearching}
onClick={handleSearch}
>
{isSearching ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Search className="mr-2 h-4 w-4" />
)}
{isSearching ? 'Söker...' : 'Sök'}
</Button>
</div>
)}
{/* Step 2: Pick template */}
{step === 'pick' && describeResult && (
<div className="space-y-4 min-h-0 flex flex-col">
{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 är osäkra. Försök beskriv mer detaljerat för bättre träffar.</p>
</div>
)}
<div className="overflow-y-auto max-h-[40vh] space-y-2 pr-1">
{/* AI Suggestion Card */}
{aiSuggestion && (
<Card
className={`cursor-pointer transition-colors hover:border-primary/50 ${
isAiSelected ? 'border-primary bg-primary/5' : ''
}`}
onClick={() => setSelection({ type: 'ai' })}
>
<CardContent className="py-3 px-4">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<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-förslag
</Badge>
</div>
<p className="text-sm text-muted-foreground mt-1">
{aiSuggestion.reasoning}
</p>
<div className="flex flex-wrap items-center gap-1.5 mt-1.5">
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
D: {formatAccountWithName(aiSuggestion.debit_account)}
</Badge>
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
K: {formatAccountWithName(aiSuggestion.credit_account)}
</Badge>
{aiSuggestion.vat_treatment && aiSuggestion.vat_treatment !== 'exempt' && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
Moms {Math.round(getVatRateFromTreatment(aiSuggestion.vat_treatment) * 100)}%
</Badge>
)}
{aiSuggestion.vat_treatment === 'exempt' && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
Momsfritt
</Badge>
)}
</div>
{aiSuggestion.warnings.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1.5">
{aiSuggestion.warnings.map((warning, i) => (
<Badge key={i} variant="outline" className="text-[10px] px-1.5 py-0 text-amber-600 border-amber-300">
{warning}
</Badge>
))}
</div>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<Badge
variant={aiSuggestion.confidence >= 0.7 ? 'default' : 'outline'}
className="text-[10px] px-1.5 py-0"
>
{Math.round(aiSuggestion.confidence * 100)}%
</Badge>
{isAiSelected && (
<Check className="h-4 w-4 text-primary" />
)}
</div>
</div>
</CardContent>
</Card>
)}
{/* Template cards */}
{describeResult.templates.length === 0 && !aiSuggestion ? (
<p className="text-sm text-muted-foreground text-center py-4">
Inga matchande mallar hittades. Försök med en annan beskrivning.
</p>
) : (
describeResult.templates.map((template) => (
<Card
key={template.template_id}
className={`cursor-pointer transition-colors hover:border-primary/50 ${
selectedTemplateId === template.template_id
? 'border-primary bg-primary/5'
: ''
}`}
onClick={() => setSelection({ type: 'template', templateId: template.template_id })}
>
<CardContent className="py-3 px-4">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<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-success/10 text-success">
AI bekräftar
</Badge>
)}
</div>
{template.description_sv && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
{template.description_sv}
</p>
)}
<div className="flex flex-wrap items-center gap-1.5 mt-1.5">
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
D: {formatAccountWithName(template.debit_account)}
</Badge>
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
K: {formatAccountWithName(template.credit_account)}
</Badge>
{template.vat_treatment && template.vat_treatment !== 'exempt' && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
Moms {Math.round(template.vat_rate * 100)}%
</Badge>
)}
{template.vat_treatment === 'exempt' && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
Momsfritt
</Badge>
)}
{template.deductibility === 'non_deductible' && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 text-amber-600 border-amber-300">
Ej avdragsgill
</Badge>
)}
{template.deductibility === 'conditional' && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 text-amber-600 border-amber-300">
Villkorligt avdrag
</Badge>
)}
</div>
{(template.deductibility_note_sv || template.special_rules_sv) && selectedTemplateId === template.template_id && (
<div className="mt-2 space-y-1">
{template.deductibility_note_sv && (
<p className="text-[11px] text-amber-600 dark:text-amber-400">
{template.deductibility_note_sv}
</p>
)}
{template.special_rules_sv && (
<p className="text-[11px] text-muted-foreground italic">
{template.special_rules_sv}
</p>
)}
</div>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<Badge
variant={template.confidence >= 0.7 ? 'default' : 'outline'}
className="text-[10px] px-1.5 py-0"
>
{Math.round(template.confidence * 100)}%
</Badge>
{selectedTemplateId === template.template_id && (
<Check className="h-4 w-4 text-primary" />
)}
</div>
</div>
</CardContent>
</Card>
))
)}
</div>
{/* Journal entry preview for selected template or AI suggestion */}
{selection && (() => {
if (selection.type === 'ai' && aiSuggestion) {
return (
<JournalEntryPreview
amount={transaction.amount}
currency={transaction.currency}
templateDebitAccount={aiSuggestion.debit_account}
templateCreditAccount={aiSuggestion.credit_account}
templateVatRate={getVatRateFromTreatment(aiSuggestion.vat_treatment)}
/>
)
}
if (selection.type === 'template') {
const tmpl = describeResult.templates.find(t => t.template_id === selection.templateId)
if (!tmpl) return null
return (
<JournalEntryPreview
amount={transaction.amount}
currency={transaction.currency}
templateDebitAccount={tmpl.debit_account}
templateCreditAccount={tmpl.credit_account}
templateVatRate={tmpl.vat_rate}
/>
)
}
return null
})()}
<div className="flex gap-2 pt-2">
<Button
variant="ghost"
className="flex-shrink-0"
onClick={() => {
setStep('describe')
setSelection(null)
}}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Beskriv igen
</Button>
<Button
className="flex-1"
disabled={!selection || isBooking}
onClick={handleBook}
>
{isBooking ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Check className="mr-2 h-4 w-4" />
)}
{isBooking ? 'Bokför...' : 'Bokför'}
</Button>
</div>
</div>
)}
{/* Step 3: Batch offer */}
{step === 'batch' && describeResult && (
<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 är bokförd!</p>
</div>
{canBatchApply && (
<p className="text-sm text-muted-foreground">
Det finns ytterligare{' '}
<span className="font-medium text-foreground">
{describeResult.batch_candidate_count}
</span>{' '}
obokförda transaktioner från{' '}
<span className="font-medium text-foreground">
{describeResult.merchant_name}
</span>
. Använd samma mall?
</p>
)}
<div className="flex gap-2">
<Button
variant="outline"
className="flex-1"
onClick={handleSkipBatch}
disabled={isBatchApplying}
>
{canBatchApply ? 'Nej, bara den här' : 'Stäng'}
</Button>
{canBatchApply && (
<Button
className="flex-1"
onClick={handleBatchApply}
disabled={isBatchApplying}
>
{isBatchApplying ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
{isBatchApplying
? 'Bokför...'
: `Ja, bokför alla ${describeResult.batch_candidate_count} st`}
</Button>
)}
</div>
</div>
)}
</DialogContent>
</Dialog>
)
}