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
@@ -56,6 +56,7 @@ const TYPE_LABELS: Record<string, string> = {
|
||||
equity: 'EK',
|
||||
revenue: 'Intakt',
|
||||
expense: 'Kostnad',
|
||||
untaxed_reserves: 'Ob. reserver',
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -69,6 +70,7 @@ export default function ChartOfAccountsManager() {
|
||||
const [view, setView] = useState<'my-accounts' | 'bas-catalog'>('my-accounts')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [expandedClasses, setExpandedClasses] = useState<Set<number>>(new Set())
|
||||
const [hideK2Excluded, setHideK2Excluded] = useState<boolean | null>(null)
|
||||
|
||||
// Data state
|
||||
const [accounts, setAccounts] = useState<BASAccount[]>([])
|
||||
@@ -104,10 +106,25 @@ export default function ChartOfAccountsManager() {
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
await Promise.all([fetchAccounts(), fetchReference()])
|
||||
// Set K2 filter default based on company settings (plan_type)
|
||||
if (hideK2Excluded === null) {
|
||||
try {
|
||||
const res = await fetch('/api/settings')
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
// Default to hiding K2-excluded accounts if the company uses K2 (plan_type === 'k1')
|
||||
setHideK2Excluded(data?.plan_type === 'k1')
|
||||
} else {
|
||||
setHideK2Excluded(false)
|
||||
}
|
||||
} catch {
|
||||
setHideK2Excluded(false)
|
||||
}
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
load()
|
||||
}, [fetchAccounts, fetchReference])
|
||||
}, [fetchAccounts, fetchReference, hideK2Excluded])
|
||||
|
||||
const refreshAll = useCallback(async () => {
|
||||
await Promise.all([fetchAccounts(), fetchReference()])
|
||||
@@ -221,12 +238,18 @@ export default function ChartOfAccountsManager() {
|
||||
}, [filteredAccounts])
|
||||
|
||||
const filteredReference = useMemo(() => {
|
||||
if (!searchQuery) return referenceAccounts
|
||||
const q = searchQuery.toLowerCase()
|
||||
return referenceAccounts.filter(
|
||||
(a) => a.account_number.includes(q) || a.account_name.toLowerCase().includes(q)
|
||||
)
|
||||
}, [referenceAccounts, searchQuery])
|
||||
let filtered = referenceAccounts
|
||||
if (hideK2Excluded) {
|
||||
filtered = filtered.filter((a) => !a.k2_excluded)
|
||||
}
|
||||
if (searchQuery) {
|
||||
const q = searchQuery.toLowerCase()
|
||||
filtered = filtered.filter(
|
||||
(a) => a.account_number.includes(q) || a.account_name.toLowerCase().includes(q)
|
||||
)
|
||||
}
|
||||
return filtered
|
||||
}, [referenceAccounts, searchQuery, hideK2Excluded])
|
||||
|
||||
const groupedReference = useMemo(() => {
|
||||
const grouped: Record<number, ReferenceAccount[]> = {}
|
||||
@@ -284,6 +307,17 @@ export default function ChartOfAccountsManager() {
|
||||
Eget konto
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{view === 'bas-catalog' && (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Switch
|
||||
checked={hideK2Excluded ?? false}
|
||||
onCheckedChange={setHideK2Excluded}
|
||||
className="scale-75"
|
||||
/>
|
||||
<span className="text-muted-foreground">Dolj K2-undantagna</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
|
||||
import type { InvoiceInboxItem, Supplier, DocumentClassificationType } from '@/types'
|
||||
import type { InvoiceInboxSettings } from '@/extensions/general/invoice-inbox/types'
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Settings, Inbox, CheckCircle2, AlertTriangle, Receipt, FileText, RefreshCw } from 'lucide-react'
|
||||
import DocumentInboxCard from '@/components/extensions/general/document-inbox/DocumentInboxCard'
|
||||
import ReceiptInboxDetail from '@/components/extensions/general/document-inbox/ReceiptInboxDetail'
|
||||
import InboxUploadZone from '@/components/extensions/general/invoice-inbox/InboxUploadZone'
|
||||
import InboxDetailDialog from '@/components/extensions/general/invoice-inbox/InboxDetailDialog'
|
||||
import InboxSettingsDialog from '@/components/extensions/general/invoice-inbox/InboxSettingsDialog'
|
||||
|
||||
type TabValue = 'all' | DocumentClassificationType
|
||||
|
||||
const TABS: { value: TabValue; label: string }[] = [
|
||||
{ value: 'all', label: 'Alla' },
|
||||
{ value: 'supplier_invoice', label: 'Fakturor' },
|
||||
{ value: 'receipt', label: 'Kvitton' },
|
||||
{ value: 'government_letter', label: 'Myndighetspost' },
|
||||
{ value: 'unknown', label: 'Övrigt' },
|
||||
]
|
||||
|
||||
const DEFAULT_SETTINGS: InvoiceInboxSettings = {
|
||||
autoProcessEnabled: true,
|
||||
autoMatchSupplierEnabled: true,
|
||||
supplierMatchThreshold: 0.7,
|
||||
inboxEmail: null,
|
||||
}
|
||||
|
||||
export default function DocumentInboxWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
const [items, setItems] = useState<InvoiceInboxItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState<TabValue>('all')
|
||||
const [selectedItem, setSelectedItem] = useState<InvoiceInboxItem | null>(null)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [settings, setSettings] = useState<InvoiceInboxSettings>(DEFAULT_SETTINGS)
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [suppliers, setSuppliers] = useState<Supplier[]>([])
|
||||
|
||||
const fetchItems = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/extensions/invoice-inbox/inbox')
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
setItems(data ?? [])
|
||||
}
|
||||
} catch {
|
||||
// Silently fail
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchSettings = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/extensions/invoice-inbox/settings')
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
if (data) setSettings(data)
|
||||
}
|
||||
} catch {
|
||||
// Use defaults
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchSuppliers = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/suppliers')
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
setSuppliers(data ?? [])
|
||||
}
|
||||
} catch {
|
||||
// ok
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchItems()
|
||||
fetchSettings()
|
||||
fetchSuppliers()
|
||||
}, [fetchItems, fetchSettings, fetchSuppliers])
|
||||
|
||||
function handleUploadComplete(result: InvoiceInboxItem | InvoiceInboxItem[]) {
|
||||
const newItems = Array.isArray(result) ? result : [result]
|
||||
setItems((prev) => [...newItems, ...prev])
|
||||
for (const item of newItems) {
|
||||
pollItem(item.id)
|
||||
}
|
||||
}
|
||||
|
||||
const [isMatching, setIsMatching] = useState(false)
|
||||
|
||||
async function handleMatchSweep() {
|
||||
setIsMatching(true)
|
||||
try {
|
||||
const res = await fetch('/api/documents/match-sweep', { method: 'POST' })
|
||||
if (res.ok) {
|
||||
await fetchItems()
|
||||
}
|
||||
} catch {
|
||||
// Non-critical
|
||||
} finally {
|
||||
setIsMatching(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function pollItem(itemId: string) {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await new Promise((r) => setTimeout(r, 3000))
|
||||
try {
|
||||
const res = await fetch(`/api/extensions/invoice-inbox/inbox/${itemId}`)
|
||||
if (!res.ok) continue
|
||||
const { data } = await res.json()
|
||||
if (data && data.status !== 'processing') {
|
||||
setItems((prev) =>
|
||||
prev.map((it) => (it.id === itemId ? data : it))
|
||||
)
|
||||
setSelectedItem((current) =>
|
||||
current?.id === itemId ? data : current
|
||||
)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleItemClick(item: InvoiceInboxItem) {
|
||||
setSelectedItem(item)
|
||||
}
|
||||
|
||||
async function handleConfirm(itemId: string, supplierId?: string) {
|
||||
const body: Record<string, string> = {}
|
||||
if (supplierId) body.supplier_id = supplierId
|
||||
|
||||
const res = await fetch(`/api/extensions/invoice-inbox/inbox/${itemId}/confirm`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setItems((prev) =>
|
||||
prev.map((it) => (it.id === itemId ? { ...it, status: 'confirmed' as const } : it))
|
||||
)
|
||||
setSelectedItem(null)
|
||||
fetchSuppliers()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReject(itemId: string) {
|
||||
const res = await fetch(`/api/extensions/invoice-inbox/inbox/${itemId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setItems((prev) =>
|
||||
prev.map((it) => (it.id === itemId ? { ...it, status: 'rejected' as const } : it))
|
||||
)
|
||||
setSelectedItem(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReprocess(itemId: string) {
|
||||
setItems((prev) =>
|
||||
prev.map((it) => (it.id === itemId ? { ...it, status: 'processing' as const } : it))
|
||||
)
|
||||
setSelectedItem(null)
|
||||
|
||||
const res = await fetch(`/api/extensions/invoice-inbox/inbox/${itemId}/process`, {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
if (data) {
|
||||
setItems((prev) => prev.map((it) => (it.id === itemId ? data : it)))
|
||||
}
|
||||
} else {
|
||||
fetchItems()
|
||||
}
|
||||
}
|
||||
|
||||
function handleReceiptConfirm() {
|
||||
fetchItems()
|
||||
setSelectedItem(null)
|
||||
}
|
||||
|
||||
async function handleSaveSettings(updated: InvoiceInboxSettings) {
|
||||
const res = await fetch('/api/extensions/invoice-inbox/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updated),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
if (data) setSettings(data)
|
||||
}
|
||||
}
|
||||
|
||||
const filteredItems =
|
||||
activeTab === 'all'
|
||||
? items
|
||||
: items.filter((it) => (it.document_type ?? 'supplier_invoice') === activeTab)
|
||||
|
||||
const totalPending = items.filter((it) => it.status === 'ready' || it.status === 'pending').length
|
||||
const receiptCount = items.filter((it) => it.document_type === 'receipt' && it.status === 'ready').length
|
||||
const invoiceCount = items.filter((it) => (it.document_type ?? 'supplier_invoice') === 'supplier_invoice' && it.status === 'ready').length
|
||||
|
||||
// Determine which detail dialog to show
|
||||
const isReceiptSelected = selectedItem?.document_type === 'receipt'
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Dokumentinkorg"
|
||||
description="Alla inkommande dokument — fakturor, kvitton och myndighetspost"
|
||||
action={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleMatchSweep}
|
||||
disabled={isMatching}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${isMatching ? 'animate-spin' : ''}`} />
|
||||
Matcha alla
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* KPI cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Inbox className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-medium">{totalPending}</p>
|
||||
<p className="text-xs text-muted-foreground">Att granska</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-warning/15">
|
||||
<FileText className="h-5 w-5 text-warning-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-medium">{invoiceCount}</p>
|
||||
<p className="text-xs text-muted-foreground">Fakturor att granska</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-secondary/50">
|
||||
<Receipt className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-medium">{receiptCount}</p>
|
||||
<p className="text-xs text-muted-foreground">Kvitton att granska</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Upload zone */}
|
||||
<InboxUploadZone
|
||||
onUploadComplete={handleUploadComplete}
|
||||
isUploading={isUploading}
|
||||
setIsUploading={setIsUploading}
|
||||
/>
|
||||
|
||||
{/* Tabs by document type */}
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as TabValue)}>
|
||||
<TabsList>
|
||||
{TABS.map((tab) => (
|
||||
<TabsTrigger key={tab.value} value={tab.value}>
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{TABS.map((tab) => (
|
||||
<TabsContent key={tab.value} value={tab.value}>
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-20 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : filteredItems.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Inbox className="h-10 w-10 text-muted-foreground/40 mb-3" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{activeTab === 'all'
|
||||
? 'Inga dokument ännu. Ladda upp ett dokument ovan eller skicka via e-post.'
|
||||
: 'Inga dokument av denna typ.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filteredItems.map((item) => (
|
||||
<DocumentInboxCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
onClick={() => handleItemClick(item)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
{/* Receipt detail dialog */}
|
||||
{isReceiptSelected && (
|
||||
<ReceiptInboxDetail
|
||||
item={selectedItem}
|
||||
open={selectedItem != null && isReceiptSelected}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSelectedItem(null)
|
||||
}}
|
||||
onConfirm={handleReceiptConfirm}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Invoice/other detail dialog (existing) */}
|
||||
{!isReceiptSelected && (
|
||||
<InboxDetailDialog
|
||||
item={selectedItem}
|
||||
open={selectedItem != null && !isReceiptSelected}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSelectedItem(null)
|
||||
}}
|
||||
onConfirm={handleConfirm}
|
||||
onReject={handleReject}
|
||||
onReprocess={handleReprocess}
|
||||
suppliers={suppliers}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Settings dialog */}
|
||||
<InboxSettingsDialog
|
||||
open={settingsOpen}
|
||||
onOpenChange={setSettingsOpen}
|
||||
settings={settings}
|
||||
onSave={handleSaveSettings}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -87,10 +87,12 @@ export default function InvoiceInboxWorkspace({ userId }: WorkspaceComponentProp
|
||||
fetchSuppliers()
|
||||
}, [fetchItems, fetchSettings, fetchSuppliers])
|
||||
|
||||
function handleUploadComplete(newItem: InvoiceInboxItem) {
|
||||
setItems((prev) => [newItem, ...prev])
|
||||
// Poll for processing completion
|
||||
pollItem(newItem.id)
|
||||
function handleUploadComplete(result: InvoiceInboxItem | InvoiceInboxItem[]) {
|
||||
const newItems = Array.isArray(result) ? result : [result]
|
||||
setItems((prev) => [...newItems, ...prev])
|
||||
for (const item of newItems) {
|
||||
pollItem(item.id)
|
||||
}
|
||||
}
|
||||
|
||||
async function pollItem(itemId: string) {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -2,15 +2,21 @@
|
||||
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import type { InvoiceInboxItem } from '@/types'
|
||||
import { Upload, Loader2, FileUp } from 'lucide-react'
|
||||
import { Upload, Loader2, FileUp, CheckCircle2, AlertCircle } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface InboxUploadZoneProps {
|
||||
onUploadComplete: (item: InvoiceInboxItem) => void
|
||||
onUploadComplete: (item: InvoiceInboxItem | InvoiceInboxItem[]) => void
|
||||
isUploading: boolean
|
||||
setIsUploading: (v: boolean) => void
|
||||
}
|
||||
|
||||
interface FileProgress {
|
||||
name: string
|
||||
status: 'pending' | 'uploading' | 'done' | 'error'
|
||||
error?: string
|
||||
}
|
||||
|
||||
const ACCEPTED_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp']
|
||||
const MAX_SIZE = 10 * 1024 * 1024 // 10 MB
|
||||
|
||||
@@ -21,27 +27,48 @@ export default function InboxUploadZone({
|
||||
}: InboxUploadZoneProps) {
|
||||
const [isDragOver, setIsDragOver] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [fileProgress, setFileProgress] = useState<FileProgress[]>([])
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const uploadFile = useCallback(
|
||||
async (file: File) => {
|
||||
const uploadFiles = useCallback(
|
||||
async (files: File[]) => {
|
||||
setError(null)
|
||||
|
||||
if (!ACCEPTED_TYPES.includes(file.type)) {
|
||||
setError('Filtypen stöds inte. Välj PDF, JPEG, PNG eller WebP.')
|
||||
return
|
||||
// Validate all files first
|
||||
const validFiles: File[] = []
|
||||
for (const file of files) {
|
||||
if (!ACCEPTED_TYPES.includes(file.type)) {
|
||||
setError(`${file.name}: filtypen stöds inte. Välj PDF, JPEG, PNG eller WebP.`)
|
||||
return
|
||||
}
|
||||
if (file.size > MAX_SIZE) {
|
||||
setError(`${file.name}: filen är för stor. Max 10 MB.`)
|
||||
return
|
||||
}
|
||||
validFiles.push(file)
|
||||
}
|
||||
|
||||
if (file.size > MAX_SIZE) {
|
||||
setError('Filen är för stor. Max 10 MB.')
|
||||
return
|
||||
}
|
||||
if (validFiles.length === 0) return
|
||||
|
||||
setIsUploading(true)
|
||||
|
||||
// Show per-file progress for multi-file uploads
|
||||
if (validFiles.length > 1) {
|
||||
setFileProgress(validFiles.map((f) => ({ name: f.name, status: 'uploading' })))
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
if (validFiles.length === 1) {
|
||||
// Single file: use legacy `file` key for backward compat
|
||||
formData.append('file', validFiles[0])
|
||||
} else {
|
||||
// Multiple files: use `files` key
|
||||
for (const file of validFiles) {
|
||||
formData.append('files', file)
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch('/api/extensions/invoice-inbox/inbox', {
|
||||
method: 'POST',
|
||||
@@ -51,13 +78,43 @@ export default function InboxUploadZone({
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ error: 'Uppladdning misslyckades' }))
|
||||
setError(body.error ?? 'Uppladdning misslyckades')
|
||||
if (validFiles.length > 1) {
|
||||
setFileProgress((prev) => prev.map((f) => ({ ...f, status: 'error' as const })))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const { data } = await res.json()
|
||||
onUploadComplete(data)
|
||||
const body = await res.json()
|
||||
|
||||
if (validFiles.length === 1) {
|
||||
onUploadComplete(body.data)
|
||||
setFileProgress([])
|
||||
} else {
|
||||
// Mark individual files
|
||||
const items: InvoiceInboxItem[] = body.data || []
|
||||
const errors: string[] = body.errors || []
|
||||
|
||||
setFileProgress((prev) =>
|
||||
prev.map((fp, i) => {
|
||||
// Check if this file had an error
|
||||
const errMsg = errors.find((e) => e.startsWith(fp.name))
|
||||
if (errMsg) {
|
||||
return { ...fp, status: 'error' as const, error: errMsg }
|
||||
}
|
||||
return { ...fp, status: 'done' as const }
|
||||
})
|
||||
)
|
||||
|
||||
if (items.length > 0) {
|
||||
onUploadComplete(items)
|
||||
}
|
||||
|
||||
// Clear progress after a delay
|
||||
setTimeout(() => setFileProgress([]), 3000)
|
||||
}
|
||||
} catch {
|
||||
setError('Nätverksfel vid uppladdning')
|
||||
setFileProgress([])
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
}
|
||||
@@ -69,10 +126,10 @@ export default function InboxUploadZone({
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragOver(false)
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file) uploadFile(file)
|
||||
const files = Array.from(e.dataTransfer.files)
|
||||
if (files.length > 0) uploadFiles(files)
|
||||
},
|
||||
[uploadFile]
|
||||
[uploadFiles]
|
||||
)
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
@@ -87,12 +144,11 @@ export default function InboxUploadZone({
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) uploadFile(file)
|
||||
// Reset so same file can be re-selected
|
||||
const files = Array.from(e.target.files || [])
|
||||
if (files.length > 0) uploadFiles(files)
|
||||
e.target.value = ''
|
||||
},
|
||||
[uploadFile]
|
||||
[uploadFiles]
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -114,6 +170,7 @@ export default function InboxUploadZone({
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".pdf,.jpg,.jpeg,.png,.webp"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
disabled={isUploading}
|
||||
@@ -127,22 +184,40 @@ export default function InboxUploadZone({
|
||||
) : isDragOver ? (
|
||||
<>
|
||||
<FileUp className="h-8 w-8 text-primary mb-2" />
|
||||
<p className="text-sm font-medium text-primary">Släpp filen här</p>
|
||||
<p className="text-sm font-medium text-primary">Släpp filerna här</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="h-8 w-8 text-muted-foreground/60 mb-2" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Dra och släpp en faktura, eller{' '}
|
||||
<span className="font-medium text-primary">välj fil</span>
|
||||
Dra och släpp fakturor, eller{' '}
|
||||
<span className="font-medium text-primary">välj filer</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/60 mt-1">
|
||||
PDF, JPEG, PNG eller WebP (max 10 MB)
|
||||
PDF, JPEG, PNG eller WebP (max 10 MB per fil)
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{fileProgress.length > 0 && (
|
||||
<div className="mt-3 space-y-1">
|
||||
{fileProgress.map((fp) => (
|
||||
<div key={fp.name} className="flex items-center gap-2 text-sm">
|
||||
{fp.status === 'uploading' && <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />}
|
||||
{fp.status === 'done' && <CheckCircle2 className="h-3.5 w-3.5 text-green-500" />}
|
||||
{fp.status === 'error' && <AlertCircle className="h-3.5 w-3.5 text-destructive" />}
|
||||
<span className={cn(
|
||||
'truncate',
|
||||
fp.status === 'error' && 'text-destructive'
|
||||
)}>
|
||||
{fp.name}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-destructive mt-2">{error}</p>
|
||||
)}
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -15,6 +15,7 @@ const TYPE_COLORS: Record<AccountType, string> = {
|
||||
equity: 'bg-blue-500',
|
||||
revenue: 'bg-purple-500',
|
||||
expense: 'bg-red-500',
|
||||
untaxed_reserves: 'bg-amber-500',
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<AccountType, string> = {
|
||||
@@ -23,6 +24,7 @@ const TYPE_LABELS: Record<AccountType, string> = {
|
||||
equity: 'Eget kapital',
|
||||
revenue: 'Intäkt',
|
||||
expense: 'Kostnad',
|
||||
untaxed_reserves: 'Obeskattade reserver',
|
||||
}
|
||||
|
||||
interface AccountNumberProps {
|
||||
|
||||
Reference in New Issue
Block a user