f3ec634a46
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>
693 lines
26 KiB
TypeScript
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>
|
|
)
|
|
}
|