feat: remove AI extensions, restructure settings, and add atomic voucher commits (#157)

Remove AI-dependent extensions (ai-chat, ai-categorization, receipt-ocr,
invoice-inbox) and their infrastructure (lib/ai/*, ai-consent, LangChain/
Anthropic/OpenAI deps) to simplify core and reduce bundle size.

Restructure monolithic settings page into dedicated sub-pages (company,
bookkeeping, invoicing, tax, banking, api, account, team, templates) with
shared layout and sidebar navigation.

Add atomic commit_journal_entry RPC so voucher number increment and status
update happen in a single transaction — prevents burned numbers on constraint
failures. Add continuity check report and voucher gap explanation tracking.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-04-01 17:08:00 +02:00
committed by GitHub
co-authored by Claude Opus 4.6
parent e89f2c402d
commit d0b3f21bde
172 changed files with 3621 additions and 20538 deletions
-102
View File
@@ -1,102 +0,0 @@
'use client'
import { useState } from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { AI_DATA_DISCLOSURES, type AiExtensionId } from '@/lib/extensions/ai-consent'
import Link from 'next/link'
interface AiConsentDialogProps {
extensionId: AiExtensionId
open: boolean
onOpenChange: (open: boolean) => void
onConsented: () => void
}
export function AiConsentDialog({
extensionId,
open,
onOpenChange,
onConsented,
}: AiConsentDialogProps) {
const [isSubmitting, setIsSubmitting] = useState(false)
const disclosure = AI_DATA_DISCLOSURES[extensionId]
async function handleAccept() {
setIsSubmitting(true)
try {
const res = await fetch('/api/ai-consent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ extension_id: extensionId }),
})
if (res.ok) {
onOpenChange(false)
onConsented()
}
} finally {
setIsSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>AI-samtycke krävs</DialogTitle>
<DialogDescription>
Denna funktion använder AI-tjänster från externa leverantörer.
Granska informationen nedan innan du fortsätter.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<p className="text-sm font-medium mb-1">Leverantör</p>
<p className="text-sm text-muted-foreground">{disclosure.provider}</p>
</div>
<div>
<p className="text-sm font-medium mb-1">Data som skickas</p>
<ul className="text-sm text-muted-foreground list-disc pl-5 space-y-1">
{disclosure.dataTypes.map((dt) => (
<li key={dt}>{dt}</li>
))}
</ul>
</div>
<div>
<p className="text-sm font-medium mb-1">Syfte</p>
<p className="text-sm text-muted-foreground">{disclosure.purpose}</p>
</div>
<p className="text-xs text-muted-foreground">
Läs mer i vår{' '}
<Link href="/privacy" className="underline underline-offset-4" target="_blank">
integritetspolicy
</Link>
. Du kan när som helst återkalla ditt samtycke i inställningarna.
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
Avbryt
</Button>
<Button onClick={handleAccept} disabled={isSubmitting}>
{isSubmitting ? 'Sparar...' : 'Jag samtycker'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -1,12 +0,0 @@
'use client'
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
import { ChatPanel } from '@/components/chat/ChatPanel'
export default function AiChatWorkspace({ userId }: WorkspaceComponentProps) {
return (
<div className="h-[calc(100vh-10rem)] max-w-4xl mx-auto">
<ChatPanel className="h-full rounded-lg border border-border bg-background shadow-sm" />
</div>
)
}
@@ -1,371 +0,0 @@
'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/ext/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/ext/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/ext/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/ext/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/ext/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/ext/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/ext/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>
)
}
@@ -14,7 +14,7 @@ export default function EnableBankingWorkspace({ userId }: WorkspaceComponentPro
Koppla ditt bankkonto under Inställningar för att synka transaktioner automatiskt.
</p>
<Button asChild variant="outline" className="mt-4">
<Link href="/settings?tab=banking">
<Link href="/settings/banking">
<Settings className="mr-2 h-4 w-4" />
Gå till bankinställningar
</Link>
@@ -1,328 +0,0 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
import type { InvoiceInboxItem, Supplier, InboxItemStatus } 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 } from 'lucide-react'
import InboxItemCard from '@/components/extensions/general/invoice-inbox/InboxItemCard'
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' | InboxItemStatus
const TABS: { value: TabValue; label: string }[] = [
{ value: 'all', label: 'Alla' },
{ value: 'pending', label: 'Ny' },
{ value: 'ready', label: 'Klar' },
{ value: 'confirmed', label: 'Bekräftad' },
{ value: 'rejected', label: 'Avvisad' },
{ value: 'error', label: 'Fel' },
]
const DEFAULT_SETTINGS: InvoiceInboxSettings = {
autoProcessEnabled: true,
autoMatchSupplierEnabled: true,
supplierMatchThreshold: 0.7,
inboxEmail: null,
}
export default function InvoiceInboxWorkspace({ 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/ext/invoice-inbox/inbox')
if (res.ok) {
const { data } = await res.json()
setItems(data ?? [])
}
} catch {
// Silently fail — user sees empty state
} finally {
setLoading(false)
}
}, [])
const fetchSettings = useCallback(async () => {
try {
const res = await fetch('/api/extensions/ext/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)
}
}
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/ext/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))
)
// Also update the detail dialog if it's open for this item
setSelectedItem((current) =>
current?.id === itemId ? data : current
)
return
}
} catch {
// continue polling
}
}
}
async function handleConfirm(
itemId: string,
supplierId?: string,
newSupplierData?: import('@/components/extensions/general/invoice-inbox/InboxDetailDialog').NewSupplierData
) {
const body: Record<string, unknown> = {}
if (supplierId) {
body.supplier_id = supplierId
} else if (newSupplierData) {
body.new_supplier = newSupplierData
}
const res = await fetch(`/api/extensions/ext/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() // New supplier may have been created
}
}
async function handleReject(itemId: string) {
const res = await fetch(`/api/extensions/ext/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/ext/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 {
// Refetch in case of error update
fetchItems()
}
}
async function handleSaveSettings(updated: InvoiceInboxSettings) {
const res = await fetch('/api/extensions/ext/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.status === activeTab)
const totalCount = items.length
const readyCount = items.filter((it) => it.status === 'ready').length
const errorCount = items.filter((it) => it.status === 'error').length
return (
<div className="space-y-6">
<PageHeader
title="Fakturainkorgen"
description="Ladda upp leverantörsfakturor och låt AI extrahera data automatiskt"
action={
<Button
variant="outline"
size="icon"
onClick={() => setSettingsOpen(true)}
>
<Settings className="h-4 w-4" />
</Button>
}
/>
{/* 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">{totalCount}</p>
<p className="text-xs text-muted-foreground">Totalt</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">
<CheckCircle2 className="h-5 w-5 text-warning-foreground" />
</div>
<div>
<p className="text-2xl font-medium">{readyCount}</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-destructive/10">
<AlertTriangle className="h-5 w-5 text-destructive" />
</div>
<div>
<p className="text-2xl font-medium">{errorCount}</p>
<p className="text-xs text-muted-foreground">Fel</p>
</div>
</CardContent>
</Card>
</div>
{/* Upload zone */}
<InboxUploadZone
onUploadComplete={handleUploadComplete}
isUploading={isUploading}
setIsUploading={setIsUploading}
/>
{/* Tabs + item list */}
<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 fakturor ännu. Ladda upp en faktura ovan.'
: 'Inga fakturor med denna status.'}
</p>
</div>
) : (
<div className="space-y-2">
{filteredItems.map((item) => (
<InboxItemCard
key={item.id}
item={item}
onClick={() => setSelectedItem(item)}
/>
))}
</div>
)}
</TabsContent>
))}
</Tabs>
{/* Detail dialog */}
<InboxDetailDialog
item={selectedItem}
open={selectedItem != null}
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>
)
}
@@ -1,150 +0,0 @@
'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 formatAmount(amount: number, currency: string = 'SEK'): string {
return new Intl.NumberFormat('sv-SE', {
style: 'currency',
currency,
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; currency: string } {
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,
currency: summary.currency,
}
}
case 'receipt': {
const receipt = item.receipt as { merchant_name?: string; total_amount?: number; currency?: string } | undefined
return {
label: receipt?.merchant_name ?? 'Okänd handlare',
total: receipt?.total_amount ?? 0,
currency: receipt?.currency || 'SEK',
}
}
case 'government_letter': {
return {
label: item.email_from ?? 'Okänd avsändare',
total: 0,
currency: 'SEK',
}
}
default: {
return { label: 'Granska manuellt', total: 0, currency: 'SEK' }
}
}
}
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, currency } = 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">{formatAmount(total, currency)}</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>
)
}
@@ -1,311 +0,0 @@
'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 formatAmount(amount: number, currency: string = 'SEK'): string {
return new Intl.NumberFormat('sv-SE', {
style: 'currency',
currency,
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/ext/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/ext/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 ? formatAmount(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-success" />
<span className="text-success">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">
{formatAmount(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: {formatAmount(Math.round(businessTotal * 100) / 100)}</Badge>
<Badge variant="secondary">Privat: {formatAmount(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>
)
}
@@ -1,454 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
import type { InvoiceInboxItem, Supplier, SupplierType } from '@/types'
import type { InvoiceExtractionResult } from '@/extensions/general/invoice-inbox/types'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Progress } from '@/components/ui/progress'
import {
getStatusLabel,
getStatusVariant,
getConfidenceLabel,
} from '@/lib/extensions/invoice-inbox-utils'
import { Loader2, RefreshCw, Check, X, ChevronDown } from 'lucide-react'
export interface NewSupplierData {
name: string
supplier_type: SupplierType
org_number: string
vat_number: string
bankgiro: string
plusgiro: string
default_expense_account: string
default_currency: string
}
interface InboxDetailDialogProps {
item: InvoiceInboxItem | null
open: boolean
onOpenChange: (open: boolean) => void
onConfirm: (itemId: string, supplierId?: string, newSupplierData?: NewSupplierData) => Promise<void>
onReject: (itemId: string) => Promise<void>
onReprocess: (itemId: string) => Promise<void>
suppliers: Supplier[]
}
function formatAmount(amount: number | null, currency: string = 'SEK'): string {
if (amount == null) return '-'
return new Intl.NumberFormat('sv-SE', {
style: 'currency',
currency,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount)
}
export default function InboxDetailDialog({
item,
open,
onOpenChange,
onConfirm,
onReject,
onReprocess,
suppliers,
}: InboxDetailDialogProps) {
const [loading, setLoading] = useState<'confirm' | 'reject' | 'reprocess' | null>(null)
const [selectedSupplierId, setSelectedSupplierId] = useState<string | undefined>(undefined)
const [supplierFormOpen, setSupplierFormOpen] = useState(false)
const [newSupplier, setNewSupplier] = useState<NewSupplierData>({
name: '',
supplier_type: 'swedish_business',
org_number: '',
vat_number: '',
bankgiro: '',
plusgiro: '',
default_expense_account: '6200',
default_currency: 'SEK',
})
// Pre-populate supplier form when item changes
const extractionForEffect = item?.extracted_data as unknown as InvoiceExtractionResult | null
useEffect(() => {
if (!extractionForEffect?.supplier) return
const s = extractionForEffect.supplier
setNewSupplier({
name: s.name ?? '',
supplier_type: 'swedish_business',
org_number: s.orgNumber ?? '',
vat_number: s.vatNumber ?? '',
bankgiro: s.bankgiro ?? '',
plusgiro: s.plusgiro ?? '',
default_expense_account: '6200',
default_currency: extractionForEffect.invoice?.currency || 'SEK',
})
}, [extractionForEffect])
if (!item) return null
const extraction = item.extracted_data as unknown as InvoiceExtractionResult | null
const currency = extraction?.invoice.currency || 'SEK'
const confidence = getConfidenceLabel(item.confidence)
const confidenceVariant = confidence.variant as 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning'
const statusVariant = getStatusVariant(item.status) as 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning'
const matchedSupplierName = (item.supplier as { name?: string } | undefined)?.name
const supplierId = selectedSupplierId ?? item.matched_supplier_id ?? undefined
const isCreatingNewSupplier = !supplierId
const canConfirm = item.status === 'ready' && extraction != null
const canReprocess = item.status !== 'confirmed'
const canReject = item.status !== 'confirmed' && item.status !== 'rejected'
function updateSupplierField<K extends keyof NewSupplierData>(field: K, value: NewSupplierData[K]) {
setNewSupplier((prev) => ({ ...prev, [field]: value }))
}
async function handleAction(action: 'confirm' | 'reject' | 'reprocess') {
setLoading(action)
try {
if (action === 'confirm') {
await onConfirm(
item!.id,
supplierId,
isCreatingNewSupplier ? newSupplier : undefined
)
} else if (action === 'reject') {
await onReject(item!.id)
} else {
await onReprocess(item!.id)
}
} finally {
setLoading(null)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl max-h-[95dvh] sm:max-h-[85vh] overflow-y-auto">
<DialogHeader>
<div className="flex items-center gap-2">
<DialogTitle>Granska faktura</DialogTitle>
<Badge variant={statusVariant}>
{getStatusLabel(item.status)}
</Badge>
</div>
</DialogHeader>
{/* Confidence */}
{item.confidence != null && (
<div className="space-y-1.5">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">AI-konfidens</span>
<Badge variant={confidenceVariant}>{confidence.label} ({Math.round(item.confidence * 100)}%)</Badge>
</div>
<Progress value={item.confidence * 100} className="h-1.5" />
</div>
)}
{item.error_message && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{item.error_message}
</div>
)}
{extraction && (
<>
<Separator />
{/* Supplier info */}
<div className="space-y-3">
<h4 className="text-sm font-medium">Leverantör</h4>
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<div>
<span className="text-muted-foreground">Namn</span>
<p className="font-medium">{extraction.supplier.name ?? '-'}</p>
</div>
<div>
<span className="text-muted-foreground">Org.nr</span>
<p className="font-medium">{extraction.supplier.orgNumber ?? '-'}</p>
</div>
{extraction.supplier.bankgiro && (
<div>
<span className="text-muted-foreground">Bankgiro</span>
<p className="font-medium">{extraction.supplier.bankgiro}</p>
</div>
)}
{extraction.supplier.plusgiro && (
<div>
<span className="text-muted-foreground">Plusgiro</span>
<p className="font-medium">{extraction.supplier.plusgiro}</p>
</div>
)}
</div>
{/* Supplier match override */}
<div className="space-y-1.5">
<label className="text-sm text-muted-foreground">
Matchad leverantör
{matchedSupplierName && (
<span className="ml-1 text-xs">
(auto: {matchedSupplierName})
</span>
)}
</label>
<Select
value={supplierId ?? '__new__'}
onValueChange={(v) => setSelectedSupplierId(v === '__new__' ? undefined : v)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Skapa ny leverantör" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__new__">Skapa ny leverantör</SelectItem>
{suppliers.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
{s.org_number ? ` (${s.org_number})` : ''}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Inline new supplier form */}
{isCreatingNewSupplier && (
<div className="rounded-md border bg-muted/30 p-3 space-y-3">
<button
type="button"
className="flex w-full items-center justify-between text-sm font-medium"
onClick={() => setSupplierFormOpen((o) => !o)}
>
<span>Granska leverantörsuppgifter</span>
<ChevronDown className={`h-4 w-4 transition-transform ${supplierFormOpen ? 'rotate-180' : ''}`} />
</button>
{supplierFormOpen && (
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2 space-y-1">
<Label className="text-xs">Namn</Label>
<Input
value={newSupplier.name}
onChange={(e) => updateSupplierField('name', e.target.value)}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Typ</Label>
<Select
value={newSupplier.supplier_type}
onValueChange={(v) => updateSupplierField('supplier_type', v as SupplierType)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="swedish_business">Svenskt företag</SelectItem>
<SelectItem value="eu_business">EU-företag</SelectItem>
<SelectItem value="non_eu_business">Utomeuropeiskt</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">Org.nr</Label>
<Input
value={newSupplier.org_number}
onChange={(e) => updateSupplierField('org_number', e.target.value)}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Bankgiro</Label>
<Input
value={newSupplier.bankgiro}
onChange={(e) => updateSupplierField('bankgiro', e.target.value)}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Plusgiro</Label>
<Input
value={newSupplier.plusgiro}
onChange={(e) => updateSupplierField('plusgiro', e.target.value)}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Kostnadskonto</Label>
<Input
value={newSupplier.default_expense_account}
onChange={(e) => updateSupplierField('default_expense_account', e.target.value)}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Valuta</Label>
<Input
value={newSupplier.default_currency}
onChange={(e) => updateSupplierField('default_currency', e.target.value)}
/>
</div>
</div>
)}
</div>
)}
</div>
<Separator />
{/* Invoice details */}
<div className="space-y-3">
<h4 className="text-sm font-medium">Fakturadetaljer</h4>
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<div>
<span className="text-muted-foreground">Fakturanummer</span>
<p className="font-medium">{extraction.invoice.invoiceNumber ?? '-'}</p>
</div>
<div>
<span className="text-muted-foreground">Valuta</span>
<p className="font-medium">{extraction.invoice.currency}</p>
</div>
<div>
<span className="text-muted-foreground">Fakturadatum</span>
<p className="font-medium">{extraction.invoice.invoiceDate ?? '-'}</p>
</div>
<div>
<span className="text-muted-foreground">Förfallodatum</span>
<p className="font-medium">{extraction.invoice.dueDate ?? '-'}</p>
</div>
{extraction.invoice.paymentReference && (
<div className="col-span-2">
<span className="text-muted-foreground">Betalningsreferens</span>
<p className="font-medium font-mono">{extraction.invoice.paymentReference}</p>
</div>
)}
</div>
</div>
{/* Line items */}
{extraction.lineItems.length > 0 && (
<>
<Separator />
<div className="space-y-3">
<h4 className="text-sm font-medium">Rader</h4>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Beskrivning</TableHead>
<TableHead className="text-right w-16">Antal</TableHead>
<TableHead className="text-right w-24">À-pris</TableHead>
<TableHead className="text-right w-24">Belopp</TableHead>
<TableHead className="text-right w-16">Moms</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{extraction.lineItems.map((line, i) => (
<TableRow key={i}>
<TableCell className="text-sm">{line.description}</TableCell>
<TableCell className="text-right text-sm">{line.quantity}</TableCell>
<TableCell className="text-right text-sm">
{line.unitPrice != null ? formatAmount(line.unitPrice, currency) : '-'}
</TableCell>
<TableCell className="text-right text-sm font-medium">
{formatAmount(line.lineTotal, currency)}
</TableCell>
<TableCell className="text-right text-sm">
{line.vatRate != null ? `${line.vatRate}%` : '-'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</>
)}
<Separator />
{/* Totals */}
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Netto</span>
<span>{formatAmount(extraction.totals.subtotal, currency)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Moms</span>
<span>{formatAmount(extraction.totals.vatAmount, currency)}</span>
</div>
<Separator />
<div className="flex justify-between font-medium text-base">
<span>Totalt</span>
<span>{formatAmount(extraction.totals.total, currency)}</span>
</div>
</div>
</>
)}
<DialogFooter className="gap-2 sm:gap-0">
{canReject && (
<Button
variant="outline"
onClick={() => handleAction('reject')}
disabled={loading !== null}
>
{loading === 'reject' ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<X className="h-4 w-4 mr-2" />
)}
Avvisa
</Button>
)}
{canReprocess && (
<Button
variant="outline"
onClick={() => handleAction('reprocess')}
disabled={loading !== null}
>
{loading === 'reprocess' ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<RefreshCw className="h-4 w-4 mr-2" />
)}
Bearbeta igen
</Button>
)}
{canConfirm && (
<Button
onClick={() => handleAction('confirm')}
disabled={loading !== null}
>
{loading === 'confirm' ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<Check className="h-4 w-4 mr-2" />
)}
Bekräfta
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -1,101 +0,0 @@
'use client'
import type { InvoiceInboxItem } 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,
} from '@/lib/extensions/invoice-inbox-utils'
import { Mail, Upload, FileText } from 'lucide-react'
interface InboxItemCardProps {
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 formatAmount(amount: number, currency: string = 'SEK'): string {
return new Intl.NumberFormat('sv-SE', {
style: 'currency',
currency,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount)
}
export default function InboxItemCard({ item, onClick }: InboxItemCardProps) {
const summary = formatExtractionSummary(item.extracted_data as unknown as InvoiceExtractionResult | null)
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 fileName = (item.document as { file_name?: string } | undefined)?.file_name ?? 'Okänd fil'
const supplierName = (item.supplier as { name?: string } | undefined)?.name ?? summary.supplierName
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">
<FileText className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span className="text-sm font-medium truncate">{fileName}</span>
</div>
<div className="flex items-center gap-2 mt-0.5">
{supplierName ? (
<span className="text-sm text-muted-foreground truncate">{supplierName}</span>
) : (
<span className="text-sm text-muted-foreground/60 italic">Okänd leverantör</span>
)}
</div>
</div>
<div className="flex flex-col items-end gap-1 shrink-0">
{summary.total > 0 && (
<span className="text-sm font-medium">{formatAmount(summary.total, summary.currency)}</span>
)}
<div className="flex items-center gap-1.5">
{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>
)
}
@@ -1,159 +0,0 @@
'use client'
import { useState } from 'react'
import type { InvoiceInboxSettings } from '@/extensions/general/invoice-inbox/types'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Loader2, Copy, Check } from 'lucide-react'
interface InboxSettingsDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
settings: InvoiceInboxSettings
onSave: (settings: InvoiceInboxSettings) => Promise<void>
}
export default function InboxSettingsDialog({
open,
onOpenChange,
settings,
onSave,
}: InboxSettingsDialogProps) {
const [local, setLocal] = useState<InvoiceInboxSettings>(settings)
const [saving, setSaving] = useState(false)
const [copied, setCopied] = useState(false)
// Reset local state when dialog opens with new settings
function handleOpenChange(isOpen: boolean) {
if (isOpen) {
setLocal(settings)
}
onOpenChange(isOpen)
}
async function handleSave() {
setSaving(true)
try {
await onSave(local)
onOpenChange(false)
} finally {
setSaving(false)
}
}
function handleCopyEmail() {
if (local.inboxEmail) {
navigator.clipboard.writeText(local.inboxEmail)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Inställningar</DialogTitle>
</DialogHeader>
<div className="space-y-6 py-2">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Automatisk bearbetning</Label>
<p className="text-xs text-muted-foreground">
Analysera fakturor automatiskt vid uppladdning
</p>
</div>
<Switch
checked={local.autoProcessEnabled}
onCheckedChange={(checked) =>
setLocal((prev) => ({ ...prev, autoProcessEnabled: checked }))
}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Automatisk leverantörsmatchning</Label>
<p className="text-xs text-muted-foreground">
Matcha extraherade uppgifter mot befintliga leverantörer
</p>
</div>
<Switch
checked={local.autoMatchSupplierEnabled}
onCheckedChange={(checked) =>
setLocal((prev) => ({ ...prev, autoMatchSupplierEnabled: checked }))
}
/>
</div>
<div className="space-y-2">
<Label>Matchningströskel</Label>
<p className="text-xs text-muted-foreground">
Lägsta konfidens för automatisk leverantörsmatchning (0-1)
</p>
<Input
type="number"
min={0}
max={1}
step={0.05}
value={local.supplierMatchThreshold}
onChange={(e) =>
setLocal((prev) => ({
...prev,
supplierMatchThreshold: Math.min(1, Math.max(0, parseFloat(e.target.value) || 0)),
}))
}
/>
</div>
{local.inboxEmail && (
<div className="space-y-2">
<Label>Inkorg-e-post</Label>
<p className="text-xs text-muted-foreground">
Vidarebefodra fakturor till denna adress
</p>
<div className="flex gap-2">
<Input
value={local.inboxEmail}
readOnly
className="font-mono text-xs"
/>
<Button
variant="outline"
size="icon"
onClick={handleCopyEmail}
>
{copied ? (
<Check className="h-4 w-4 text-success" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Avbryt
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
Spara
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -1,226 +0,0 @@
'use client'
import { useCallback, useRef, useState } from 'react'
import type { InvoiceInboxItem } from '@/types'
import { Upload, Loader2, FileUp, CheckCircle2, AlertCircle } from 'lucide-react'
import { cn } from '@/lib/utils'
interface InboxUploadZoneProps {
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
export default function InboxUploadZone({
onUploadComplete,
isUploading,
setIsUploading,
}: InboxUploadZoneProps) {
const [isDragOver, setIsDragOver] = useState(false)
const [error, setError] = useState<string | null>(null)
const [fileProgress, setFileProgress] = useState<FileProgress[]>([])
const inputRef = useRef<HTMLInputElement>(null)
const uploadFiles = useCallback(
async (files: File[]) => {
setError(null)
// 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 (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()
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/ext/invoice-inbox/inbox', {
method: 'POST',
body: formData,
})
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 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)
}
},
[onUploadComplete, setIsUploading]
)
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
setIsDragOver(false)
const files = Array.from(e.dataTransfer.files)
if (files.length > 0) uploadFiles(files)
},
[uploadFiles]
)
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragOver(true)
}, [])
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragOver(false)
}, [])
const handleFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || [])
if (files.length > 0) uploadFiles(files)
e.target.value = ''
},
[uploadFiles]
)
return (
<div>
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() => !isUploading && inputRef.current?.click()}
className={cn(
'relative flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-6 transition-colors cursor-pointer',
isDragOver
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/50 hover:bg-accent/30',
isUploading && 'pointer-events-none opacity-60'
)}
>
<input
ref={inputRef}
type="file"
accept=".pdf,.jpg,.jpeg,.png,.webp"
multiple
className="hidden"
onChange={handleFileSelect}
disabled={isUploading}
/>
{isUploading ? (
<>
<Loader2 className="h-8 w-8 text-primary animate-spin mb-2" />
<p className="text-sm text-muted-foreground">Laddar upp och analyserar...</p>
</>
) : isDragOver ? (
<>
<FileUp className="h-8 w-8 text-primary mb-2" />
<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 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 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-success" />}
{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>
)}
</div>
)
}