'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { Receipt as ReceiptIcon, Landmark } from 'lucide-react'
import { formatCurrency, formatDate, cn } from '@/lib/utils'
import type { AgentInboxItemView } from '@/app/(dashboard)/agent-inbox/page'
import type { BookingProposalPayload, MatchProposalPayload } from '@/types'
import ReceiptDetailDialog from './ReceiptDetailDialog'
import TransactionDetailDialog from './TransactionDetailDialog'
import { assessReceiptQuality } from './receipt-quality'
interface ProposalCardProps {
item: AgentInboxItemView
isSelected: boolean
isBusy: boolean
onToggleSelect: () => void
onAccept: () => void
onReject: () => void
onEdit: () => void
onChangeMatch?: () => void
}
function confidenceLabel(c: number | null): string {
if (c === null) return 'Ingen säkerhet'
const pct = Math.round(c * 100)
return `${pct}% säkerhet`
}
function confidenceColor(c: number | null): string {
if (c === null) return 'bg-muted'
if (c >= 0.9) return 'bg-success/15 text-success-foreground'
if (c >= 0.6) return 'bg-warning/15 text-warning-foreground'
return 'bg-destructive/15 text-destructive-foreground'
}
export default function ProposalCard({
item,
isSelected,
isBusy,
onToggleSelect,
onAccept,
onReject,
onEdit,
onChangeMatch,
}: ProposalCardProps) {
const proposal = item.proposal!
const inbox = item.inbox_item
const tx = item.transaction
const isMatch = proposal.step_type === 'match'
const isUserEdited = Boolean(proposal.edit_diff)
// BFL compliance: can't book without a source document. Block match-accept
// when no receipt file is attached — the server-side validator enforces this
// too, but disabling the button client-side avoids a round-trip error.
const receiptMissing = isMatch && !inbox.document_id
const matchPayload = isMatch ? (proposal.proposal_json as MatchProposalPayload) : null
const bookingPayload = !isMatch ? (proposal.proposal_json as BookingProposalPayload) : null
return (
{isMatch ? 'Match' : 'Bokföring'}
{confidenceLabel(proposal.confidence)}
{isUserEdited && (
Ändrad av användare
)}
{inbox.document && (
{inbox.document.file_name}
)}
{isMatch && matchPayload && (
)}
{!isMatch && bookingPayload && (
)}
{receiptMissing && (
Kvittobild saknas — ladda upp i kvittodialogen innan du kan godkänna.
)}
{isMatch && onChangeMatch && (
)}
{!isMatch && (
)}
)
}
function MatchProposalBody({
payload,
reasoning,
transaction,
inbox,
}: {
payload: MatchProposalPayload
reasoning: string | null
transaction: AgentInboxItemView['transaction']
inbox: AgentInboxItemView['inbox_item']
}) {
const proposedTx = transaction && transaction.id === payload.matched_transaction_id ? transaction : null
return (
{proposedTx ? (
) : (
Föreslagen transaktion: {payload.matched_transaction_id}
)}
)
}
function ReasoningDisclosure({
reasoning,
alternatives,
}: {
reasoning: string | null
alternatives?: MatchProposalPayload['alternatives']
}) {
const [open, setOpen] = useState(false)
const hasReasoning = Boolean(reasoning)
const hasAlternatives = alternatives && alternatives.length > 0
if (!hasReasoning && !hasAlternatives) return null
return (
{open && (
{reasoning && (
“{reasoning}”
)}
{hasAlternatives && (
{alternatives!.map((alt) => (
-
{Math.round(alt.confidence * 100)}% — {alt.reasoning}
))}
)}
)}
)
}
function ReceiptBox({
inbox,
}: {
inbox: AgentInboxItemView['inbox_item']
}) {
const [open, setOpen] = useState(false)
const data = (inbox.extracted_data as {
merchant?: { name?: string | null }
receipt?: { date?: string | null; currency?: string | null }
totals?: { total?: number | null }
} | null) ?? {}
const merchant = data.merchant?.name ?? inbox.document?.file_name ?? 'Okänt kvitto'
const total = data.totals?.total ?? null
const currency = data.receipt?.currency ?? 'SEK'
const date = data.receipt?.date ?? null
const quality = assessReceiptQuality(inbox)
const hasFile = Boolean(inbox.document_id)
const needsAttention = !hasFile || !quality.ok
return (
<>
>
)
}
function TransactionBox({
tx,
}: {
tx: NonNullable
}) {
const [open, setOpen] = useState(false)
return (
<>
>
)
}
function BookingProposalBody({
payload,
reasoning,
}: {
payload: BookingProposalPayload
reasoning: string | null
}) {
const totalDebit = payload.lines.reduce((s, l) => s + l.debit_amount, 0)
return (
| Konto |
Debet |
Kredit |
{payload.lines.map((line, i) => (
|
{line.account_number}{' '}
{line.description}
|
{line.debit_amount > 0 ? line.debit_amount.toFixed(2) : ''}
|
{line.credit_amount > 0 ? line.credit_amount.toFixed(2) : ''}
|
))}
|
{payload.vat_treatment && Moms: {payload.vat_treatment}}
{payload.default_private && Privat uttag}
|
{totalDebit.toFixed(2)} |
{totalDebit.toFixed(2)} |
)
}