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:
co-authored by
Claude Opus 4.6
parent
a9bf24ce0b
commit
66a4027f1e
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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} · 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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user