Update paddle, and support invoice line item
This commit is contained in:
@@ -1,15 +1,30 @@
|
||||
import apiClient from '../client'
|
||||
import type { InferenceResponse } from '../types'
|
||||
|
||||
export interface ProcessDocumentOptions {
|
||||
extractLineItems?: boolean
|
||||
}
|
||||
|
||||
// Longer timeout for inference - line items extraction can take 60+ seconds
|
||||
const INFERENCE_TIMEOUT_MS = 120000
|
||||
|
||||
export const inferenceApi = {
|
||||
processDocument: async (file: File): Promise<InferenceResponse> => {
|
||||
processDocument: async (
|
||||
file: File,
|
||||
options: ProcessDocumentOptions = {}
|
||||
): Promise<InferenceResponse> => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
if (options.extractLineItems) {
|
||||
formData.append('extract_line_items', 'true')
|
||||
}
|
||||
|
||||
const { data } = await apiClient.post('/api/v1/infer', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
timeout: INFERENCE_TIMEOUT_MS,
|
||||
})
|
||||
return data
|
||||
},
|
||||
|
||||
@@ -182,6 +182,62 @@ export interface CrossValidationResult {
|
||||
details: string[]
|
||||
}
|
||||
|
||||
// Business Features Types (Line Items, VAT)
|
||||
|
||||
export interface LineItem {
|
||||
row_index: number
|
||||
description: string | null
|
||||
quantity: string | null
|
||||
unit: string | null
|
||||
unit_price: string | null
|
||||
amount: string | null
|
||||
article_number: string | null
|
||||
vat_rate: string | null
|
||||
is_deduction: boolean
|
||||
confidence: number
|
||||
}
|
||||
|
||||
export interface LineItemsResult {
|
||||
items: LineItem[]
|
||||
header_row: string[]
|
||||
total_amount: string | null
|
||||
}
|
||||
|
||||
export interface VATBreakdown {
|
||||
rate: number
|
||||
base_amount: string | null
|
||||
vat_amount: string
|
||||
source: string
|
||||
}
|
||||
|
||||
export interface VATSummary {
|
||||
breakdowns: VATBreakdown[]
|
||||
total_excl_vat: string | null
|
||||
total_vat: string | null
|
||||
total_incl_vat: string | null
|
||||
confidence: number
|
||||
}
|
||||
|
||||
export interface MathCheckResult {
|
||||
rate: number
|
||||
base_amount: number | null
|
||||
expected_vat: number | null
|
||||
actual_vat: number | null
|
||||
is_valid: boolean
|
||||
tolerance: number
|
||||
}
|
||||
|
||||
export interface VATValidationResult {
|
||||
is_valid: boolean
|
||||
confidence_score: number
|
||||
math_checks: MathCheckResult[]
|
||||
total_check: boolean
|
||||
line_items_vs_summary: boolean | null
|
||||
amount_consistency: boolean | null
|
||||
needs_review: boolean
|
||||
review_reasons: string[]
|
||||
}
|
||||
|
||||
export interface InferenceResult {
|
||||
document_id: string
|
||||
document_type: string
|
||||
@@ -193,6 +249,10 @@ export interface InferenceResult {
|
||||
visualization_url: string | null
|
||||
errors: string[]
|
||||
fallback_used: boolean
|
||||
// Business features (optional, only when extract_line_items=true)
|
||||
line_items: LineItemsResult | null
|
||||
vat_summary: VATSummary | null
|
||||
vat_validation: VATValidationResult | null
|
||||
}
|
||||
|
||||
export interface InferenceResponse {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React, { useState, useRef } from 'react'
|
||||
import { UploadCloud, FileText, Loader2, CheckCircle2, AlertCircle, Clock } from 'lucide-react'
|
||||
import { UploadCloud, FileText, Loader2, CheckCircle2, AlertCircle, Clock, Table2 } from 'lucide-react'
|
||||
import { Button } from './Button'
|
||||
import { inferenceApi } from '../api/endpoints'
|
||||
import { LineItemsTable } from './LineItemsTable'
|
||||
import { VATSummaryCard } from './VATSummaryCard'
|
||||
import type { InferenceResult } from '../api/types'
|
||||
|
||||
export const InferenceDemo: React.FC = () => {
|
||||
@@ -10,6 +12,7 @@ export const InferenceDemo: React.FC = () => {
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [result, setResult] = useState<InferenceResult | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [extractLineItems, setExtractLineItems] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleFileSelect = (file: File | null) => {
|
||||
@@ -50,9 +53,9 @@ export const InferenceDemo: React.FC = () => {
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await inferenceApi.processDocument(selectedFile)
|
||||
console.log('API Response:', response)
|
||||
console.log('Visualization URL:', response.result?.visualization_url)
|
||||
const response = await inferenceApi.processDocument(selectedFile, {
|
||||
extractLineItems,
|
||||
})
|
||||
setResult(response.result)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Processing failed')
|
||||
@@ -65,6 +68,7 @@ export const InferenceDemo: React.FC = () => {
|
||||
setSelectedFile(null)
|
||||
setResult(null)
|
||||
setError(null)
|
||||
setExtractLineItems(false)
|
||||
}
|
||||
|
||||
const formatFieldName = (field: string): string => {
|
||||
@@ -183,11 +187,34 @@ export const InferenceDemo: React.FC = () => {
|
||||
)}
|
||||
|
||||
{selectedFile && !isProcessing && (
|
||||
<div className="mt-6 flex gap-3 justify-end">
|
||||
<Button variant="secondary" onClick={handleReset}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleProcess}>Process Invoice</Button>
|
||||
<div className="mt-6 space-y-4">
|
||||
{/* Business Features Checkbox */}
|
||||
<label className="flex items-center gap-3 p-4 bg-warm-bg/50 rounded-lg border border-warm-divider cursor-pointer hover:bg-warm-hover/50 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={extractLineItems}
|
||||
onChange={(e) => setExtractLineItems(e.target.checked)}
|
||||
className="w-5 h-5 rounded border-warm-border text-warm-text-secondary focus:ring-warm-text-secondary"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Table2 size={18} className="text-warm-text-secondary" />
|
||||
<div>
|
||||
<span className="font-medium text-warm-text-primary">
|
||||
Extract Line Items & VAT
|
||||
</span>
|
||||
<p className="text-xs text-warm-text-muted mt-0.5">
|
||||
Extract product/service rows, VAT breakdown, and cross-validation
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
<Button variant="secondary" onClick={handleReset}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleProcess}>Process Invoice</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -274,6 +301,21 @@ export const InferenceDemo: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Line Items */}
|
||||
{result.line_items && (
|
||||
<div className="bg-warm-card rounded-xl border border-warm-border p-6 shadow-sm">
|
||||
<h3 className="text-lg font-bold text-warm-text-primary mb-5 flex items-center gap-2">
|
||||
<span className="w-1 h-5 bg-warm-text-secondary rounded-full"></span>
|
||||
<Table2 size={20} className="text-warm-text-secondary" />
|
||||
Line Items
|
||||
<span className="ml-auto text-sm font-normal text-warm-text-muted">
|
||||
{result.line_items.items.length} item(s)
|
||||
</span>
|
||||
</h3>
|
||||
<LineItemsTable lineItems={result.line_items} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Visualization */}
|
||||
{result.visualization_url && (
|
||||
<div className="bg-warm-card rounded-xl border border-warm-border p-6 shadow-sm">
|
||||
@@ -437,6 +479,20 @@ export const InferenceDemo: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* VAT Summary */}
|
||||
{result.vat_summary && (
|
||||
<div className="bg-warm-card rounded-xl border border-warm-border p-6 shadow-sm">
|
||||
<h3 className="text-lg font-bold text-warm-text-primary mb-4 flex items-center gap-2">
|
||||
<span className="w-1 h-5 bg-warm-text-secondary rounded-full"></span>
|
||||
VAT Summary
|
||||
</h3>
|
||||
<VATSummaryCard
|
||||
vatSummary={result.vat_summary}
|
||||
vatValidation={result.vat_validation}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Errors */}
|
||||
{result.errors.length > 0 && (
|
||||
<div className="bg-warm-card rounded-xl border border-warm-border p-6 shadow-sm">
|
||||
|
||||
128
frontend/src/components/LineItemsTable.tsx
Normal file
128
frontend/src/components/LineItemsTable.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import React from 'react'
|
||||
import { CheckCircle2, MinusCircle } from 'lucide-react'
|
||||
import type { LineItemsResult } from '../api/types'
|
||||
|
||||
interface LineItemsTableProps {
|
||||
lineItems: LineItemsResult
|
||||
}
|
||||
|
||||
export const LineItemsTable: React.FC<LineItemsTableProps> = ({ lineItems }) => {
|
||||
if (!lineItems.items || lineItems.items.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-warm-text-muted">
|
||||
No line items found in this document
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-warm-divider">
|
||||
<th className="text-left py-3 px-4 font-semibold text-warm-text-muted text-xs uppercase tracking-wide">
|
||||
#
|
||||
</th>
|
||||
<th className="text-left py-3 px-4 font-semibold text-warm-text-muted text-xs uppercase tracking-wide">
|
||||
Description
|
||||
</th>
|
||||
<th className="text-right py-3 px-4 font-semibold text-warm-text-muted text-xs uppercase tracking-wide">
|
||||
Qty
|
||||
</th>
|
||||
<th className="text-right py-3 px-4 font-semibold text-warm-text-muted text-xs uppercase tracking-wide">
|
||||
Unit Price
|
||||
</th>
|
||||
<th className="text-right py-3 px-4 font-semibold text-warm-text-muted text-xs uppercase tracking-wide">
|
||||
Amount
|
||||
</th>
|
||||
<th className="text-right py-3 px-4 font-semibold text-warm-text-muted text-xs uppercase tracking-wide">
|
||||
VAT %
|
||||
</th>
|
||||
<th className="text-center py-3 px-4 font-semibold text-warm-text-muted text-xs uppercase tracking-wide">
|
||||
Conf.
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lineItems.items.map((item) => (
|
||||
<tr
|
||||
key={`row-${item.row_index}`}
|
||||
className={`border-b border-warm-divider hover:bg-warm-hover/50 transition-colors ${
|
||||
item.is_deduction ? 'bg-red-50' : ''
|
||||
}`}
|
||||
>
|
||||
<td className="py-3 px-4 text-warm-text-muted font-mono text-xs">
|
||||
{item.row_index}
|
||||
</td>
|
||||
<td className="py-3 px-4 font-medium max-w-xs truncate">
|
||||
<div className="flex items-center gap-2">
|
||||
{item.is_deduction && (
|
||||
<MinusCircle size={14} className="text-red-500 flex-shrink-0" />
|
||||
)}
|
||||
<span className={item.is_deduction ? 'text-red-600' : 'text-warm-text-primary'}>
|
||||
{item.description || '-'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right text-warm-text-primary font-mono">
|
||||
{item.quantity || '-'}
|
||||
{item.unit && (
|
||||
<span className="text-warm-text-muted ml-1">{item.unit}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right text-warm-text-primary font-mono">
|
||||
{item.unit_price || '-'}
|
||||
</td>
|
||||
<td className={`py-3 px-4 text-right font-bold font-mono ${
|
||||
item.is_deduction ? 'text-red-600' : 'text-warm-text-primary'
|
||||
}`}>
|
||||
{item.amount || '-'}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right text-warm-text-secondary font-mono">
|
||||
{item.vat_rate ? `${item.vat_rate}%` : '-'}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-center">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<CheckCircle2
|
||||
size={14}
|
||||
className={
|
||||
item.confidence >= 0.8
|
||||
? 'text-green-500'
|
||||
: item.confidence >= 0.5
|
||||
? 'text-yellow-500'
|
||||
: 'text-red-500'
|
||||
}
|
||||
/>
|
||||
<span
|
||||
className={`text-xs font-medium ${
|
||||
item.confidence >= 0.8
|
||||
? 'text-green-600'
|
||||
: item.confidence >= 0.5
|
||||
? 'text-yellow-600'
|
||||
: 'text-red-600'
|
||||
}`}
|
||||
>
|
||||
{(item.confidence * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{lineItems.total_amount && (
|
||||
<div className="flex justify-end pt-4 border-t border-warm-divider">
|
||||
<div className="text-right">
|
||||
<span className="text-sm text-warm-text-muted mr-4">Total:</span>
|
||||
<span className="text-lg font-bold text-warm-text-primary font-mono">
|
||||
{lineItems.total_amount} SEK
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
188
frontend/src/components/VATSummaryCard.tsx
Normal file
188
frontend/src/components/VATSummaryCard.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import React from 'react'
|
||||
import { CheckCircle2, AlertCircle, AlertTriangle } from 'lucide-react'
|
||||
import type { VATSummary, VATValidationResult } from '../api/types'
|
||||
|
||||
interface VATSummaryCardProps {
|
||||
vatSummary: VATSummary
|
||||
vatValidation?: VATValidationResult | null
|
||||
}
|
||||
|
||||
export const VATSummaryCard: React.FC<VATSummaryCardProps> = ({
|
||||
vatSummary,
|
||||
vatValidation,
|
||||
}) => {
|
||||
const hasBreakdowns = vatSummary.breakdowns && vatSummary.breakdowns.length > 0
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* VAT Breakdowns by Rate */}
|
||||
{hasBreakdowns && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-semibold text-warm-text-muted uppercase tracking-wide">
|
||||
VAT Breakdown
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{vatSummary.breakdowns.map((breakdown, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="p-3 bg-warm-bg/70 rounded-lg border border-warm-divider"
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-bold text-warm-text-secondary">
|
||||
{breakdown.rate}% Moms
|
||||
</span>
|
||||
<span className="text-xs text-warm-text-muted px-2 py-0.5 bg-warm-selected rounded">
|
||||
{breakdown.source}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-warm-text-muted">Base: </span>
|
||||
<span className="font-mono text-warm-text-primary">
|
||||
{breakdown.base_amount ?? 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-warm-text-muted">VAT: </span>
|
||||
<span className="font-mono font-bold text-warm-text-primary">
|
||||
{breakdown.vat_amount ?? 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Totals */}
|
||||
<div className="pt-4 border-t border-warm-divider space-y-2">
|
||||
{vatSummary.total_excl_vat && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-warm-text-muted">Excl. VAT:</span>
|
||||
<span className="font-mono text-warm-text-primary">
|
||||
{vatSummary.total_excl_vat}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{vatSummary.total_vat && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-warm-text-muted">Total VAT:</span>
|
||||
<span className="font-mono font-bold text-warm-text-secondary">
|
||||
{vatSummary.total_vat}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{vatSummary.total_incl_vat && (
|
||||
<div className="flex justify-between text-sm pt-2 border-t border-warm-divider">
|
||||
<span className="font-semibold text-warm-text-primary">Incl. VAT:</span>
|
||||
<span className="font-mono font-bold text-warm-text-primary text-base">
|
||||
{vatSummary.total_incl_vat}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confidence */}
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<CheckCircle2 size={14} className="text-warm-text-secondary" />
|
||||
<span className="text-warm-text-muted">
|
||||
Confidence: {(vatSummary.confidence * 100).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Validation Results */}
|
||||
{vatValidation && (
|
||||
<div className="pt-4 border-t border-warm-divider">
|
||||
<h4 className="text-sm font-semibold text-warm-text-muted uppercase tracking-wide mb-3">
|
||||
VAT Validation
|
||||
</h4>
|
||||
|
||||
<div
|
||||
className={`
|
||||
p-3 rounded-lg mb-3 flex items-center gap-3
|
||||
${
|
||||
vatValidation.is_valid
|
||||
? 'bg-green-50 border border-green-200'
|
||||
: vatValidation.needs_review
|
||||
? 'bg-yellow-50 border border-yellow-200'
|
||||
: 'bg-red-50 border border-red-200'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{vatValidation.is_valid ? (
|
||||
<>
|
||||
<CheckCircle2 size={20} className="text-green-600 flex-shrink-0" />
|
||||
<span className="font-bold text-green-800 text-sm">
|
||||
VAT Calculation Valid
|
||||
</span>
|
||||
</>
|
||||
) : vatValidation.needs_review ? (
|
||||
<>
|
||||
<AlertTriangle size={20} className="text-yellow-600 flex-shrink-0" />
|
||||
<span className="font-bold text-yellow-800 text-sm">
|
||||
Needs Manual Review
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertCircle size={20} className="text-red-600 flex-shrink-0" />
|
||||
<span className="font-bold text-red-800 text-sm">
|
||||
Validation Failed
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Math Checks */}
|
||||
{vatValidation.math_checks && vatValidation.math_checks.length > 0 && (
|
||||
<div className="space-y-2 mb-3">
|
||||
{vatValidation.math_checks.map((check, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`
|
||||
p-2 rounded text-xs flex items-center justify-between
|
||||
${
|
||||
check.is_valid
|
||||
? 'bg-green-50 border border-green-200'
|
||||
: 'bg-red-50 border border-red-200'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<span className={check.is_valid ? 'text-green-700' : 'text-red-700'}>
|
||||
{check.rate}%: {check.base_amount?.toFixed(2) ?? 'N/A'} x {check.rate}% ={' '}
|
||||
{check.expected_vat?.toFixed(2) ?? 'N/A'}
|
||||
</span>
|
||||
{check.is_valid ? (
|
||||
<CheckCircle2 size={14} className="text-green-600" />
|
||||
) : (
|
||||
<AlertCircle size={14} className="text-red-600" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Review Reasons */}
|
||||
{vatValidation.review_reasons && vatValidation.review_reasons.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{vatValidation.review_reasons.map((reason, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="text-xs text-yellow-700 bg-yellow-50 p-2 rounded border border-yellow-200"
|
||||
>
|
||||
{reason}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confidence Score */}
|
||||
<div className="mt-3 text-xs text-warm-text-muted">
|
||||
Validation confidence: {(vatValidation.confidence_score * 100).toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user