refactor: consolidate AI analyzers into shared lib/ai module and add journal entry reversal columns
Extract shared vision/document analysis logic into lib/ai (vision-client, document-analyzer, image preprocessing, validation helpers), simplifying invoice-analyzer, receipt-analyzer, and document classifier. Add migration 046 for journal entry reversal/correction link columns (reversed_by_id, reverses_id, correction_of_id) required by storno service. Update extension components and shared UI. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
6d647b956c
commit
3f255ea201
@@ -24,8 +24,8 @@ export default function ExtensionWorkspaceLoader({
|
||||
<WorkspaceComponent userId={userId} />
|
||||
) : (
|
||||
<EmptyExtensionState
|
||||
title="Kommer snart"
|
||||
description={`${definition.name} \u00e4r under utveckling och kommer snart att vara tillg\u00e4ngligt.`}
|
||||
title="Bakgrundstjänst"
|
||||
description={`${definition.name} körs i bakgrunden och har ingen egen vy. Du kan hantera inställningar under Inställningar.`}
|
||||
/>
|
||||
)}
|
||||
</ExtensionWorkspaceShell>
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function ExtensionWorkspaceShell({
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-1.5 text-sm text-muted-foreground mb-6">
|
||||
<Link href="/extensions" className="hover:text-foreground transition-colors">
|
||||
Till\u00e4gg
|
||||
Tillägg
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<Link
|
||||
|
||||
@@ -8,7 +8,7 @@ export default function AiCategorizationWorkspace({ userId }: WorkspaceComponent
|
||||
return (
|
||||
<EmptyExtensionState
|
||||
title="AI-kategorisering"
|
||||
description="AI-kategorisering k\u00f6rs automatiskt n\u00e4r nya transaktioner synkas. G\u00e5 till Transaktioner f\u00f6r att se f\u00f6rslag."
|
||||
description="AI-kategorisering körs automatiskt när nya transaktioner synkas. Gå till Transaktioner för att se förslag."
|
||||
icon={<Sparkles className="h-12 w-12 text-muted-foreground/40 mb-4" />}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ export default function AiChatWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
return (
|
||||
<EmptyExtensionState
|
||||
title="AI-assistent"
|
||||
description="Anv\u00e4nd chattwidgeten i nedre h\u00f6gra h\u00f6rnet f\u00f6r att st\u00e4lla fr\u00e5gor om bokf\u00f6ring och skatt."
|
||||
description="Använd chattwidgeten i nedre högra hörnet för att ställa frågor om bokföring och skatt."
|
||||
icon={<MessageSquare className="h-12 w-12 text-muted-foreground/40 mb-4" />}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ export default function PushNotificationsWorkspace({ userId }: WorkspaceComponen
|
||||
return (
|
||||
<EmptyExtensionState
|
||||
title="Push-notiser"
|
||||
description="Konfigurera push-notiser under Inst\u00e4llningar. Notiser skickas automatiskt vid viktiga h\u00e4ndelser."
|
||||
description="Konfigurera push-notiser under Inställningar. Notiser skickas automatiskt vid viktiga händelser."
|
||||
icon={<Bell className="h-12 w-12 text-muted-foreground/40 mb-4" />}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ export default function ReceiptOcrWorkspace({ userId }: WorkspaceComponentProps)
|
||||
return (
|
||||
<EmptyExtensionState
|
||||
title="Kvittoscanning"
|
||||
description="Ladda upp och skanna kvitton direkt fr\u00e5n till\u00e4ggets arbetsyta. G\u00e5 till Kvitton i sidomenyn f\u00f6r att komma ig\u00e5ng."
|
||||
description="Ladda upp och skanna kvitton direkt från tillägets arbetsyta. Gå till Kvitton i sidomenyn för att komma igång."
|
||||
icon={<Camera className="h-12 w-12 text-muted-foreground/40 mb-4" />}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -33,10 +33,10 @@ function formatRelativeTime(dateStr: string): string {
|
||||
return `${diffD} dagar sedan`
|
||||
}
|
||||
|
||||
function formatSEK(amount: number): string {
|
||||
function formatAmount(amount: number, currency: string = 'SEK'): string {
|
||||
return new Intl.NumberFormat('sv-SE', {
|
||||
style: 'currency',
|
||||
currency: 'SEK',
|
||||
currency,
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount)
|
||||
@@ -53,7 +53,7 @@ function getDocumentIcon(type: DocumentClassificationType) {
|
||||
}
|
||||
}
|
||||
|
||||
function getSummaryText(item: InvoiceInboxItem): { label: string; total: number } {
|
||||
function getSummaryText(item: InvoiceInboxItem): { label: string; total: number; currency: string } {
|
||||
const type = item.document_type ?? 'supplier_invoice'
|
||||
|
||||
switch (type) {
|
||||
@@ -63,23 +63,26 @@ function getSummaryText(item: InvoiceInboxItem): { label: string; total: number
|
||||
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 } | undefined
|
||||
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 }
|
||||
return { label: 'Granska manuellt', total: 0, currency: 'SEK' }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,7 +95,7 @@ export default function DocumentInboxCard({ item, onClick }: DocumentInboxCardPr
|
||||
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)
|
||||
const { label: summaryLabel, total, currency } = getSummaryText(item)
|
||||
|
||||
return (
|
||||
<Card
|
||||
@@ -122,7 +125,7 @@ export default function DocumentInboxCard({ item, onClick }: DocumentInboxCardPr
|
||||
|
||||
<div className="flex flex-col items-end gap-1 shrink-0">
|
||||
{total > 0 && (
|
||||
<span className="text-sm font-medium">{formatSEK(total)}</span>
|
||||
<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">
|
||||
|
||||
@@ -34,10 +34,10 @@ interface ReceiptInboxDetailProps {
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
function formatSEK(amount: number): string {
|
||||
function formatAmount(amount: number, currency: string = 'SEK'): string {
|
||||
return new Intl.NumberFormat('sv-SE', {
|
||||
style: 'currency',
|
||||
currency: 'SEK',
|
||||
currency,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount)
|
||||
@@ -180,7 +180,7 @@ export default function ReceiptInboxDetail({
|
||||
<div>
|
||||
<span className="text-muted-foreground">Totalbelopp</span>
|
||||
<p className="font-medium">
|
||||
{receipt.total_amount ? formatSEK(receipt.total_amount) : '-'}
|
||||
{receipt.total_amount ? formatAmount(receipt.total_amount) : '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
@@ -217,7 +217,7 @@ export default function ReceiptInboxDetail({
|
||||
<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)}
|
||||
{formatAmount(li.line_total)}
|
||||
{li.vat_rate != null && ` (${li.vat_rate}% moms)`}
|
||||
</p>
|
||||
</div>
|
||||
@@ -237,8 +237,8 @@ export default function ReceiptInboxDetail({
|
||||
{/* 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>
|
||||
<Badge variant="default">Företag: {formatAmount(Math.round(businessTotal * 100) / 100)}</Badge>
|
||||
<Badge variant="secondary">Privat: {formatAmount(Math.round(privateTotal * 100) / 100)}</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -46,11 +46,11 @@ interface InboxDetailDialogProps {
|
||||
suppliers: Supplier[]
|
||||
}
|
||||
|
||||
function formatSEK(amount: number | null): string {
|
||||
function formatAmount(amount: number | null, currency: string = 'SEK'): string {
|
||||
if (amount == null) return '-'
|
||||
return new Intl.NumberFormat('sv-SE', {
|
||||
style: 'currency',
|
||||
currency: 'SEK',
|
||||
currency,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount)
|
||||
@@ -71,6 +71,7 @@ export default function InboxDetailDialog({
|
||||
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'
|
||||
@@ -240,10 +241,10 @@ export default function InboxDetailDialog({
|
||||
<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 ? formatSEK(line.unitPrice) : '-'}
|
||||
{line.unitPrice != null ? formatAmount(line.unitPrice, currency) : '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm font-medium">
|
||||
{formatSEK(line.lineTotal)}
|
||||
{formatAmount(line.lineTotal, currency)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm">
|
||||
{line.vatRate != null ? `${line.vatRate}%` : '-'}
|
||||
@@ -263,16 +264,16 @@ export default function InboxDetailDialog({
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Netto</span>
|
||||
<span>{formatSEK(extraction.totals.subtotal)}</span>
|
||||
<span>{formatAmount(extraction.totals.subtotal, currency)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Moms</span>
|
||||
<span>{formatSEK(extraction.totals.vatAmount)}</span>
|
||||
<span>{formatAmount(extraction.totals.vatAmount, currency)}</span>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex justify-between font-medium text-base">
|
||||
<span>Totalt</span>
|
||||
<span>{formatSEK(extraction.totals.total)}</span>
|
||||
<span>{formatAmount(extraction.totals.total, currency)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -31,18 +31,17 @@ function formatRelativeTime(dateStr: string): string {
|
||||
return `${diffD} dagar sedan`
|
||||
}
|
||||
|
||||
function formatSEK(amount: number): string {
|
||||
function formatAmount(amount: number, currency: string = 'SEK'): string {
|
||||
return new Intl.NumberFormat('sv-SE', {
|
||||
style: 'currency',
|
||||
currency: 'SEK',
|
||||
currency,
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
export default function InboxItemCard({ item, onClick }: InboxItemCardProps) {
|
||||
const extraction = item.extracted_data as unknown as InvoiceExtractionResult | null
|
||||
const summary = formatExtractionSummary(extraction)
|
||||
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'
|
||||
@@ -80,7 +79,7 @@ export default function InboxItemCard({ item, onClick }: InboxItemCardProps) {
|
||||
|
||||
<div className="flex flex-col items-end gap-1 shrink-0">
|
||||
{summary.total > 0 && (
|
||||
<span className="text-sm font-medium">{formatSEK(summary.total)}</span>
|
||||
<span className="text-sm font-medium">{formatAmount(summary.total, summary.currency)}</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{item.confidence != null && (
|
||||
|
||||
@@ -43,9 +43,9 @@ export default function DateRangeFilter({ onRangeChange, className }: DateRangeF
|
||||
}
|
||||
|
||||
const periods: { key: Period; label: string }[] = [
|
||||
{ key: 'month', label: 'M\u00e5nad' },
|
||||
{ key: 'month', label: 'Månad' },
|
||||
{ key: 'quarter', label: 'Kvartal' },
|
||||
{ key: 'year', label: '\u00c5r' },
|
||||
{ key: 'year', label: 'År' },
|
||||
]
|
||||
|
||||
return (
|
||||
|
||||
@@ -7,8 +7,8 @@ interface EmptyExtensionStateProps {
|
||||
}
|
||||
|
||||
export default function EmptyExtensionState({
|
||||
title = 'Ingen data \u00e4nnu',
|
||||
description = 'Data kommer att visas h\u00e4r n\u00e4r det finns tillg\u00e4ngligt.',
|
||||
title = 'Ingen data ännu',
|
||||
description = 'Data kommer att visas här när det finns tillgängligt.',
|
||||
icon,
|
||||
}: EmptyExtensionStateProps) {
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user