feat: unified document inbox, full BAS 2026, and document-transaction matching
- Expand BAS reference from ~180 to ~1,276 accounts (full BAS Kontoplan 2026) with K2 exclusion flags, per-class data files, and computed SRU codes - Evolve invoice inbox into unified document inbox handling invoices, receipts, and government letters with AI-powered classification (Claude Haiku Vision) - Add multi-pass document-to-transaction matching engine with greedy assignment for both supplier invoices (reference/amount/date/name) and receipts (weighted amount/merchant/date scoring) - Add supplier invoice matching in transaction ingest pipeline - Inject booking template suggestions into AI extraction prompts - Surface matched documents in swipe categorization UI with one-tap booking - Auto-activate missing BAS accounts during SIE import against full reference - Add K2 filter toggle in Chart of Accounts manager - Add receipt confirmation route with BFNAR representation fields - Add database migrations for K2 support and document matching columns - Remove obsolete extension migration scripts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
6956a757f3
commit
39e407644d
@@ -10,6 +10,7 @@ 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 JournalEntryPreview from './JournalEntryPreview'
|
||||
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
|
||||
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
|
||||
@@ -59,6 +60,8 @@ export default function SwipeCategorizationView({
|
||||
const [showUploadZone, setShowUploadZone] = useState(false)
|
||||
const [showDescribeDialog, setShowDescribeDialog] = useState(false)
|
||||
const [showVatDropdown, setShowVatDropdown] = useState(false)
|
||||
const [pendingTemplateId, setPendingTemplateId] = useState<string | null>(null)
|
||||
const [pendingInboxItemId, setPendingInboxItemId] = useState<string | null>(null)
|
||||
|
||||
// Clear VAT treatment when switching to a liability/equity account (class 2)
|
||||
useEffect(() => {
|
||||
@@ -120,6 +123,23 @@ export default function SwipeCategorizationView({
|
||||
setPendingCategory(category)
|
||||
setAccountOverride(defaultAccount)
|
||||
setVatTreatment(defaultVat ?? 'none')
|
||||
setPendingTemplateId(null)
|
||||
setPendingInboxItemId(null)
|
||||
setShowVatDropdown(false)
|
||||
setShowCategorySelect(false)
|
||||
setShowReviewStep(true)
|
||||
setError(null)
|
||||
}, [])
|
||||
|
||||
const handleTemplateSelect = useCallback((templateId: string, inboxItemId?: string) => {
|
||||
const template = getTemplateById(templateId)
|
||||
if (!template) return
|
||||
|
||||
setPendingCategory(template.fallback_category)
|
||||
setAccountOverride(template.debit_account)
|
||||
setVatTreatment(template.vat_treatment ?? 'none')
|
||||
setPendingTemplateId(templateId)
|
||||
setPendingInboxItemId(inboxItemId ?? null)
|
||||
setShowVatDropdown(false)
|
||||
setShowCategorySelect(false)
|
||||
setShowReviewStep(true)
|
||||
@@ -180,7 +200,9 @@ export default function SwipeCategorizationView({
|
||||
true,
|
||||
pendingCategory,
|
||||
resolvedVat,
|
||||
override
|
||||
override,
|
||||
pendingTemplateId ?? undefined,
|
||||
pendingInboxItemId ?? undefined
|
||||
)
|
||||
if (journalEntryId) {
|
||||
// Link uploaded documents to the journal entry
|
||||
@@ -210,6 +232,8 @@ export default function SwipeCategorizationView({
|
||||
resetUploadState()
|
||||
setShowReviewStep(false)
|
||||
setPendingCategory(null)
|
||||
setPendingTemplateId(null)
|
||||
setPendingInboxItemId(null)
|
||||
moveToNext()
|
||||
} else {
|
||||
setError('Kunde inte bokföra. Tryck "Hoppa över" för att gå vidare.')
|
||||
@@ -248,6 +272,8 @@ export default function SwipeCategorizationView({
|
||||
setShowCategorySelect(false)
|
||||
setShowReviewStep(false)
|
||||
setPendingCategory(null)
|
||||
setPendingTemplateId(null)
|
||||
setPendingInboxItemId(null)
|
||||
resetUploadState()
|
||||
moveToNext()
|
||||
}, [moveToNext, resetUploadState])
|
||||
@@ -424,38 +450,51 @@ export default function SwipeCategorizationView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Document upload */}
|
||||
<div className="rounded-lg border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowUploadZone(!showUploadZone)}
|
||||
className="flex items-center justify-between w-full px-3 py-2.5 text-sm hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
{/* Document upload / pre-attached document */}
|
||||
{pendingInboxItemId && currentTransaction.matched_inbox_item?.document_id ? (
|
||||
<div className="rounded-lg border bg-blue-500/5 border-blue-500/30 px-3 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Paperclip className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">Underlag</span>
|
||||
{uploadedFiles.filter((f) => f.status === 'uploaded').length > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{uploadedFiles.filter((f) => f.status === 'uploaded').length} bifogade
|
||||
</span>
|
||||
<Paperclip className="h-4 w-4 text-blue-600" />
|
||||
<span className="text-sm font-medium">Underlag bifogat</span>
|
||||
<Check className="h-4 w-4 text-blue-600" />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Dokumentet från inkorgen länkas automatiskt till verifikationen.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowUploadZone(!showUploadZone)}
|
||||
className="flex items-center justify-between w-full px-3 py-2.5 text-sm hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Paperclip className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">Underlag</span>
|
||||
{uploadedFiles.filter((f) => f.status === 'uploaded').length > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{uploadedFiles.filter((f) => f.status === 'uploaded').length} bifogade
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{showUploadZone ? (
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
{showUploadZone ? (
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
{showUploadZone && (
|
||||
<div className="px-3 pb-3">
|
||||
<DocumentUploadZone
|
||||
files={uploadedFiles}
|
||||
onFilesChange={setUploadedFiles}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
{showUploadZone && (
|
||||
<div className="px-3 pb-3">
|
||||
<DocumentUploadZone
|
||||
files={uploadedFiles}
|
||||
onFilesChange={setUploadedFiles}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
|
||||
@@ -603,6 +642,55 @@ export default function SwipeCategorizationView({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Document Match from Inbox */}
|
||||
{currentTransaction.matched_inbox_item && (
|
||||
<div className="p-4 rounded-lg border-2 border-blue-500/40 bg-blue-500/5 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-blue-600 dark:text-blue-400">
|
||||
<Paperclip className="h-5 w-5" />
|
||||
<span className="font-semibold text-sm">
|
||||
{currentTransaction.matched_inbox_item.document_type === 'receipt'
|
||||
? 'Matchat kvitto'
|
||||
: currentTransaction.matched_inbox_item.document_type === 'supplier_invoice'
|
||||
? 'Matchad leverantörsfaktura'
|
||||
: 'Matchat dokument'}
|
||||
</span>
|
||||
</div>
|
||||
{currentTransaction.matched_inbox_item.match_confidence != null && (
|
||||
<Badge variant="outline" className="text-blue-600 border-blue-500">
|
||||
{Math.round(currentTransaction.matched_inbox_item.match_confidence * 100)}%
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
{(() => {
|
||||
const ext = currentTransaction.matched_inbox_item.extracted_data as Record<string, unknown> | null
|
||||
if (!ext) return null
|
||||
const supplierName = (ext as { supplier?: { name?: string } })?.supplier?.name
|
||||
const merchantName = (ext as { merchant?: { name?: string } })?.merchant?.name
|
||||
const totals = ext as { totals?: { total?: number } }
|
||||
return (
|
||||
<>
|
||||
{(supplierName || merchantName) && (
|
||||
<p className="font-medium">{supplierName || merchantName}</p>
|
||||
)}
|
||||
{totals?.totals?.total != null && (
|
||||
<p className="text-muted-foreground">
|
||||
{formatCurrency(totals.totals.total)}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
{currentTransaction.matched_inbox_item.suggested_template_id && (
|
||||
<p className="text-xs text-blue-600 dark:text-blue-400 mt-1">
|
||||
Mall: {currentTransaction.matched_inbox_item.suggested_template_id}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warnings */}
|
||||
{warnings.length > 0 && (
|
||||
<div className="space-y-2 pt-4 border-t">
|
||||
@@ -642,6 +730,23 @@ export default function SwipeCategorizationView({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Document template match — primary action when inbox item has a suggested template */}
|
||||
{currentTransaction.matched_inbox_item?.suggested_template_id && (() => {
|
||||
const tmplId = currentTransaction.matched_inbox_item!.suggested_template_id!
|
||||
const template = getTemplateById(tmplId)
|
||||
if (!template) return null
|
||||
return (
|
||||
<Button
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white"
|
||||
onClick={() => handleTemplateSelect(tmplId, currentTransaction.matched_inbox_item!.id)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
<Paperclip className="mr-2 h-4 w-4" />
|
||||
Bokför som {template.name_sv}
|
||||
</Button>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Invoice match button - primary action when there's a match */}
|
||||
{currentTransaction.potential_invoice && onMatchInvoice && (
|
||||
<Button
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { ArrowUpRight, ArrowDownRight, FileText, Loader2, MessageSquareText } from 'lucide-react'
|
||||
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 type { TransactionWithInvoice, CategorizeHandler } from './transaction-types'
|
||||
@@ -51,11 +51,13 @@ export default function TransactionInboxCard({
|
||||
const isDisabled = processingId !== null && processingId !== transaction.id
|
||||
const isIncome = transaction.amount > 0
|
||||
const hasInvoiceMatch = !!transaction.potential_invoice && !transaction.invoice_id
|
||||
const hasSupplierInvoiceMatch = !!transaction.potential_supplier_invoice && !transaction.supplier_invoice_id
|
||||
const topSuggestion = suggestions?.[0]
|
||||
const isUncategorized = transaction.is_business === null && !transaction.journal_entry_id
|
||||
const showCheckbox = isBatchMode && isUncategorized
|
||||
const hasWeakSuggestions = !topSuggestion || topSuggestion.confidence < 0.55
|
||||
const showTemplateFallback = hasWeakSuggestions && templateSuggestions && templateSuggestions.length > 0
|
||||
const hasDocumentMatch = !!transaction.matched_inbox_item
|
||||
|
||||
function handleSuggestionClick(suggestion: SuggestedCategory) {
|
||||
if (onOpenQuickReview) {
|
||||
@@ -80,7 +82,7 @@ export default function TransactionInboxCard({
|
||||
>
|
||||
<Card
|
||||
className={`transition-colors ${
|
||||
hasInvoiceMatch ? 'border-blue-500/50' : 'border-warning/50'
|
||||
hasInvoiceMatch || hasSupplierInvoiceMatch ? 'border-blue-500/50' : 'border-warning/50'
|
||||
} ${isSelected ? 'border-primary bg-primary/[0.02]' : ''} ${
|
||||
isDisabled ? 'opacity-50' : ''
|
||||
}`}
|
||||
@@ -113,7 +115,16 @@ export default function TransactionInboxCard({
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium truncate">{transaction.description}</p>
|
||||
<p className="text-sm text-muted-foreground">{formatDate(transaction.date)}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm text-muted-foreground">{formatDate(transaction.date)}</p>
|
||||
{hasDocumentMatch && (
|
||||
<Badge variant="secondary" className="text-xs gap-1">
|
||||
<Paperclip className="h-3 w-3" />
|
||||
{transaction.matched_inbox_item!.document_type === 'receipt' ? 'Kvitto' :
|
||||
transaction.matched_inbox_item!.document_type === 'supplier_invoice' ? 'Faktura' : 'Dokument'}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -150,6 +161,21 @@ export default function TransactionInboxCard({
|
||||
)}
|
||||
Matcha Faktura {transaction.potential_invoice!.invoice_number}
|
||||
</Button>
|
||||
) : hasSupplierInvoiceMatch ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="h-8 text-xs"
|
||||
onClick={() => onOpenMatchDialog(transaction)}
|
||||
disabled={isProcessing || isDisabled}
|
||||
>
|
||||
{isProcessing ? (
|
||||
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<FileText className="mr-1.5 h-3 w-3" />
|
||||
)}
|
||||
Matcha Leverantörsfaktura {transaction.potential_supplier_invoice!.supplier_invoice_number}
|
||||
</Button>
|
||||
) : topSuggestion ? (
|
||||
<Button
|
||||
size="sm"
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { Transaction, TransactionCategory, Invoice, Customer, VatTreatment } from '@/types'
|
||||
import type { Transaction, TransactionCategory, Invoice, Customer, SupplierInvoice, VatTreatment, InvoiceInboxItem } from '@/types'
|
||||
|
||||
// Shared transaction type with potential invoice data
|
||||
export interface TransactionWithInvoice extends Transaction {
|
||||
potential_invoice?: Invoice & { customer?: Customer }
|
||||
potential_supplier_invoice?: SupplierInvoice
|
||||
matched_inbox_item?: InvoiceInboxItem
|
||||
}
|
||||
|
||||
// Page view modes
|
||||
@@ -16,7 +18,9 @@ export type CategorizeHandler = (
|
||||
isBusiness: boolean,
|
||||
category?: TransactionCategory,
|
||||
vatTreatment?: VatTreatment,
|
||||
accountOverride?: string
|
||||
accountOverride?: string,
|
||||
templateId?: string,
|
||||
inboxItemId?: string
|
||||
) => Promise<string | null>
|
||||
|
||||
export type MatchInvoiceHandler = (
|
||||
|
||||
Reference in New Issue
Block a user