Files
accounted/components/extensions/general/document-inbox/ReceiptInboxDetail.tsx
T
Jakob WennbergandClaude Opus 4.6 f3ec634a46 feat: open-source under AGPL-3.0, redesign UI to grayscale palette, add uncategorize API, fix VAT account names
Add LICENSE (AGPL-3.0-or-later), CONTRIBUTING.md, SECURITY.md, DCO, and NOTICE files.
Rewrite README for open-source audience with self-hosting instructions.
Redesign color palette to grayscale chrome theme across all components.
Add transaction uncategorize API route with tests.
Fix VAT account name mismatches in migration 052.
Improve import page with SIE file support and loading skeleton.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 18:18:11 +01:00

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-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>
)
}