feat(transactions): AI booking proposal in the quick-review dialog (cascade step 3) (#1782)
Wires the auto-booking cascade to the UI. When the quick-review dialog opens
for a transaction, AiCategorizeProposal fetches POST /api/agent/categorize
(Tier 1 deterministic candidates → Tier 2 model selector, provider-agnostic)
and:
- pre-fills the dialog's account + VAT from the model's pick (through
handleAccountChange, so class-2 VAT clearing is preserved),
- shows a confidence band (säker / trolig / välj konto), a short "Varför", and
the candidate alternatives (click one to re-apply),
- falls back SILENTLY to the deterministic defaults on error, and shows a soft
note when no AI backend is configured — the dialog always works without AI.
Surfaced inside the existing dialog (not a new card) so it reuses the proven,
balanced commit path. No silent auto-posting: "säker" is pre-filled and booked
with the dialog's existing one-tap Bokför; hands-off auto-book waits for
calibration (avoids any storno-on-undo).
lint + guards clean; 94 transaction/categorize tests green (no regressions).
Strings inline Swedish for now (lift to messages/{sv,en} pre-merge). UI PR:
awaiting founder visual sign-off before merge.
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Opus 4.8
parent
e7a5e65ecf
commit
0531576807
@@ -0,0 +1,198 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Sparkles, AlertTriangle } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { VatTreatment } from '@/types'
|
||||
|
||||
/**
|
||||
* The auto-booking proposal, inline in the quick-review dialog.
|
||||
*
|
||||
* Fetches POST /api/agent/categorize (Tier 1 deterministic candidates → Tier 2
|
||||
* model selector, provider-agnostic) and shows the model's pick with a
|
||||
* confidence pill, a short "Varför", and the candidate alternatives. It
|
||||
* pre-fills the dialog's account + VAT when it lands on a real account, and
|
||||
* clicking an alternative re-applies. Nothing books here — the dialog's own
|
||||
* "Bokför" commits through the existing categorize route.
|
||||
*
|
||||
* i18n: strings are inline Swedish for now (this is the Swedish-answering
|
||||
* assistant surface); lift to messages/{sv,en}.json before final merge.
|
||||
*/
|
||||
|
||||
interface CandidateDto {
|
||||
account: string
|
||||
label: string
|
||||
vatTreatment: VatTreatment | null
|
||||
source: string
|
||||
confidence: number
|
||||
}
|
||||
|
||||
interface ProposalDto {
|
||||
account: string | null
|
||||
category: string | null
|
||||
vatTreatment: VatTreatment | null
|
||||
reverseCharge: boolean
|
||||
confidence: number
|
||||
agreement: number
|
||||
reasoning: string
|
||||
choice: { kind: 'candidate' | 'category' | 'needs_review' }
|
||||
candidates: CandidateDto[]
|
||||
}
|
||||
|
||||
type State =
|
||||
| { status: 'loading' }
|
||||
| { status: 'error' }
|
||||
| { status: 'unconfigured' }
|
||||
| { status: 'ready'; proposal: ProposalDto }
|
||||
|
||||
interface Props {
|
||||
transactionId: string
|
||||
/** Fetch when the dialog is open. */
|
||||
open: boolean
|
||||
/** Apply an account + VAT to the dialog fields. */
|
||||
onApply: (account: string, vat: VatTreatment | 'none') => void
|
||||
}
|
||||
|
||||
type Band = 'sure' | 'likely' | 'review'
|
||||
function bandOf(p: ProposalDto): Band {
|
||||
if (p.choice.kind === 'needs_review' || !p.account) return 'review'
|
||||
if (p.confidence >= 0.8) return 'sure'
|
||||
if (p.confidence >= 0.5) return 'likely'
|
||||
return 'review'
|
||||
}
|
||||
|
||||
const BAND_LABEL: Record<Band, string> = { sure: 'Säker', likely: 'Trolig', review: 'Välj konto' }
|
||||
|
||||
export default function AiCategorizeProposal({ transactionId, open, onApply }: Props) {
|
||||
const [state, setState] = useState<State>({ status: 'loading' })
|
||||
// Apply the pick to the dialog exactly once per fetch, so the user's later
|
||||
// manual edits are never clobbered by a re-render.
|
||||
const appliedRef = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
let alive = true
|
||||
appliedRef.current = null
|
||||
// Initial state is already 'loading'; the dialog mounts this fresh per
|
||||
// transaction (keyed on tx.id), so no in-effect reset is needed.
|
||||
;(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/agent/categorize', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ transaction_id: transactionId }),
|
||||
})
|
||||
if (!alive) return
|
||||
if (res.status === 503) return setState({ status: 'unconfigured' })
|
||||
if (!res.ok) return setState({ status: 'error' })
|
||||
const body = (await res.json()) as { data?: ProposalDto }
|
||||
if (!alive) return
|
||||
if (!body.data) return setState({ status: 'error' })
|
||||
setState({ status: 'ready', proposal: body.data })
|
||||
} catch {
|
||||
if (alive) setState({ status: 'error' })
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [open, transactionId])
|
||||
|
||||
// Pre-fill the dialog once, when a confident proposal with a real account arrives.
|
||||
useEffect(() => {
|
||||
if (state.status !== 'ready') return
|
||||
const p = state.proposal
|
||||
if (!p.account || appliedRef.current === p.account) return
|
||||
if (bandOf(p) === 'review') return
|
||||
appliedRef.current = p.account
|
||||
onApply(p.account, p.vatTreatment ?? 'none')
|
||||
}, [state, onApply])
|
||||
|
||||
if (state.status === 'loading') {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border bg-secondary/30 px-3 py-2.5 text-sm text-muted-foreground">
|
||||
<Sparkles className="h-3.5 w-3.5 animate-pulse" />
|
||||
Assistenten föreslår kontering…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (state.status === 'error') return null // fall back silently to the deterministic default
|
||||
|
||||
if (state.status === 'unconfigured') {
|
||||
return (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-border bg-secondary/30 px-3 py-2.5 text-xs text-muted-foreground">
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-warning" />
|
||||
Assistenten är inte konfigurerad. Välj konto nedan som vanligt.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const p = state.proposal
|
||||
const band = bandOf(p)
|
||||
const alternatives = p.candidates.filter((c) => c.account !== p.account).slice(0, 3)
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card">
|
||||
<div className="flex items-start justify-between gap-3 px-3 pt-3">
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
Assistentens förslag
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-[11px] font-medium',
|
||||
band === 'sure' && 'bg-success/15 text-success',
|
||||
band === 'likely' && 'bg-warning/15 text-warning',
|
||||
band === 'review' && 'bg-secondary text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'h-1.5 w-1.5 rounded-full',
|
||||
band === 'sure' && 'bg-success',
|
||||
band === 'likely' && 'bg-warning',
|
||||
band === 'review' && 'bg-muted-foreground',
|
||||
)}
|
||||
/>
|
||||
{BAND_LABEL[band]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="px-3 pb-3 pt-2">
|
||||
{band === 'review' ? (
|
||||
<p className="text-sm text-foreground">
|
||||
{p.reasoning || 'För lite underlag för att avgöra konto. Välj konto nedan.'}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{p.reasoning && (
|
||||
<p className="text-[13px] leading-snug text-muted-foreground">{p.reasoning}</p>
|
||||
)}
|
||||
{alternatives.length > 0 && (
|
||||
<div className="mt-2.5">
|
||||
<p className="mb-1.5 text-[11px] text-muted-foreground">Byt konto:</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{alternatives.map((c) => (
|
||||
<button
|
||||
key={c.account}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
appliedRef.current = c.account
|
||||
onApply(c.account, c.vatTreatment ?? 'none')
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-border bg-card px-2.5 py-1 text-xs transition-colors hover:bg-secondary"
|
||||
>
|
||||
<span className="font-mono">{c.account}</span>
|
||||
<span className="text-muted-foreground">{c.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import InboxDocumentPicker from '@/components/bookkeeping/InboxDocumentPicker'
|
||||
import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
|
||||
import type { AvailableInboxDoc } from '@/components/bookkeeping/InboxDocumentPicker'
|
||||
import VatTreatmentSelect from './VatTreatmentSelect'
|
||||
import AiCategorizeProposal from './AiCategorizeProposal'
|
||||
import { VAT_TREATMENT_OPTIONS } from './transaction-types'
|
||||
import type { TransactionWithInvoice } from './transaction-types'
|
||||
import type { TransactionCategory, VatTreatment, BASAccount, EntityType, LinePatternEntry } from '@/types'
|
||||
@@ -439,6 +440,22 @@ export default function QuickReviewDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI booking proposal: pre-fills account + VAT and explains why.
|
||||
Falls back silently to the deterministic defaults on error. */}
|
||||
{tx.id && (
|
||||
<AiCategorizeProposal
|
||||
key={tx.id}
|
||||
transactionId={tx.id}
|
||||
open={open}
|
||||
onApply={(account, vat) => {
|
||||
handleAccountChange(account)
|
||||
// handleAccountChange clears VAT for class-2 accounts; for the
|
||||
// rest, apply the proposed treatment.
|
||||
if (!account.startsWith('2')) setVatTreatment(vat)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Template or Category */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">
|
||||
|
||||
Reference in New Issue
Block a user