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>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
ab63da8324
commit
1af977950b
@@ -0,0 +1,562 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Loader2, Search, Sparkles, Check } from 'lucide-react'
|
||||
import { formatCurrency, formatDate, cn } from '@/lib/utils'
|
||||
import type { AIProposal, MatchProposalPayload } from '@/types'
|
||||
|
||||
type ChangeSource = 'user_alternative' | 'user_manual' | 'ai_regenerated'
|
||||
|
||||
interface ChangeTransactionDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
proposal: AIProposal
|
||||
receiptTotal: number | null
|
||||
receiptDate: string | null
|
||||
onChanged: () => void
|
||||
}
|
||||
|
||||
interface PickerTx {
|
||||
id: string
|
||||
date: string
|
||||
description: string | null
|
||||
amount: number
|
||||
currency: string | null
|
||||
merchant_name: string | null
|
||||
}
|
||||
|
||||
export default function ChangeTransactionDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
proposal,
|
||||
receiptTotal,
|
||||
receiptDate,
|
||||
onChanged,
|
||||
}: ChangeTransactionDialogProps) {
|
||||
const payload = proposal.proposal_json as MatchProposalPayload
|
||||
const alternatives = payload.alternatives ?? []
|
||||
|
||||
const [selected, setSelected] = useState<{ id: string; source: ChangeSource } | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [showAll, setShowAll] = useState(alternatives.length === 0)
|
||||
const [search, setSearch] = useState('')
|
||||
const [allTx, setAllTx] = useState<PickerTx[]>([])
|
||||
const [alternativeTx, setAlternativeTx] = useState<Record<string, PickerTx>>({})
|
||||
const [loadingList, setLoadingList] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Reset when dialog opens/closes or proposal changes.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSelected(null)
|
||||
setSearch('')
|
||||
setError(null)
|
||||
}
|
||||
}, [open, proposal.id])
|
||||
|
||||
// Fetch human-readable context for the AI's alternatives so the user
|
||||
// can compare description/amount/date, not bare UUIDs.
|
||||
useEffect(() => {
|
||||
if (!open || alternatives.length === 0) return
|
||||
const ids = alternatives.map((a) => a.transaction_id)
|
||||
fetch(`/api/transactions/uncategorized?limit=50&offset=0`)
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
const list: PickerTx[] = body?.data?.transactions ?? []
|
||||
const map: Record<string, PickerTx> = {}
|
||||
for (const tx of list) if (ids.includes(tx.id)) map[tx.id] = tx
|
||||
setAlternativeTx(map)
|
||||
})
|
||||
.catch(() => { /* alternatives still show with reasoning text */ })
|
||||
}, [open, alternatives])
|
||||
|
||||
// Fetch the full picker list when the user opens "Visa alla".
|
||||
const loadAllTransactions = useCallback(async () => {
|
||||
setLoadingList(true)
|
||||
setError(null)
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
params.set('limit', '30')
|
||||
if (search) params.set('search', search)
|
||||
if (receiptTotal) {
|
||||
params.set('amount_center', String(-Math.abs(receiptTotal)))
|
||||
params.set('amount_window', String(Math.max(5, Math.abs(receiptTotal) * 0.1)))
|
||||
}
|
||||
if (receiptDate) {
|
||||
params.set('date_center', receiptDate)
|
||||
params.set('date_window', '60')
|
||||
}
|
||||
const res = await fetch(`/api/transactions/uncategorized?${params.toString()}`)
|
||||
const body = await res.json()
|
||||
if (!res.ok) {
|
||||
setError(body?.error ?? 'Kunde inte hämta transaktioner')
|
||||
return
|
||||
}
|
||||
setAllTx(body?.data?.transactions ?? [])
|
||||
} catch {
|
||||
setError('Nätverksfel')
|
||||
} finally {
|
||||
setLoadingList(false)
|
||||
}
|
||||
}, [search, receiptTotal, receiptDate])
|
||||
|
||||
// Refetch whenever the "show all" pane is open and the search changes.
|
||||
useEffect(() => {
|
||||
if (!open || !showAll) return
|
||||
loadAllTransactions()
|
||||
}, [open, showAll, loadAllTransactions])
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!selected) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`/api/ai/proposals/${proposal.id}/change-match`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
version: proposal.version,
|
||||
matched_transaction_id: selected.id,
|
||||
source: selected.source,
|
||||
}),
|
||||
})
|
||||
const body = await res.json()
|
||||
if (!res.ok) {
|
||||
setError(body?.error ?? 'Kunde inte uppdatera förslaget')
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
onChanged()
|
||||
onOpenChange(false)
|
||||
} catch {
|
||||
setError('Nätverksfel')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const currentMatchId = payload.matched_transaction_id
|
||||
|
||||
const alternativesWithContext = useMemo(
|
||||
() =>
|
||||
alternatives.map((alt) => ({
|
||||
...alt,
|
||||
tx: alternativeTx[alt.transaction_id] ?? null,
|
||||
})),
|
||||
[alternatives, alternativeTx]
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Byt transaktion</DialogTitle>
|
||||
<DialogDescription>
|
||||
Välj en annan transaktion att koppla kvittot till. Du kan välja bland AI:ns
|
||||
alternativ eller söka i alla okategoriserade transaktioner.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-6 py-2">
|
||||
{/* AI alternatives */}
|
||||
{alternatives.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-xs font-medium uppercase tracking-wider text-muted-foreground mb-2 flex items-center gap-2">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
AI:ns alternativ ({alternatives.length})
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{alternativesWithContext.map((alt) => (
|
||||
<AlternativeRow
|
||||
key={alt.transaction_id}
|
||||
tx={alt.tx}
|
||||
transactionId={alt.transaction_id}
|
||||
confidence={alt.confidence}
|
||||
reasoning={alt.reasoning}
|
||||
isCurrent={alt.transaction_id === currentMatchId}
|
||||
isSelected={selected?.id === alt.transaction_id}
|
||||
onSelect={() =>
|
||||
setSelected({ id: alt.transaction_id, source: 'user_alternative' })
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Toggle manual picker */}
|
||||
{alternatives.length > 0 && !showAll && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowAll(true)}
|
||||
className="w-full"
|
||||
>
|
||||
<Search className="h-3.5 w-3.5 mr-2" />
|
||||
Visa alla transaktioner
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Manual picker */}
|
||||
{showAll && (
|
||||
<section>
|
||||
<h3 className="text-xs font-medium uppercase tracking-wider text-muted-foreground mb-2">
|
||||
Alla okategoriserade transaktioner
|
||||
</h3>
|
||||
<div className="relative mb-2">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Sök beskrivning eller handlare…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
{receiptTotal && (
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
Filtrerat på belopp runt {formatCurrency(-Math.abs(receiptTotal), 'SEK')} och datum runt{' '}
|
||||
{receiptDate ? formatDate(receiptDate) : '—'}. Rensa sökrutan för att se fler.
|
||||
</p>
|
||||
)}
|
||||
{loadingList ? (
|
||||
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
) : allTx.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-6">
|
||||
Inga matchande transaktioner
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{allTx.map((tx) => (
|
||||
<PickerRow
|
||||
key={tx.id}
|
||||
tx={tx}
|
||||
isCurrent={tx.id === currentMatchId}
|
||||
isSelected={selected?.id === tx.id}
|
||||
onSelect={() => setSelected({ id: tx.id, source: 'user_manual' })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} disabled={!selected || saving}>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Sparar…
|
||||
</>
|
||||
) : (
|
||||
'Använd denna transaktion'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function AlternativeRow({
|
||||
tx,
|
||||
transactionId,
|
||||
confidence,
|
||||
reasoning,
|
||||
isCurrent,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: {
|
||||
tx: PickerTx | null
|
||||
transactionId: string
|
||||
confidence: number
|
||||
reasoning: string
|
||||
isCurrent: boolean
|
||||
isSelected: boolean
|
||||
onSelect: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
disabled={isCurrent}
|
||||
className={cn(
|
||||
'w-full text-left rounded border p-3 transition-colors',
|
||||
isSelected && 'border-primary bg-primary/5',
|
||||
!isSelected && 'hover:bg-muted/40',
|
||||
isCurrent && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 mb-1">
|
||||
<div className="flex-1 min-w-0">
|
||||
{tx ? (
|
||||
<>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-sm truncate">{tx.description ?? 'Okänd'}</span>
|
||||
<span className="text-sm tabular-nums font-medium">
|
||||
{formatCurrency(tx.amount, tx.currency ?? 'SEK')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{formatDate(tx.date)}</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground font-mono">
|
||||
{transactionId.slice(0, 8)}…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{isCurrent && <Badge variant="outline" className="text-xs">Nuvarande</Badge>}
|
||||
<Badge className="text-xs">{Math.round(confidence * 100)}%</Badge>
|
||||
{isSelected && <Check className="h-4 w-4 text-primary" />}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground italic">“{reasoning}”</p>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function PickerRow({
|
||||
tx,
|
||||
isCurrent,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: {
|
||||
tx: PickerTx
|
||||
isCurrent: boolean
|
||||
isSelected: boolean
|
||||
onSelect: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
disabled={isCurrent}
|
||||
className={cn(
|
||||
'w-full text-left rounded border p-2.5 transition-colors',
|
||||
isSelected && 'border-primary bg-primary/5',
|
||||
!isSelected && 'hover:bg-muted/40',
|
||||
isCurrent && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-sm truncate">{tx.description ?? 'Okänd'}</span>
|
||||
<span className="text-sm tabular-nums font-medium">
|
||||
{formatCurrency(tx.amount, tx.currency ?? 'SEK')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground mt-0.5">
|
||||
<span>{formatDate(tx.date)}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{isCurrent && <Badge variant="outline" className="text-xs">Nuvarande</Badge>}
|
||||
{isSelected && <Check className="h-4 w-4 text-primary" />}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import type { AIProposal, BookingProposalLine, BookingProposalPayload } from '@/types'
|
||||
|
||||
interface EditBookingDialogProps {
|
||||
proposal: AIProposal
|
||||
onClose: () => void
|
||||
onSubmit: (edits: BookingProposalPayload) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal in-place editor for a booking proposal.
|
||||
*
|
||||
* v1 lets the user change account numbers + amounts per line; the full
|
||||
* AccountCombobox + VatTreatmentSelect UX comes in a polish pass. The
|
||||
* point of this dialog is proving the edit-accept-learning-prompt path
|
||||
* works end-to-end before we invest in the richer form.
|
||||
*/
|
||||
export default function EditBookingDialog({ proposal, onClose, onSubmit }: EditBookingDialogProps) {
|
||||
const original = proposal.proposal_json as BookingProposalPayload
|
||||
const [lines, setLines] = useState<BookingProposalLine[]>(original.lines)
|
||||
const [description, setDescription] = useState(original.description)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const totalDebit = lines.reduce((s, l) => s + (Number(l.debit_amount) || 0), 0)
|
||||
const totalCredit = lines.reduce((s, l) => s + (Number(l.credit_amount) || 0), 0)
|
||||
const balanced = Math.abs(totalDebit - totalCredit) < 0.005 && totalDebit > 0
|
||||
|
||||
const updateLine = (index: number, patch: Partial<BookingProposalLine>) => {
|
||||
setLines((prev) => prev.map((l, i) => (i === index ? { ...l, ...patch } : l)))
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!balanced) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await onSubmit({
|
||||
...original,
|
||||
lines,
|
||||
description,
|
||||
})
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Redigera bokföringsförslag</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="description">Beskrivning</Label>
|
||||
<Input
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Rader</Label>
|
||||
<div className="mt-1 border rounded">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-muted-foreground bg-muted/40">
|
||||
<tr>
|
||||
<th className="text-left p-2 font-normal">Konto</th>
|
||||
<th className="text-left p-2 font-normal">Beskrivning</th>
|
||||
<th className="text-right p-2 font-normal">Debet</th>
|
||||
<th className="text-right p-2 font-normal">Kredit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lines.map((line, i) => (
|
||||
<tr key={i} className="border-t">
|
||||
<td className="p-2">
|
||||
<Input
|
||||
value={line.account_number}
|
||||
onChange={(e) => updateLine(i, { account_number: e.target.value })}
|
||||
className="h-8 font-mono w-20"
|
||||
maxLength={4}
|
||||
/>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<Input
|
||||
value={line.description}
|
||||
onChange={(e) => updateLine(i, { description: e.target.value })}
|
||||
className="h-8"
|
||||
/>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={line.debit_amount || ''}
|
||||
onChange={(e) =>
|
||||
updateLine(i, { debit_amount: parseFloat(e.target.value) || 0 })
|
||||
}
|
||||
className="h-8 text-right tabular-nums w-24 ml-auto"
|
||||
/>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={line.credit_amount || ''}
|
||||
onChange={(e) =>
|
||||
updateLine(i, { credit_amount: parseFloat(e.target.value) || 0 })
|
||||
}
|
||||
className="h-8 text-right tabular-nums w-24 ml-auto"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t text-xs">
|
||||
<td colSpan={2} className="p-2 text-muted-foreground">
|
||||
Summa
|
||||
</td>
|
||||
<td className="p-2 text-right tabular-nums">{totalDebit.toFixed(2)}</td>
|
||||
<td className="p-2 text-right tabular-nums">{totalCredit.toFixed(2)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
{!balanced && (
|
||||
<p className="text-xs text-destructive mt-1">
|
||||
Debet och kredit måste summera till samma belopp.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={onClose} disabled={submitting}>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!balanced || submitting}>
|
||||
{submitting ? 'Bokför…' : 'Godkänn med ändringar'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
'use client'
|
||||
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface LearningPromptDialogProps {
|
||||
counterpartyName: string
|
||||
debitAccount: string
|
||||
creditAccount: string
|
||||
onYes: () => void
|
||||
onNo: () => void
|
||||
}
|
||||
|
||||
export default function LearningPromptDialog({
|
||||
counterpartyName,
|
||||
debitAccount,
|
||||
creditAccount,
|
||||
onYes,
|
||||
onNo,
|
||||
}: LearningPromptDialogProps) {
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onNo()}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Kom ihåg denna bokföring?</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm">
|
||||
Vill du att AI:n använder samma kontering nästa gång ett kvitto från{' '}
|
||||
<strong>{counterpartyName}</strong> dyker upp?
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground font-mono">
|
||||
Debet {debitAccount} · Kredit {creditAccount}
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={onNo}>
|
||||
Bara den här gången
|
||||
</Button>
|
||||
<Button onClick={onYes}>Ja, kom ihåg</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
'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 (
|
||||
<Card className="transition-colors">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="pt-1">
|
||||
<Checkbox checked={isSelected} onCheckedChange={onToggleSelect} aria-label="Markera" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<Badge variant="outline">{isMatch ? 'Match' : 'Bokföring'}</Badge>
|
||||
<Badge className={confidenceColor(proposal.confidence)}>
|
||||
{confidenceLabel(proposal.confidence)}
|
||||
</Badge>
|
||||
{isUserEdited && (
|
||||
<Badge variant="outline" className="text-xs border-success/40 text-success-foreground">
|
||||
Ändrad av användare
|
||||
</Badge>
|
||||
)}
|
||||
{inbox.document && (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{inbox.document.file_name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isMatch && matchPayload && (
|
||||
<MatchProposalBody
|
||||
payload={matchPayload}
|
||||
reasoning={proposal.reasoning}
|
||||
transaction={tx}
|
||||
inbox={inbox}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isMatch && bookingPayload && (
|
||||
<BookingProposalBody payload={bookingPayload} reasoning={proposal.reasoning} />
|
||||
)}
|
||||
|
||||
{receiptMissing && (
|
||||
<p className="text-xs text-muted-foreground mt-3 flex items-center gap-1.5">
|
||||
<span className="inline-block w-1 h-1 rounded-full bg-warning" />
|
||||
Kvittobild saknas — ladda upp i kvittodialogen innan du kan godkänna.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 mt-4 flex-wrap">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onAccept}
|
||||
disabled={isBusy || receiptMissing}
|
||||
title={receiptMissing ? 'Kvittobild krävs för att bokföra' : undefined}
|
||||
>
|
||||
{isBusy ? '…' : 'Godkänn'}
|
||||
</Button>
|
||||
{isMatch && onChangeMatch && (
|
||||
<Button size="sm" variant="outline" onClick={onChangeMatch} disabled={isBusy}>
|
||||
Byt transaktion
|
||||
</Button>
|
||||
)}
|
||||
{!isMatch && (
|
||||
<Button size="sm" variant="outline" onClick={onEdit} disabled={isBusy}>
|
||||
Redigera
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="ghost" onClick={onReject} disabled={isBusy}>
|
||||
Avvisa
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
<ReceiptBox inbox={inbox} />
|
||||
{proposedTx ? (
|
||||
<TransactionBox tx={proposedTx} />
|
||||
) : (
|
||||
<div className="rounded border bg-muted/40 p-3 text-sm text-muted-foreground">
|
||||
Föreslagen transaktion: {payload.matched_transaction_id}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ReasoningDisclosure reasoning={reasoning} alternatives={payload.alternatives} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="mt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground underline underline-offset-2"
|
||||
>
|
||||
{open ? 'Dölj AI:ns resonemang' : 'Visa AI:ns resonemang'}
|
||||
{hasAlternatives && ` (${alternatives!.length} alternativ)`}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{reasoning && (
|
||||
<p className="text-xs text-muted-foreground italic">“{reasoning}”</p>
|
||||
)}
|
||||
{hasAlternatives && (
|
||||
<ul className="space-y-1 text-xs">
|
||||
{alternatives!.map((alt) => (
|
||||
<li key={alt.transaction_id} className="text-muted-foreground">
|
||||
<span className="tabular-nums">{Math.round(alt.confidence * 100)}%</span> — {alt.reasoning}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className={cn(
|
||||
'w-full text-left rounded border bg-muted/40 p-3 text-sm transition-colors hover:bg-muted/60 hover:border-primary/40',
|
||||
needsAttention && 'border-warning/50 bg-warning/5 hover:bg-warning/10'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="truncate">{merchant}</span>
|
||||
{total != null && (
|
||||
<span className="tabular-nums font-medium">
|
||||
{formatCurrency(total, currency)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-1 text-xs text-muted-foreground">
|
||||
<ReceiptIcon className="h-3 w-3" />
|
||||
<span>Kvitto · {date ? formatDate(date) : 'Okänt datum'}</span>
|
||||
</div>
|
||||
{!hasFile && (
|
||||
<p className="mt-2 text-xs text-warning-foreground">
|
||||
Ingen kvittobild — klicka för att ladda upp
|
||||
</p>
|
||||
)}
|
||||
{hasFile && !quality.ok && (
|
||||
<p className="mt-2 text-xs text-warning-foreground">
|
||||
{quality.message}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
<ReceiptDetailDialog open={open} onOpenChange={setOpen} inbox={inbox} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function TransactionBox({
|
||||
tx,
|
||||
}: {
|
||||
tx: NonNullable<AgentInboxItemView['transaction']>
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="w-full text-left rounded border bg-muted/40 p-3 text-sm transition-colors hover:bg-muted/60 hover:border-primary/40"
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="truncate">{tx.description || 'Okänd'}</span>
|
||||
<span className="tabular-nums font-medium">
|
||||
{formatCurrency(tx.amount, tx.currency)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-1 text-xs text-muted-foreground">
|
||||
<Landmark className="h-3 w-3" />
|
||||
<span>Banktransaktion · {formatDate(tx.date)}</span>
|
||||
</div>
|
||||
</button>
|
||||
<TransactionDetailDialog open={open} onOpenChange={setOpen} tx={tx} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function BookingProposalBody({
|
||||
payload,
|
||||
reasoning,
|
||||
}: {
|
||||
payload: BookingProposalPayload
|
||||
reasoning: string | null
|
||||
}) {
|
||||
const totalDebit = payload.lines.reduce((s, l) => s + l.debit_amount, 0)
|
||||
return (
|
||||
<div>
|
||||
<div className="rounded border bg-muted/40 p-3 text-sm">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground">
|
||||
<th className="text-left font-normal pb-1">Konto</th>
|
||||
<th className="text-right font-normal pb-1">Debet</th>
|
||||
<th className="text-right font-normal pb-1">Kredit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{payload.lines.map((line, i) => (
|
||||
<tr key={i} className="border-t border-border/40">
|
||||
<td className="py-1">
|
||||
<span className="font-mono">{line.account_number}</span>{' '}
|
||||
<span className="text-muted-foreground">{line.description}</span>
|
||||
</td>
|
||||
<td className="py-1 text-right tabular-nums">
|
||||
{line.debit_amount > 0 ? line.debit_amount.toFixed(2) : ''}
|
||||
</td>
|
||||
<td className="py-1 text-right tabular-nums">
|
||||
{line.credit_amount > 0 ? line.credit_amount.toFixed(2) : ''}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot className="text-xs text-muted-foreground">
|
||||
<tr className="border-t">
|
||||
<td className="pt-1">
|
||||
{payload.vat_treatment && <span>Moms: {payload.vat_treatment}</span>}
|
||||
{payload.default_private && <span className="ml-2">Privat uttag</span>}
|
||||
</td>
|
||||
<td className="pt-1 text-right tabular-nums">{totalDebit.toFixed(2)}</td>
|
||||
<td className="pt-1 text-right tabular-nums">{totalDebit.toFixed(2)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
<ReasoningDisclosure reasoning={reasoning} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Loader2, ExternalLink, FileText, Upload, ImagePlus, MailPlus } from 'lucide-react'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import type { AgentInboxItemView } from '@/app/(dashboard)/agent-inbox/page'
|
||||
import { assessReceiptQuality } from './receipt-quality'
|
||||
|
||||
// Mirrors ReceiptExtractionResult, kept local + forgiving since legacy rows
|
||||
// may be sparse.
|
||||
interface ExtractedReceipt {
|
||||
merchant?: {
|
||||
name?: string | null
|
||||
orgNumber?: string | null
|
||||
vatNumber?: string | null
|
||||
isForeign?: boolean
|
||||
} | null
|
||||
receipt?: {
|
||||
date?: string | null
|
||||
time?: string | null
|
||||
currency?: string | null
|
||||
} | null
|
||||
totals?: {
|
||||
subtotal?: number | null
|
||||
vatAmount?: number | null
|
||||
total?: number | null
|
||||
} | null
|
||||
lineItems?: Array<{
|
||||
description?: string
|
||||
quantity?: number
|
||||
unitPrice?: number | null
|
||||
lineTotal?: number
|
||||
vatRate?: number | null
|
||||
}> | null
|
||||
flags?: {
|
||||
isRestaurant?: boolean
|
||||
isSystembolaget?: boolean
|
||||
isForeignMerchant?: boolean
|
||||
} | null
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
inbox: AgentInboxItemView['inbox_item']
|
||||
}
|
||||
|
||||
export default function ReceiptDetailDialog({ open, onOpenChange, inbox }: Props) {
|
||||
const data = (inbox.extracted_data as ExtractedReceipt | null) ?? {}
|
||||
const merchant = data.merchant?.name ?? inbox.document?.file_name ?? 'Okänt kvitto'
|
||||
const currency = data.receipt?.currency ?? 'SEK'
|
||||
const lineItems = data.lineItems ?? []
|
||||
const flags = data.flags ?? {}
|
||||
|
||||
// Lazily fetch a signed download URL so we can preview the file.
|
||||
const [downloadUrl, setDownloadUrl] = useState<string | null>(null)
|
||||
const [loadingUrl, setLoadingUrl] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadError, setUploadError] = useState<string | null>(null)
|
||||
const [requesting, setRequesting] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
const quality = assessReceiptQuality(inbox)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !inbox.document?.id || downloadUrl) return
|
||||
setLoadingUrl(true)
|
||||
fetch(`/api/documents/${inbox.document.id}`)
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
if (body?.data?.download_url) setDownloadUrl(body.data.download_url)
|
||||
})
|
||||
.catch(() => { /* fall back to no preview */ })
|
||||
.finally(() => setLoadingUrl(false))
|
||||
}, [open, inbox.document?.id, downloadUrl])
|
||||
|
||||
const mime = inbox.document?.mime_type ?? null
|
||||
const isImage = mime?.startsWith('image/') ?? false
|
||||
const isPdf = mime === 'application/pdf'
|
||||
|
||||
const handleFilePicked = async (file: File) => {
|
||||
setUploading(true)
|
||||
setUploadError(null)
|
||||
try {
|
||||
const body = new FormData()
|
||||
body.append('file', file)
|
||||
const res = await fetch(`/api/ai/inbox-items/${inbox.id}/attach-file`, {
|
||||
method: 'POST',
|
||||
body,
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) {
|
||||
setUploadError(json?.error ?? 'Kunde inte ladda upp filen.')
|
||||
setUploading(false)
|
||||
return
|
||||
}
|
||||
toast({ title: 'Kvittobild uppladdad' })
|
||||
// Force the server component to re-run so the new inbox.document
|
||||
// propagates into the card + modal.
|
||||
router.refresh()
|
||||
onOpenChange(false)
|
||||
} catch {
|
||||
setUploadError('Nätverksfel.')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRequestReceipt = async () => {
|
||||
setRequesting(true)
|
||||
try {
|
||||
const res = await fetch(`/api/ai/inbox-items/${inbox.id}/request-receipt`, {
|
||||
method: 'POST',
|
||||
})
|
||||
const body = await res.json()
|
||||
if (!res.ok) {
|
||||
toast({
|
||||
title: 'Kunde inte skicka begäran',
|
||||
description: body?.error ?? 'Försök igen.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
setRequesting(false)
|
||||
return
|
||||
}
|
||||
toast({
|
||||
title: 'Begäran skickad',
|
||||
description: `Mejl skickat till ${body.data.sent} av ${body.data.total} medlemmar.`,
|
||||
})
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Nätverksfel',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setRequesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{merchant}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{data.receipt?.date ? formatDate(data.receipt.date) : 'Okänt datum'}
|
||||
{data.receipt?.time && ` · ${data.receipt.time}`}
|
||||
{data.totals?.total != null && (
|
||||
<span> · {formatCurrency(data.totals.total, currency)}</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* File preview */}
|
||||
<section className="min-h-[280px] bg-muted/40 rounded border flex items-center justify-center overflow-hidden">
|
||||
{!inbox.document ? (
|
||||
<div className="flex flex-col items-center gap-3 p-6 text-center">
|
||||
<div className="p-3 rounded-full bg-muted">
|
||||
<ImagePlus className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">Ingen kvittobild</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1 max-w-[240px]">
|
||||
Utan bildbevis kan bokföringen inte verifieras. Ladda upp kvittot (PDF, JPG, PNG, WebP — max 15 MB).
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".pdf,.jpg,.jpeg,.png,.webp"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0]
|
||||
if (f) handleFilePicked(f)
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col gap-2 w-full max-w-[240px]">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading || requesting}
|
||||
>
|
||||
{uploading ? (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 mr-2 animate-spin" />
|
||||
Laddar upp…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="h-3.5 w-3.5 mr-2" />
|
||||
Ladda upp kvittobild
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleRequestReceipt}
|
||||
disabled={uploading || requesting}
|
||||
>
|
||||
{requesting ? (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 mr-2 animate-spin" />
|
||||
Skickar…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MailPlus className="h-3.5 w-3.5 mr-2" />
|
||||
Begär kvitto från teamet
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{uploadError && (
|
||||
<p className="text-xs text-destructive">{uploadError}</p>
|
||||
)}
|
||||
</div>
|
||||
) : loadingUrl ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
) : !downloadUrl ? (
|
||||
<div className="text-sm text-muted-foreground text-center p-6">
|
||||
Kunde inte ladda filen
|
||||
</div>
|
||||
) : isImage ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={downloadUrl}
|
||||
alt={inbox.document.file_name}
|
||||
className="max-w-full max-h-[60vh] object-contain"
|
||||
/>
|
||||
) : isPdf ? (
|
||||
<iframe
|
||||
src={downloadUrl}
|
||||
title={inbox.document.file_name}
|
||||
className="w-full h-[60vh]"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-2 p-6">
|
||||
<FileText className="h-10 w-10 text-muted-foreground" />
|
||||
<span className="text-sm">{inbox.document.file_name}</span>
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<a href={downloadUrl} target="_blank" rel="noreferrer">
|
||||
<ExternalLink className="h-3.5 w-3.5 mr-2" />
|
||||
Öppna fil
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Extracted data */}
|
||||
<section className="space-y-4 text-sm">
|
||||
{/* Quality warning — shown when a file exists but the data is weak */}
|
||||
{inbox.document && !quality.ok && (
|
||||
<div className="rounded border border-warning/50 bg-warning/5 p-3">
|
||||
<p className="text-sm font-medium mb-1">Kvittot verkar otydligt</p>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
{quality.message} Be teamet skicka en tydligare bild för att kunna bokföra säkert.
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleRequestReceipt}
|
||||
disabled={requesting}
|
||||
>
|
||||
{requesting ? (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 mr-2 animate-spin" />
|
||||
Skickar…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MailPlus className="h-3.5 w-3.5 mr-2" />
|
||||
Begär nytt kvitto
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Merchant */}
|
||||
<div>
|
||||
<h3 className="text-xs font-medium uppercase tracking-wider text-muted-foreground mb-1.5">
|
||||
Handlare
|
||||
</h3>
|
||||
<dl className="grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1 text-sm">
|
||||
<dt className="text-muted-foreground">Namn</dt>
|
||||
<dd>{data.merchant?.name ?? '—'}</dd>
|
||||
{data.merchant?.orgNumber && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">Org.nr</dt>
|
||||
<dd className="font-mono">{data.merchant.orgNumber}</dd>
|
||||
</>
|
||||
)}
|
||||
{data.merchant?.vatNumber && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">VAT-nr</dt>
|
||||
<dd className="font-mono">{data.merchant.vatNumber}</dd>
|
||||
</>
|
||||
)}
|
||||
{(flags.isRestaurant || flags.isSystembolaget || flags.isForeignMerchant) && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">Flagga</dt>
|
||||
<dd className="flex flex-wrap gap-1">
|
||||
{flags.isRestaurant && <Badge variant="outline" className="text-xs">Restaurang</Badge>}
|
||||
{flags.isSystembolaget && <Badge variant="outline" className="text-xs">Systembolaget</Badge>}
|
||||
{flags.isForeignMerchant && <Badge variant="outline" className="text-xs">Utländsk handlare</Badge>}
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Totals */}
|
||||
{(data.totals?.subtotal != null || data.totals?.total != null) && (
|
||||
<div>
|
||||
<h3 className="text-xs font-medium uppercase tracking-wider text-muted-foreground mb-1.5">
|
||||
Belopp
|
||||
</h3>
|
||||
<dl className="grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1 text-sm">
|
||||
{data.totals?.subtotal != null && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">Netto</dt>
|
||||
<dd className="tabular-nums">{formatCurrency(data.totals.subtotal, currency)}</dd>
|
||||
</>
|
||||
)}
|
||||
{data.totals?.vatAmount != null && data.totals.vatAmount > 0 && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">Moms</dt>
|
||||
<dd className="tabular-nums">{formatCurrency(data.totals.vatAmount, currency)}</dd>
|
||||
</>
|
||||
)}
|
||||
{data.totals?.total != null && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">Totalt</dt>
|
||||
<dd className="tabular-nums font-medium">{formatCurrency(data.totals.total, currency)}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Line items */}
|
||||
{lineItems.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-xs font-medium uppercase tracking-wider text-muted-foreground mb-1.5">
|
||||
Rader ({lineItems.length})
|
||||
</h3>
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground">
|
||||
<th className="text-left font-normal pb-1">Beskrivning</th>
|
||||
<th className="text-right font-normal pb-1">Antal</th>
|
||||
<th className="text-right font-normal pb-1">Moms</th>
|
||||
<th className="text-right font-normal pb-1">Summa</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lineItems.map((li, i) => (
|
||||
<tr key={i} className="border-t border-border/40">
|
||||
<td className="py-1">{li.description ?? 'Rad'}</td>
|
||||
<td className="py-1 text-right tabular-nums">
|
||||
{li.quantity ?? '—'}
|
||||
</td>
|
||||
<td className="py-1 text-right tabular-nums text-muted-foreground">
|
||||
{li.vatRate != null ? `${li.vatRate}%` : '—'}
|
||||
</td>
|
||||
<td className="py-1 text-right tabular-nums">
|
||||
{li.lineTotal != null ? formatCurrency(li.lineTotal, currency) : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Meta */}
|
||||
<div className="pt-2 border-t text-xs text-muted-foreground space-y-0.5">
|
||||
<div>Källa: {inbox.source}{inbox.email_from ? ` (${inbox.email_from})` : ''}</div>
|
||||
{inbox.confidence != null && (
|
||||
<div>Extraktionskonfidens: {Math.round(Number(inbox.confidence) * 100)}%</div>
|
||||
)}
|
||||
{inbox.document?.file_name && (
|
||||
<div>Fil: {inbox.document.file_name}</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
'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 { useToast } from '@/components/ui/use-toast'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import type { AgentInboxItemView } from '@/app/(dashboard)/agent-inbox/page'
|
||||
import type { AIRequestType } from '@/types'
|
||||
|
||||
interface RequestCardProps {
|
||||
item: AgentInboxItemView
|
||||
onDismiss: () => void
|
||||
}
|
||||
|
||||
const REQUEST_LABEL: Record<AIRequestType, string> = {
|
||||
reupload_document: 'Oläslig bild',
|
||||
pick_transaction: 'Saknar matchning',
|
||||
specify_vat: 'Momssats',
|
||||
clarify_business_private: 'Privat eller business?',
|
||||
needs_manual: 'Hantera manuellt',
|
||||
}
|
||||
|
||||
export default function RequestCard({ item, onDismiss }: RequestCardProps) {
|
||||
const req = item.request!
|
||||
const { toast } = useToast()
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const handleResolve = async () => {
|
||||
setBusy(true)
|
||||
try {
|
||||
const res = await fetch(`/api/ai/requests/${req.id}/resolve`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.json()
|
||||
toast({ title: 'Fel', description: body.error, variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
toast({ title: 'Markerad som hanterad' })
|
||||
onDismiss()
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Guidance varies by request type.
|
||||
let action: React.ReactNode = null
|
||||
if (req.request_type === 'reupload_document') {
|
||||
action = (
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<Link href="/e/general/invoice-inbox">Ladda upp ny bild</Link>
|
||||
</Button>
|
||||
)
|
||||
} else if (req.request_type === 'pick_transaction' || req.request_type === 'needs_manual') {
|
||||
action = (
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<Link href="/transactions">Gå till transaktioner</Link>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border-warning/50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-warning mt-0.5 shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Badge variant="outline">{REQUEST_LABEL[req.request_type]}</Badge>
|
||||
{item.inbox_item.document && (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{item.inbox_item.document.file_name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm">{req.message}</p>
|
||||
<div className="flex gap-2 mt-3 flex-wrap">
|
||||
{action}
|
||||
<Button size="sm" variant="ghost" onClick={handleResolve} disabled={busy}>
|
||||
Markera som hanterad
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import type { AgentInboxItemView } from '@/app/(dashboard)/agent-inbox/page'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
tx: NonNullable<AgentInboxItemView['transaction']>
|
||||
}
|
||||
|
||||
export default function TransactionDetailDialog({ open, onOpenChange, tx }: Props) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-baseline justify-between gap-3">
|
||||
<span className="truncate">{tx.description || 'Okänd transaktion'}</span>
|
||||
<span className="tabular-nums text-base font-medium flex-shrink-0">
|
||||
{formatCurrency(tx.amount, tx.currency)}
|
||||
</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{formatDate(tx.date)} · Banktransaktion
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 text-sm">
|
||||
<dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1.5">
|
||||
{tx.merchant_name && tx.merchant_name !== tx.description && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">Handlare</dt>
|
||||
<dd>{tx.merchant_name}</dd>
|
||||
</>
|
||||
)}
|
||||
<dt className="text-muted-foreground">Kategori</dt>
|
||||
<dd>{tx.category ?? '—'}</dd>
|
||||
<dt className="text-muted-foreground">Affärs/privat</dt>
|
||||
<dd>
|
||||
{tx.is_business === true
|
||||
? 'Affärs'
|
||||
: tx.is_business === false
|
||||
? 'Privat'
|
||||
: 'Okänt'}
|
||||
</dd>
|
||||
{tx.currency && tx.currency !== 'SEK' && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">Valuta</dt>
|
||||
<dd>
|
||||
{tx.currency}
|
||||
{tx.amount_sek != null && (
|
||||
<span className="text-muted-foreground">
|
||||
{' '}
|
||||
({formatCurrency(tx.amount_sek, 'SEK')})
|
||||
</span>
|
||||
)}
|
||||
</dd>
|
||||
{tx.exchange_rate != null && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">Växelkurs</dt>
|
||||
<dd className="tabular-nums">{tx.exchange_rate}</dd>
|
||||
</>
|
||||
)}
|
||||
{tx.exchange_rate_date && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">Kursdatum</dt>
|
||||
<dd>{formatDate(tx.exchange_rate_date)}</dd>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{tx.mcc_code != null && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">MCC-kod</dt>
|
||||
<dd className="font-mono">{tx.mcc_code}</dd>
|
||||
</>
|
||||
)}
|
||||
{tx.external_id && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">Externt ID</dt>
|
||||
<dd className="font-mono text-xs text-muted-foreground break-all">
|
||||
{tx.external_id}
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
{tx.bank_connection_id && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">Bankanslutning</dt>
|
||||
<dd className="font-mono text-xs text-muted-foreground">
|
||||
{tx.bank_connection_id.slice(0, 8)}…
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
<dt className="text-muted-foreground">Transaktions-ID</dt>
|
||||
<dd className="font-mono text-xs text-muted-foreground break-all">
|
||||
{tx.id}
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
<div className="pt-3 border-t">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/transactions?highlight=${tx.id}`}>
|
||||
<ExternalLink className="h-3.5 w-3.5 mr-2" />
|
||||
Öppna i transaktionslistan
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { AgentInboxItemView } from '@/app/(dashboard)/agent-inbox/page'
|
||||
|
||||
export type ReceiptQualityIssue =
|
||||
| 'missing_merchant'
|
||||
| 'missing_total'
|
||||
| 'missing_date'
|
||||
| 'low_confidence'
|
||||
|
||||
export interface ReceiptQualityAssessment {
|
||||
ok: boolean
|
||||
issues: ReceiptQualityIssue[]
|
||||
message: string | null
|
||||
}
|
||||
|
||||
const ISSUE_LABELS: Record<ReceiptQualityIssue, string> = {
|
||||
missing_merchant: 'handlare saknas',
|
||||
missing_total: 'belopp saknas',
|
||||
missing_date: 'datum saknas',
|
||||
low_confidence: 'låg extraktionssäkerhet',
|
||||
}
|
||||
|
||||
// Heuristic quality check on a classified receipt. Until the classification
|
||||
// prompt returns an explicit quality_score, we infer it from which critical
|
||||
// fields came back and the LLM's self-reported confidence (stored on
|
||||
// invoice_inbox_items.confidence after classification). 0.6 is the cutoff
|
||||
// where accepted vs. edited rates diverge noticeably in practice.
|
||||
export function assessReceiptQuality(
|
||||
inbox: AgentInboxItemView['inbox_item']
|
||||
): ReceiptQualityAssessment {
|
||||
const data = inbox.extracted_data as {
|
||||
merchant?: { name?: string | null } | null
|
||||
receipt?: { date?: string | null } | null
|
||||
totals?: { total?: number | null } | null
|
||||
} | null
|
||||
|
||||
const issues: ReceiptQualityIssue[] = []
|
||||
|
||||
if (!data?.merchant?.name) issues.push('missing_merchant')
|
||||
if (data?.totals?.total == null) issues.push('missing_total')
|
||||
if (!data?.receipt?.date) issues.push('missing_date')
|
||||
|
||||
const confidence = inbox.confidence == null ? null : Number(inbox.confidence)
|
||||
if (confidence != null && confidence < 0.6) issues.push('low_confidence')
|
||||
|
||||
if (issues.length === 0) {
|
||||
return { ok: true, issues, message: null }
|
||||
}
|
||||
|
||||
const labels = issues.map((i) => ISSUE_LABELS[i])
|
||||
return {
|
||||
ok: false,
|
||||
issues,
|
||||
message: `Kvittot verkar otydligt — ${labels.join(', ')}.`,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user