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
@@ -0,0 +1,147 @@
|
||||
'use client'
|
||||
|
||||
import type { InvoiceInboxItem, DocumentClassificationType } from '@/types'
|
||||
import type { InvoiceExtractionResult } from '@/extensions/general/invoice-inbox/types'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
getStatusLabel,
|
||||
getStatusVariant,
|
||||
getConfidenceLabel,
|
||||
formatExtractionSummary,
|
||||
getDocumentTypeLabel,
|
||||
getDocumentTypeVariant,
|
||||
} from '@/lib/extensions/invoice-inbox-utils'
|
||||
import { Mail, Upload, FileText, Receipt, Landmark } from 'lucide-react'
|
||||
|
||||
interface DocumentInboxCardProps {
|
||||
item: InvoiceInboxItem
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateStr: string): string {
|
||||
const now = new Date()
|
||||
const date = new Date(dateStr)
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMin = Math.floor(diffMs / 60000)
|
||||
if (diffMin < 1) return 'Just nu'
|
||||
if (diffMin < 60) return `${diffMin} min sedan`
|
||||
const diffH = Math.floor(diffMin / 60)
|
||||
if (diffH < 24) return `${diffH} tim sedan`
|
||||
const diffD = Math.floor(diffH / 24)
|
||||
if (diffD === 1) return 'Igår'
|
||||
return `${diffD} dagar sedan`
|
||||
}
|
||||
|
||||
function formatSEK(amount: number): string {
|
||||
return new Intl.NumberFormat('sv-SE', {
|
||||
style: 'currency',
|
||||
currency: 'SEK',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
function getDocumentIcon(type: DocumentClassificationType) {
|
||||
switch (type) {
|
||||
case 'receipt':
|
||||
return <Receipt className="h-5 w-5 text-muted-foreground" />
|
||||
case 'government_letter':
|
||||
return <Landmark className="h-5 w-5 text-muted-foreground" />
|
||||
default:
|
||||
return <FileText className="h-5 w-5 text-muted-foreground" />
|
||||
}
|
||||
}
|
||||
|
||||
function getSummaryText(item: InvoiceInboxItem): { label: string; total: number } {
|
||||
const type = item.document_type ?? 'supplier_invoice'
|
||||
|
||||
switch (type) {
|
||||
case 'supplier_invoice': {
|
||||
const extraction = item.extracted_data as unknown as InvoiceExtractionResult | null
|
||||
const summary = formatExtractionSummary(extraction)
|
||||
return {
|
||||
label: (item.supplier as { name?: string } | undefined)?.name ?? (summary.supplierName || 'Okänd leverantör'),
|
||||
total: summary.total,
|
||||
}
|
||||
}
|
||||
case 'receipt': {
|
||||
const receipt = item.receipt as { merchant_name?: string; total_amount?: number } | undefined
|
||||
return {
|
||||
label: receipt?.merchant_name ?? 'Okänd handlare',
|
||||
total: receipt?.total_amount ?? 0,
|
||||
}
|
||||
}
|
||||
case 'government_letter': {
|
||||
return {
|
||||
label: item.email_from ?? 'Okänd avsändare',
|
||||
total: 0,
|
||||
}
|
||||
}
|
||||
default: {
|
||||
return { label: 'Granska manuellt', total: 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function DocumentInboxCard({ item, onClick }: DocumentInboxCardProps) {
|
||||
const confidence = getConfidenceLabel(item.confidence)
|
||||
const statusVariant = getStatusVariant(item.status) as 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning'
|
||||
const confidenceVariant = confidence.variant as 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning'
|
||||
const docType = (item.document_type ?? 'supplier_invoice') as DocumentClassificationType
|
||||
const docTypeVariant = getDocumentTypeVariant(docType) as 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning'
|
||||
|
||||
const fileName = (item.document as { file_name?: string } | undefined)?.file_name ?? 'Okänd fil'
|
||||
const { label: summaryLabel, total } = getSummaryText(item)
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="cursor-pointer transition-colors hover:bg-accent/50"
|
||||
onClick={onClick}
|
||||
>
|
||||
<CardContent className="flex items-center gap-4 p-4">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-muted">
|
||||
{item.source === 'email' ? (
|
||||
<Mail className="h-5 w-5 text-muted-foreground" />
|
||||
) : (
|
||||
<Upload className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{getDocumentIcon(docType)}
|
||||
<span className="text-sm font-medium truncate">{fileName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className={`text-sm truncate ${summaryLabel === 'Granska manuellt' ? 'text-muted-foreground/60 italic' : 'text-muted-foreground'}`}>
|
||||
{summaryLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-1 shrink-0">
|
||||
{total > 0 && (
|
||||
<span className="text-sm font-medium">{formatSEK(total)}</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Badge variant={docTypeVariant} className="text-[10px] px-1.5 py-0">
|
||||
{getDocumentTypeLabel(docType)}
|
||||
</Badge>
|
||||
{item.confidence != null && (
|
||||
<Badge variant={confidenceVariant} className="text-[10px] px-1.5 py-0">
|
||||
{confidence.label}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant={statusVariant}>
|
||||
{getStatusLabel(item.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{formatRelativeTime(item.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { InvoiceInboxItem } from '@/types'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { CheckCircle2, Receipt, LinkIcon } from 'lucide-react'
|
||||
|
||||
interface ReceiptLineItem {
|
||||
id: string
|
||||
description: string
|
||||
line_total: number
|
||||
vat_rate: number | null
|
||||
is_business: boolean | null
|
||||
category: string | null
|
||||
bas_account: string | null
|
||||
}
|
||||
|
||||
interface ReceiptInboxDetailProps {
|
||||
item: InvoiceInboxItem | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
function formatSEK(amount: number): string {
|
||||
return new Intl.NumberFormat('sv-SE', {
|
||||
style: 'currency',
|
||||
currency: 'SEK',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
export default function ReceiptInboxDetail({
|
||||
item,
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
}: ReceiptInboxDetailProps) {
|
||||
const [lineItems, setLineItems] = useState<ReceiptLineItem[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const [representationPersons, setRepresentationPersons] = useState<number | null>(null)
|
||||
const [representationPurpose, setRepresentationPurpose] = useState('')
|
||||
const [representationBusinessConnection, setRepresentationBusinessConnection] = useState('')
|
||||
|
||||
const receipt = item?.receipt as {
|
||||
id?: string
|
||||
merchant_name?: string
|
||||
total_amount?: number
|
||||
receipt_date?: string
|
||||
status?: string
|
||||
matched_transaction_id?: string
|
||||
} | undefined
|
||||
|
||||
// Fetch line items when dialog opens
|
||||
async function fetchLineItems() {
|
||||
if (!item?.linked_receipt_id) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/extensions/receipt-ocr/${item.linked_receipt_id}`)
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
if (data?.line_items) {
|
||||
setLineItems(data.line_items)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ok
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenChange(isOpen: boolean) {
|
||||
if (isOpen && item?.linked_receipt_id) {
|
||||
fetchLineItems()
|
||||
}
|
||||
onOpenChange(isOpen)
|
||||
}
|
||||
|
||||
function toggleBusiness(lineItemId: string) {
|
||||
setLineItems((prev) =>
|
||||
prev.map((li) =>
|
||||
li.id === lineItemId
|
||||
? { ...li, is_business: li.is_business === true ? false : true }
|
||||
: li
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
async function handleConfirm() {
|
||||
if (!item?.id || !item.linked_receipt_id) return
|
||||
setConfirming(true)
|
||||
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
line_items: lineItems.map((li) => ({
|
||||
id: li.id,
|
||||
is_business: li.is_business,
|
||||
category: li.category,
|
||||
bas_account: li.bas_account,
|
||||
})),
|
||||
}
|
||||
|
||||
if (receipt?.matched_transaction_id) {
|
||||
body.matched_transaction_id = receipt.matched_transaction_id
|
||||
}
|
||||
if (representationPersons != null && representationPersons > 0) {
|
||||
body.representation_persons = representationPersons
|
||||
}
|
||||
if (representationPurpose) {
|
||||
body.representation_purpose = representationPurpose
|
||||
}
|
||||
if (representationBusinessConnection) {
|
||||
body.representation_business_connection = representationBusinessConnection
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`/api/extensions/invoice-inbox/inbox/${item.id}/confirm-receipt`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
)
|
||||
|
||||
if (res.ok) {
|
||||
onConfirm()
|
||||
onOpenChange(false)
|
||||
}
|
||||
} catch {
|
||||
// ok
|
||||
} finally {
|
||||
setConfirming(false)
|
||||
}
|
||||
}
|
||||
|
||||
const businessTotal = lineItems
|
||||
.filter((li) => li.is_business === true)
|
||||
.reduce((sum, li) => sum + li.line_total, 0)
|
||||
const privateTotal = lineItems
|
||||
.filter((li) => li.is_business === false)
|
||||
.reduce((sum, li) => sum + li.line_total, 0)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Receipt className="h-5 w-5" />
|
||||
Kvitto via e-post
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{receipt && (
|
||||
<div className="space-y-4">
|
||||
{/* Summary */}
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Handlare</span>
|
||||
<p className="font-medium">{receipt.merchant_name ?? 'Okänd'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Datum</span>
|
||||
<p className="font-medium">{receipt.receipt_date ?? '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Totalbelopp</span>
|
||||
<p className="font-medium">
|
||||
{receipt.total_amount ? formatSEK(receipt.total_amount) : '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Transaktionsmatch</span>
|
||||
<p className="font-medium flex items-center gap-1">
|
||||
{receipt.matched_transaction_id ? (
|
||||
<>
|
||||
<LinkIcon className="h-3 w-3 text-green-600" />
|
||||
<span className="text-green-600">Matchad</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Ingen match</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Line items with business/private toggle */}
|
||||
<div className="space-y-2">
|
||||
<Label>Artikelrader</Label>
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">Laddar...</p>
|
||||
) : lineItems.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Inga rader extraherade</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{lineItems.map((li) => (
|
||||
<div
|
||||
key={li.id}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm truncate">{li.description}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatSEK(li.line_total)}
|
||||
{li.vat_rate != null && ` (${li.vat_rate}% moms)`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-xs text-muted-foreground">Företag</span>
|
||||
<Switch
|
||||
checked={li.is_business === true}
|
||||
onCheckedChange={() => toggleBusiness(li.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Totals */}
|
||||
{lineItems.length > 0 && (
|
||||
<div className="flex gap-4 text-sm">
|
||||
<Badge variant="default">Företag: {formatSEK(Math.round(businessTotal * 100) / 100)}</Badge>
|
||||
<Badge variant="secondary">Privat: {formatSEK(Math.round(privateTotal * 100) / 100)}</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Representation fields */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wide">
|
||||
Representation (vid restaurangkvitto)
|
||||
</Label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="rep-persons" className="text-sm">
|
||||
Antal personer
|
||||
</Label>
|
||||
<Input
|
||||
id="rep-persons"
|
||||
type="number"
|
||||
min={0}
|
||||
value={representationPersons ?? ''}
|
||||
onChange={(e) =>
|
||||
setRepresentationPersons(
|
||||
e.target.value ? parseInt(e.target.value) : null
|
||||
)
|
||||
}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="rep-purpose" className="text-sm">
|
||||
Syfte
|
||||
</Label>
|
||||
<Input
|
||||
id="rep-purpose"
|
||||
value={representationPurpose}
|
||||
onChange={(e) => setRepresentationPurpose(e.target.value)}
|
||||
placeholder="T.ex. kundmöte"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="rep-connection" className="text-sm">
|
||||
Affärsmässig koppling (BFNAR)
|
||||
</Label>
|
||||
<Input
|
||||
id="rep-connection"
|
||||
value={representationBusinessConnection}
|
||||
onChange={(e) =>
|
||||
setRepresentationBusinessConnection(e.target.value)
|
||||
}
|
||||
placeholder="T.ex. potentiell kund, pågående projekt"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} disabled={confirming}>
|
||||
<CheckCircle2 className="h-4 w-4 mr-1.5" />
|
||||
{confirming ? 'Bekräftar...' : 'Bekräfta kvitto'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user