Files
accounted/components/agent-inbox/AgentInbox.tsx
T
MattssonandClaude Opus 4.7 1af977950b Ai/full autonomous flow (#359)
* Refactor bookkeeping error handling and introduce new error classes

- Introduced new error classes for better error categorization:
  - JournalEntryNotBalancedError
  - FiscalPeriodNotFoundError
  - EntryDateOutsideFiscalPeriodError
  - JournalEntryNotFoundError
  - CannotReverseNonPostedError
  - CannotCorrectNonPostedError
  - EntryAlreadyReversedError
  - CurrencyRevaluationAlreadyExistsError
  - InvalidMappingResultError
  - BookkeepingDatabaseError

- Updated existing functions in engine.ts and transaction-entries.ts to throw specific errors instead of generic ones.
- Enhanced error response handling in get-error-message.ts to provide localized messages for new error types.
- Added unit tests for new error classes and error handling functions to ensure correctness and coverage.

* feat(ai): implement AI proposal application and persistence

- Add apply.ts to handle the application of AI proposals, including match and booking steps.
- Introduce persist.ts for inserting and managing AI requests and proposals, ensuring unique constraints.
- Create re-validate.ts for validating proposals before acceptance, checking for stale conditions.
- Define database migrations for ai_requests and ai_proposals tables, including constraints and indexes.
- Enhance journal_entries with AI provenance tracking, linking entries to AI proposals.
- Update categorization_templates to distinguish AI-corrected templates.
- Add company settings for toggling AI flow and managing backfill processes.
- Extend processing_history to include AI-related events for better tracking.

* feat: add uncategorized transactions API and UI for transaction selection

- Implemented a new API endpoint for fetching uncategorized transactions with pagination and filtering options.
- Created ChangeTransactionDialog component for selecting alternative transactions based on AI proposals.
- Developed ReceiptDetailDialog to display detailed information about receipts, including upload functionality.
- Added TransactionDetailDialog for viewing transaction details with links to the transaction list.
- Introduced receipt quality assessment logic to evaluate extracted receipt data.
- Implemented feature flagging for the AI bookkeeping agent to control availability in different environments.

* feat: add manual receipt extraction dialog and integrate AWS Textract for expense analysis

- Added ManualExtractDialog component for user input when AI fails to extract receipt data.
- Implemented ReceiptsList component to manage and display uploaded receipts, including upload and rescan functionalities.
- Introduced Textract integration for analyzing expenses, extracting fields like total, vendor, and date.
- Updated package.json to include @aws-sdk/client-textract dependency.

* fix(ai): handle livsmedel VAT transition (12% → 6%) in booking prompt and re-validate guard

Add date-aware guidance to BOOKING_SYSTEM_PROMPT for the temporary livsmedel
VAT cut (Prop. 2025/26:55, 2026-04-01 to 2027-12-31), with restaurang/servering
carve-out at 12%. Add a re-validate safety net that rejects clearly-stale rate
labels for grocery-chain merchants relative to the entry date.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 10:32:15 +02:00

563 lines
21 KiB
TypeScript

'use client'
import { useState, useMemo, useCallback, useEffect, useRef } from 'react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { useToast } from '@/components/ui/use-toast'
import { PageHeader } from '@/components/ui/page-header'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Progress } from '@/components/ui/progress'
import { Sparkles, PlayCircle, XCircle, Loader2 } from 'lucide-react'
import ProposalCard from './ProposalCard'
import RequestCard from './RequestCard'
import EditBookingDialog from './EditBookingDialog'
import LearningPromptDialog from './LearningPromptDialog'
import ChangeTransactionDialog from './ChangeTransactionDialog'
import type { AgentInboxItemView } from '@/app/(dashboard)/agent-inbox/page'
import type { AIProposal, BookingProposalPayload } from '@/types'
type FilterKey = 'all' | 'match' | 'booking'
// The match step encompasses two UI states: actual match proposals waiting
// for approval AND ai_requests where Claude couldn't find a candidate and
// asked the user to pick manually. Both belong under the "Matchning" tab.
function filterKeyFor(item: AgentInboxItemView): Exclude<FilterKey, 'all'> {
if (item.proposal?.step_type === 'booking') return 'booking'
return 'match'
}
interface AgentInboxProps {
initialItems: AgentInboxItemView[]
}
interface LearningPromptState {
proposalId: string
counterparty_name: string
debit_account: string
credit_account: string
vat_treatment: string | null
}
export default function AgentInbox({ initialItems }: AgentInboxProps) {
const [items, setItems] = useState(initialItems)
// After router.refresh() the server re-runs and passes a new initialItems
// prop. useState only reads its arg on mount, so sync explicitly — otherwise
// newly-chained booking proposals stay invisible after a match accept.
useEffect(() => {
setItems(initialItems)
}, [initialItems])
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [busyProposalId, setBusyProposalId] = useState<string | null>(null)
const [editProposal, setEditProposal] = useState<AIProposal | null>(null)
const [changeMatchItem, setChangeMatchItem] = useState<AgentInboxItemView | null>(null)
const [learningPrompt, setLearningPrompt] = useState<LearningPromptState | null>(null)
const [backfillRunning, setBackfillRunning] = useState(false)
const [batchRunning, setBatchRunning] = useState(false)
const [filter, setFilter] = useState<FilterKey>('all')
const [backfillProgress, setBackfillProgress] = useState<{
target: number
startPending: number
currentPending: number
} | null>(null)
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const stableTicksRef = useRef(0)
const { toast } = useToast()
const router = useRouter()
// Cleanup any running poller on unmount.
useEffect(() => () => {
if (pollRef.current) clearInterval(pollRef.current)
}, [])
// Counts per filter bucket, used on the tab triggers.
const counts = useMemo(() => {
let match = 0, booking = 0
for (const i of items) {
if (filterKeyFor(i) === 'booking') booking++
else match++
}
return { all: items.length, match, booking }
}, [items])
const filteredItems = useMemo(() => {
if (filter === 'all') return items
return items.filter((i) => filterKeyFor(i) === filter)
}, [items, filter])
const selectableProposalIds = useMemo(
() =>
filteredItems
.filter((i) => i.proposal && i.proposal.status === 'pending')
.map((i) => i.proposal!.id),
[filteredItems]
)
const toggleSelect = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}, [])
const selectAll = useCallback(() => {
setSelectedIds(new Set(selectableProposalIds))
}, [selectableProposalIds])
const clearSelection = useCallback(() => setSelectedIds(new Set()), [])
const removeItem = useCallback((proposalId: string | null, requestId: string | null) => {
setItems((prev) =>
prev.filter((i) => {
if (proposalId && i.proposal?.id === proposalId) return false
if (requestId && i.request?.id === requestId) return false
return true
})
)
if (proposalId) {
setSelectedIds((prev) => {
const next = new Set(prev)
next.delete(proposalId)
return next
})
}
}, [])
// ── Accept ─────────────────────────────────────────────────────────
const handleAccept = async (
proposal: AIProposal,
edits?: BookingProposalPayload | { matched_transaction_id: string }
) => {
setBusyProposalId(proposal.id)
try {
const res = await fetch(`/api/ai/proposals/${proposal.id}/accept`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ version: proposal.version, edits }),
})
const body = await res.json()
if (!res.ok) {
toast({ title: 'Kunde inte godkänna', description: body.error, variant: 'destructive' })
return
}
toast({ title: proposal.step_type === 'match' ? 'Matchning godkänd' : 'Bokförd' })
if (body.data?.learning_prompt) {
setLearningPrompt({
proposalId: proposal.id,
counterparty_name: body.data.learning_prompt.counterparty_name,
debit_account: body.data.learning_prompt.debit_account,
credit_account: body.data.learning_prompt.credit_account,
vat_treatment: body.data.learning_prompt.vat_treatment,
})
}
removeItem(proposal.id, null)
// Accepting a match proposal chains to a new booking proposal (generated
// synchronously inside the event handler during accept). Accepting a
// booking proposal produces the terminal state. Refresh the server
// component either way so the new state lands on screen.
router.refresh()
} catch (err) {
toast({
title: 'Fel',
description: err instanceof Error ? err.message : String(err),
variant: 'destructive',
})
} finally {
setBusyProposalId(null)
}
}
// ── Reject ─────────────────────────────────────────────────────────
const handleReject = async (proposal: AIProposal) => {
setBusyProposalId(proposal.id)
try {
const res = await fetch(`/api/ai/proposals/${proposal.id}/reject`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ version: proposal.version }),
})
const body = await res.json()
if (!res.ok) {
toast({ title: 'Kunde inte avvisa', description: body.error, variant: 'destructive' })
return
}
toast({ title: 'Avvisad' })
removeItem(proposal.id, null)
router.refresh()
} finally {
setBusyProposalId(null)
}
}
// ── Batch accept ───────────────────────────────────────────────────
const handleBatchAccept = async () => {
if (selectedIds.size === 0) return
setBatchRunning(true)
try {
const res = await fetch('/api/ai/proposals/batch-accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ proposal_ids: [...selectedIds] }),
})
const body = await res.json()
if (!res.ok) {
toast({ title: 'Batch-godkännande misslyckades', description: body.error, variant: 'destructive' })
return
}
const { accepted, failed, outcomes } = body.data
toast({
title: `${accepted} godkända${failed > 0 ? `, ${failed} misslyckades` : ''}`,
})
// Remove only the ones that succeeded.
const successIds = new Set<string>(
(outcomes as Array<{ proposal_id: string; ok: boolean }>)
.filter((o) => o.ok)
.map((o) => o.proposal_id)
)
setItems((prev) => prev.filter((i) => !(i.proposal && successIds.has(i.proposal.id))))
clearSelection()
router.refresh()
} finally {
setBatchRunning(false)
}
}
// ── Backfill ───────────────────────────────────────────────────────
//
// The server runs a fire-and-forget loop that drafts proposals one at a
// time. Without feedback the user clicks the button and sees nothing. So
// here we poll the proposal count every 2s and show a progress card: the
// target is "pending proposals count at start + queued_match + queued_booking",
// the delta against that is progress. Poller stops when: (a) target hit,
// (b) count hasn't moved for 3 consecutive polls (drafted everything it
// could), or (c) user clicks cancel.
const stopPolling = useCallback(() => {
if (pollRef.current) {
clearInterval(pollRef.current)
pollRef.current = null
}
stableTicksRef.current = 0
}, [])
const fetchPendingCount = useCallback(async (): Promise<number | null> => {
try {
const res = await fetch('/api/ai/proposals?status=pending&limit=1')
if (!res.ok) return null
const body = await res.json()
return typeof body.count === 'number' ? body.count : null
} catch { return null }
}, [])
const handleBackfill = async () => {
setBackfillRunning(true)
try {
// Snapshot current pending count before kicking off. The target is
// this + the queued counts the server reports back.
const startPending = (await fetchPendingCount()) ?? 0
const res = await fetch('/api/ai/backfill/receipts', { method: 'POST' })
const body = await res.json()
if (!res.ok) {
toast({ title: 'Kunde inte starta backfill', description: body.error, variant: 'destructive' })
setBackfillRunning(false)
return
}
const queuedTotal = (body.data.queued_match || 0) + (body.data.queued_booking || 0)
if (queuedTotal === 0) {
toast({ title: 'Inget att bearbeta', description: 'Alla kvitton har redan förslag.' })
setBackfillRunning(false)
return
}
setBackfillProgress({
target: queuedTotal,
startPending,
currentPending: startPending,
})
toast({
title: 'Bearbetar befintliga',
description: `${queuedTotal} kvitton i kö. Det tar ca ${Math.ceil(queuedTotal * 5 / 60)} min.`,
})
// Start polling. First tick fires after 2s so the initial state
// matches what the server saw on the snapshot.
stableTicksRef.current = 0
pollRef.current = setInterval(async () => {
const current = await fetchPendingCount()
if (current == null) return
setBackfillProgress((prev) => {
if (!prev) return prev
const newState = { ...prev, currentPending: current }
const done = current - prev.startPending
if (done >= prev.target) {
// Hit the expected target — wrap up.
stopPolling()
setBackfillRunning(false)
toast({ title: 'Klart', description: `${done} förslag skapade.` })
router.refresh()
return null
}
if (current === prev.currentPending) {
stableTicksRef.current += 1
} else {
stableTicksRef.current = 0
}
// Stability threshold: 6 ticks * 2s = 12s no change → assume loop
// ran out of eligible items (some failed, skipped, etc).
if (stableTicksRef.current >= 6) {
stopPolling()
setBackfillRunning(false)
const short = prev.target - done
toast({
title: 'Backfill klar',
description: short > 0
? `${done} av ${prev.target} lyckades. ${short} kunde inte bearbetas (kontrollera kvittobilderna).`
: `${done} förslag skapade.`,
})
router.refresh()
return null
}
// Refresh every poll so new cards appear as they're drafted.
router.refresh()
return newState
})
}, 2000)
} catch (err) {
toast({ title: 'Fel', description: String(err), variant: 'destructive' })
setBackfillRunning(false)
}
}
const handleCancelBackfill = async () => {
await fetch('/api/ai/backfill/cancel', { method: 'POST' })
stopPolling()
setBackfillProgress(null)
setBackfillRunning(false)
toast({ title: 'Backfill stoppades' })
router.refresh()
}
// ── Learning prompt ────────────────────────────────────────────────
const handleRememberYes = async () => {
if (!learningPrompt) return
await fetch('/api/ai/learning/remember', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
proposal_id: learningPrompt.proposalId,
counterparty_name: learningPrompt.counterparty_name,
debit_account: learningPrompt.debit_account,
credit_account: learningPrompt.credit_account,
vat_treatment: learningPrompt.vat_treatment,
category: null,
}),
})
toast({ title: 'Sparad som mall' })
setLearningPrompt(null)
}
return (
<div className="container mx-auto p-4 sm:p-8 max-w-5xl">
<PageHeader
title="Agent-inkorg"
description="AI föreslår bokföring — du godkänner varje steg."
action={
<div className="flex gap-2">
{!backfillRunning ? (
<Button variant="outline" onClick={handleBackfill} disabled={backfillRunning}>
<PlayCircle className="mr-2 h-4 w-4" />
Bearbeta befintliga
</Button>
) : (
<Button variant="outline" onClick={handleCancelBackfill}>
<XCircle className="mr-2 h-4 w-4" />
Stoppa backfill
</Button>
)}
</div>
}
/>
{backfillProgress && (
<BackfillProgressCard progress={backfillProgress} onCancel={handleCancelBackfill} />
)}
{items.length > 0 && (
<Tabs value={filter} onValueChange={(v) => setFilter(v as FilterKey)} className="mb-4">
<TabsList>
<TabsTrigger value="all">Allt ({counts.all})</TabsTrigger>
<TabsTrigger value="match">Matchning ({counts.match})</TabsTrigger>
<TabsTrigger value="booking">Bokföring ({counts.booking})</TabsTrigger>
</TabsList>
</Tabs>
)}
{items.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<div className="p-5 rounded-full bg-muted mb-6">
<Sparkles className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-lg font-medium mb-2">Inga väntande förslag</h3>
<p className="text-sm text-muted-foreground text-center max-w-sm">
När nya kvitton klassas i inkorgen kommer AI-förslagen att visas här.
</p>
</CardContent>
</Card>
) : (
<>
{selectableProposalIds.length > 1 && (
<div className="flex items-center justify-between mb-4">
<Button variant="ghost" size="sm" onClick={selectAll}>
Markera alla ({selectableProposalIds.length})
</Button>
{selectedIds.size > 0 && (
<Button variant="ghost" size="sm" onClick={clearSelection}>
Avmarkera ({selectedIds.size})
</Button>
)}
</div>
)}
<div className="flex flex-col gap-3">
{filteredItems.length === 0 && (
<Card>
<CardContent className="py-10 text-center text-sm text-muted-foreground">
Inga kort i denna vy.
</CardContent>
</Card>
)}
{filteredItems.map((item) => {
if (item.proposal) {
return (
<ProposalCard
key={`p-${item.proposal.id}`}
item={item}
isSelected={selectedIds.has(item.proposal.id)}
isBusy={busyProposalId === item.proposal.id}
onToggleSelect={() => toggleSelect(item.proposal!.id)}
onAccept={() => handleAccept(item.proposal!)}
onReject={() => handleReject(item.proposal!)}
onEdit={() => setEditProposal(item.proposal!)}
onChangeMatch={() => setChangeMatchItem(item)}
/>
)
}
if (item.request) {
return (
<RequestCard
key={`r-${item.request.id}`}
item={item}
onDismiss={() => removeItem(null, item.request!.id)}
/>
)
}
return null
})}
</div>
{selectedIds.size > 0 && (
<div className="fixed bottom-20 md:bottom-6 left-1/2 -translate-x-1/2 bg-background border shadow-lg rounded-full px-4 py-3 flex items-center gap-3 z-40">
<span className="text-sm font-medium">{selectedIds.size} valda</span>
<Button size="sm" onClick={handleBatchAccept} disabled={batchRunning}>
{batchRunning ? 'Godkänner…' : `Godkänn ${selectedIds.size} st`}
</Button>
</div>
)}
</>
)}
{editProposal && (
<EditBookingDialog
proposal={editProposal}
onClose={() => setEditProposal(null)}
onSubmit={async (edits) => {
const proposal = editProposal
setEditProposal(null)
await handleAccept(proposal, edits)
}}
/>
)}
{changeMatchItem?.proposal && (
<ChangeTransactionDialog
open={true}
onOpenChange={(open) => { if (!open) setChangeMatchItem(null) }}
proposal={changeMatchItem.proposal}
receiptTotal={
(changeMatchItem.inbox_item.extracted_data as { totals?: { total?: number | null } } | null)
?.totals?.total ?? null
}
receiptDate={
(changeMatchItem.inbox_item.extracted_data as { receipt?: { date?: string | null } } | null)
?.receipt?.date ?? null
}
onChanged={() => {
setChangeMatchItem(null)
router.refresh()
}}
/>
)}
{learningPrompt && (
<LearningPromptDialog
counterpartyName={learningPrompt.counterparty_name}
debitAccount={learningPrompt.debit_account}
creditAccount={learningPrompt.credit_account}
onYes={handleRememberYes}
onNo={() => setLearningPrompt(null)}
/>
)}
</div>
)
}
// Progress indicator shown while "Bearbeta befintliga" runs. Target is the
// number of proposals queued at start; `done` is the delta against the
// initial pending count. Caps visible done at target to avoid flicker above
// 100% when other proposals happen to land during the run.
function BackfillProgressCard({
progress,
onCancel,
}: {
progress: { target: number; startPending: number; currentPending: number }
onCancel: () => void
}) {
const done = Math.max(0, progress.currentPending - progress.startPending)
const capped = Math.min(done, progress.target)
const pct = progress.target > 0 ? Math.round((capped / progress.target) * 100) : 0
return (
<Card className="mb-4 border-primary/30 bg-primary/[0.02]">
<CardContent className="p-4 space-y-3">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin text-primary" />
<span className="text-sm font-medium">Bearbetar befintliga kvitton</span>
</div>
<div className="flex items-center gap-3">
<span className="text-sm tabular-nums text-muted-foreground">
{capped} av {progress.target}
</span>
<Button size="sm" variant="ghost" onClick={onCancel}>
<XCircle className="mr-1.5 h-3.5 w-3.5" />
Stoppa
</Button>
</div>
</div>
<Progress value={pct} />
<p className="text-xs text-muted-foreground">
AI-agenten skapar förslag ett kvitto i taget. Nya kort dyker upp här automatiskt.
</p>
</CardContent>
</Card>
)
}