feat: add inline supplier creation in invoice inbox and remove dead code

- Add inline new supplier form in InboxDetailDialog with pre-populated
  fields from AI extraction
- Support new_supplier payload in confirm endpoint to create suppliers
  with user-editable fields (type, org number, bankgiro, etc.)
- Improve line item amount calculation using cross-checked extraction totals
- Remove unused estimateProductValue function and LangChain imports
  from receipt-analyzer
- Add debug logging to invoice inbox confirm flow

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-02-26 19:55:50 +01:00
co-authored by Claude Opus 4.6
parent 3f255ea201
commit 9cd950de88
4 changed files with 257 additions and 132 deletions
@@ -118,9 +118,17 @@ export default function InvoiceInboxWorkspace({ userId }: WorkspaceComponentProp
}
}
async function handleConfirm(itemId: string, supplierId?: string) {
const body: Record<string, string> = {}
if (supplierId) body.supplier_id = supplierId
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',
@@ -1,7 +1,7 @@
'use client'
import { useState } from 'react'
import type { InvoiceInboxItem, Supplier } from '@/types'
import { useState, useEffect } from 'react'
import type { InvoiceInboxItem, Supplier, SupplierType } from '@/types'
import type { InvoiceExtractionResult } from '@/extensions/general/invoice-inbox/types'
import {
Dialog,
@@ -13,6 +13,8 @@ import {
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,
@@ -34,13 +36,24 @@ import {
getStatusVariant,
getConfidenceLabel,
} from '@/lib/extensions/invoice-inbox-utils'
import { Loader2, RefreshCw, Check, X } from 'lucide-react'
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) => Promise<void>
onConfirm: (itemId: string, supplierId?: string, newSupplierData?: NewSupplierData) => Promise<void>
onReject: (itemId: string) => Promise<void>
onReprocess: (itemId: string) => Promise<void>
suppliers: Supplier[]
@@ -67,6 +80,34 @@ export default function InboxDetailDialog({
}: 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
@@ -78,16 +119,25 @@ export default function InboxDetailDialog({
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)
await onConfirm(
item!.id,
supplierId,
isCreatingNewSupplier ? newSupplier : undefined
)
} else if (action === 'reject') {
await onReject(item!.id)
} else {
@@ -185,6 +235,82 @@ export default function InboxDetailDialog({
</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 />
+115 -35
View File
@@ -604,34 +604,53 @@ async function handleConfirmInboxItem(
const body = await request.json().catch(() => ({}))
try {
console.log('[invoice-inbox] Confirm: starting for item', id, 'extraction keys:', Object.keys(extraction))
// Resolve supplier: use matched, use body override, or create new
let supplierId = body.supplier_id || inboxItem.matched_supplier_id
if (!supplierId) {
// Create new supplier from extracted data
const supplierName = extraction.supplier?.name
// Use frontend-provided supplier data if available, otherwise fall back to extraction
const frontendSupplier = body.new_supplier as {
name?: string
supplier_type?: string
org_number?: string
vat_number?: string
bankgiro?: string
plusgiro?: string
default_expense_account?: string
default_currency?: string
} | undefined
const supplierName = frontendSupplier?.name || extraction.supplier?.name
if (!supplierName) {
return NextResponse.json({ error: 'Supplier name is required' }, { status: 400 })
}
const validSupplierTypes = ['swedish_business', 'eu_business', 'non_eu_business']
const supplierType = frontendSupplier?.supplier_type && validSupplierTypes.includes(frontendSupplier.supplier_type)
? frontendSupplier.supplier_type
: 'swedish_business'
const { data: newSupplier, error: supplierError } = await supabase
.from('suppliers')
.insert({
user_id: userId,
name: supplierName,
supplier_type: 'swedish_business',
org_number: extraction.supplier?.orgNumber || null,
vat_number: extraction.supplier?.vatNumber || null,
bankgiro: extraction.supplier?.bankgiro || null,
plusgiro: extraction.supplier?.plusgiro || null,
default_expense_account: '6200',
supplier_type: supplierType,
org_number: frontendSupplier?.org_number || extraction.supplier?.orgNumber || null,
vat_number: frontendSupplier?.vat_number || extraction.supplier?.vatNumber || null,
bankgiro: frontendSupplier?.bankgiro || extraction.supplier?.bankgiro || null,
plusgiro: frontendSupplier?.plusgiro || extraction.supplier?.plusgiro || null,
default_expense_account: frontendSupplier?.default_expense_account || '6200',
default_payment_terms: 30,
default_currency: extraction.invoice?.currency || 'SEK',
default_currency: frontendSupplier?.default_currency || extraction.invoice?.currency || 'SEK',
})
.select()
.single()
if (supplierError || !newSupplier) {
console.error('[invoice-inbox] Confirm: supplier creation failed:', supplierError)
return NextResponse.json({ error: 'Failed to create supplier' }, { status: 500 })
}
@@ -647,6 +666,7 @@ async function handleConfirmInboxItem(
.single()
if (supplierCheckError || !supplier) {
console.error('[invoice-inbox] Confirm: supplier check failed:', supplierCheckError, 'supplierId=', supplierId)
return NextResponse.json({ error: 'Supplier not found' }, { status: 404 })
}
@@ -655,45 +675,99 @@ async function handleConfirmInboxItem(
.rpc('get_next_arrival_number', { p_user_id: userId })
if (arrivalError) {
console.error('[invoice-inbox] Confirm: arrival number error:', arrivalError)
return NextResponse.json({ error: 'Failed to get arrival number' }, { status: 500 })
}
// Build line items from extraction
const items = (extraction.lineItems || []).map((item, index) => {
const vatRate = item.vatRate != null ? item.vatRate / 100 : 0.25
const lineTotal = Math.round(item.lineTotal * 100) / 100
const vatAmount = Math.round(lineTotal * vatRate * 100) / 100
return {
sort_order: index,
description: item.description,
quantity: item.quantity || 1,
unit: 'st',
unit_price: item.unitPrice != null ? item.unitPrice : lineTotal,
line_total: lineTotal,
account_number: item.accountSuggestion || supplier.default_expense_account || '6200',
vat_code: null,
vat_rate: vatRate,
vat_amount: vatAmount,
console.log('[invoice-inbox] Confirm: supplierId=', supplierId, 'arrivalNum=', arrivalNum)
// Build line items from extraction.
// The AI extraction may return lineTotal as gross (incl. VAT) or net.
// Cross-check with extraction.totals to determine the correct split.
const extractedTotals = extraction.totals
const rawLineItems = extraction.lineItems || []
const items: Array<{
sort_order: number
description: string
quantity: number
unit: string
unit_price: number
line_total: number
account_number: string
vat_code: null
vat_rate: number
vat_amount: number
}> = []
if (rawLineItems.length > 0 && extractedTotals?.subtotal != null && extractedTotals?.vatAmount != null) {
// We have both line items and reliable totals — use totals for amounts
// and distribute proportionally across line items
const rawSum = rawLineItems.reduce((s, i) => s + (i.lineTotal || 0), 0)
const knownSubtotal = extractedTotals.subtotal
const knownVat = extractedTotals.vatAmount
const knownVatRate = knownSubtotal > 0
? Math.round((knownVat / knownSubtotal) * 100) / 100
: 0.25
for (let index = 0; index < rawLineItems.length; index++) {
const item = rawLineItems[index]
// Distribute the known subtotal proportionally by each line's share
const proportion = rawSum > 0 ? (item.lineTotal || 0) / rawSum : 1 / rawLineItems.length
const lineNet = Math.round(knownSubtotal * proportion * 100) / 100
const lineVat = Math.round(knownVat * proportion * 100) / 100
items.push({
sort_order: index,
description: item.description,
quantity: item.quantity || 1,
unit: 'st',
unit_price: lineNet,
line_total: lineNet,
account_number: item.accountSuggestion || supplier.default_expense_account || '6200',
vat_code: null,
vat_rate: knownVatRate,
vat_amount: lineVat,
})
}
})
} else if (rawLineItems.length > 0) {
// Line items but no reliable totals — use per-line vatRate
for (let index = 0; index < rawLineItems.length; index++) {
const item = rawLineItems[index]
const vatRate = item.vatRate != null ? item.vatRate / 100 : 0.25
const lineTotal = Math.round(item.lineTotal * 100) / 100
const vatAmount = Math.round(lineTotal * vatRate * 100) / 100
items.push({
sort_order: index,
description: item.description,
quantity: item.quantity || 1,
unit: 'st',
unit_price: item.unitPrice != null ? item.unitPrice : lineTotal,
line_total: lineTotal,
account_number: item.accountSuggestion || supplier.default_expense_account || '6200',
vat_code: null,
vat_rate: vatRate,
vat_amount: vatAmount,
})
}
}
// If no line items, create a single item from totals
if (items.length === 0 && extraction.totals?.total) {
const total = extraction.totals.total
const vatAmount = extraction.totals.vatAmount || 0
const subtotal = extraction.totals.subtotal || total - vatAmount
const vatRate = subtotal > 0 ? Math.round((vatAmount / subtotal) * 100) / 100 : 0.25
if (items.length === 0 && extractedTotals?.total) {
const total = extractedTotals.total
const vatAmt = extractedTotals.vatAmount || 0
const sub = extractedTotals.subtotal || total - vatAmt
const vatRate = sub > 0 ? Math.round((vatAmt / sub) * 100) / 100 : 0.25
items.push({
sort_order: 0,
description: 'Fakturabelopp',
quantity: 1,
unit: 'st',
unit_price: subtotal,
line_total: subtotal,
unit_price: sub,
line_total: sub,
account_number: supplier.default_expense_account || '6200',
vat_code: null,
vat_rate: vatRate,
vat_amount: Math.round(vatAmount * 100) / 100,
vat_amount: Math.round(vatAmt * 100) / 100,
})
}
@@ -708,6 +782,8 @@ async function handleConfirmInboxItem(
else if (primaryVatRate === 0.06) vatTreatment = 'reduced_6'
else if (primaryVatRate === 0) vatTreatment = 'exempt'
console.log('[invoice-inbox] Confirm: items=', items.length, 'subtotal=', subtotal, 'vat=', vatAmount, 'total=', total, 'vatTreatment=', vatTreatment)
// Insert supplier invoice
const { data: invoice, error: invoiceError } = await supabase
.from('supplier_invoices')
@@ -733,9 +809,12 @@ async function handleConfirmInboxItem(
.single()
if (invoiceError || !invoice) {
console.error('[invoice-inbox] Confirm: invoice insert error:', invoiceError)
return NextResponse.json({ error: invoiceError?.message || 'Failed to create invoice' }, { status: 500 })
}
console.log('[invoice-inbox] Confirm: invoice created id=', invoice.id)
// Insert line items
const itemInserts = items.map((item) => ({
supplier_invoice_id: invoice.id,
@@ -747,6 +826,7 @@ async function handleConfirmInboxItem(
.insert(itemInserts)
if (itemsError) {
console.error('[invoice-inbox] Confirm: items insert error:', itemsError, 'payload:', JSON.stringify(itemInserts))
await supabase.from('supplier_invoices').delete().eq('id', invoice.id)
return NextResponse.json({ error: itemsError.message }, { status: 500 })
}
@@ -784,7 +864,7 @@ async function handleConfirmInboxItem(
},
})
} catch (error) {
console.error('[invoice-inbox] Confirm failed:', error)
console.error('[invoice-inbox] Confirm failed:', error instanceof Error ? error.stack : error)
return NextResponse.json({ error: 'Confirmation failed' }, { status: 500 })
}
}
@@ -3,14 +3,9 @@
* then applies receipt-specific validation and enhancement.
*
* SERVER-ONLY: uses the shared vision client via document-analyzer.
*
* Preserved public API: analyzeReceipt() and estimateProductValue().
*/
import 'server-only'
import { ChatAnthropic } from '@langchain/anthropic'
import { HumanMessage, SystemMessage } from '@langchain/core/messages'
import { z } from 'zod'
import type { ReceiptExtractionResult } from '@/types'
import { extractReceipt } from '@/lib/ai/document-analyzer'
import {
@@ -26,10 +21,6 @@ export {
detectRestaurant,
} from './receipt-utils'
// Retry config for estimateProductValue (still uses LangChain, not vision-client)
const MAX_RETRIES = 3
const RETRY_DELAY_MS = 1000
/**
* Analyze a receipt image using Claude Haiku Vision.
* Delegates extraction to the shared core, then applies receipt-specific enhancements.
@@ -102,83 +93,3 @@ function detectForeignMerchant(
return false
}
// ============================================================
// Product Value Estimation (LangChain — separate from receipt analysis)
// ============================================================
const ProductEstimationSchema = z.object({
estimatedValue: z.number().describe('Uppskattat marknadsvärde i SEK'),
confidence: z.number().min(0).max(1).describe('Hur säker du är på uppskattningen (0.0-1.0)'),
description: z.string().describe('Kort beskrivning av produkten'),
brand: z.string().nullable().describe('Varumärke om identifierbart, annars null'),
})
/**
* Estimate product value from image using Claude Vision via LangChain.
* Used for gift/product registration without receipt.
*/
export async function estimateProductValue(
imageBase64: string,
mimeType: 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif' = 'image/jpeg'
): Promise<{
estimatedValue: number
confidence: number
description: string
brand: string | null
}> {
const model = new ChatAnthropic({
modelName: 'claude-haiku-4-5-20251001',
maxTokens: 1024,
temperature: 0,
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
})
const structuredModel = model.withStructuredOutput(ProductEstimationSchema)
const systemPrompt = `Du är expert på att uppskatta marknadsvärdet på produkter baserat på bilder.
Din uppgift är att identifiera produkten och ge en rimlig uppskattning av dess marknadsvärde i svenska kronor.`
const userPrompt = `Analysera produkten i bilden och uppskatta dess marknadsvärde i SEK. Identifiera varumärke om möjligt.`
let lastError: Error | null = null
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
const result = await structuredModel.invoke([
new SystemMessage(systemPrompt),
new HumanMessage({
content: [
{
type: 'image_url',
image_url: { url: `data:${mimeType};base64,${imageBase64}` },
},
{
type: 'text',
text: userPrompt,
},
],
}),
])
return {
estimatedValue: result.estimatedValue || 0,
confidence: result.confidence || 0.5,
description: result.description || 'Okänd produkt',
brand: result.brand,
}
} catch (error) {
lastError = error instanceof Error ? error : new Error('Unknown error')
if (attempt < MAX_RETRIES - 1) {
await sleep(RETRY_DELAY_MS * (attempt + 1))
}
}
}
throw new Error(`Product value estimation failed after ${MAX_RETRIES} attempts: ${lastError?.message}`)
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}