3f255ea201
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>
312 lines
10 KiB
TypeScript
312 lines
10 KiB
TypeScript
'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-green-600" />
|
|
<span className="text-green-600">Matchad</span>
|
|
</>
|
|
) : (
|
|
<span className="text-muted-foreground">Ingen match</span>
|
|
)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
{/* Line items with business/private toggle */}
|
|
<div className="space-y-2">
|
|
<Label>Artikelrader</Label>
|
|
{loading ? (
|
|
<p className="text-sm text-muted-foreground">Laddar...</p>
|
|
) : lineItems.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">Inga rader extraherade</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{lineItems.map((li) => (
|
|
<div
|
|
key={li.id}
|
|
className="flex items-center justify-between gap-3 rounded-lg border p-3"
|
|
>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm truncate">{li.description}</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{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>
|
|
)
|
|
}
|