Supp/invoice bfl errors (#390)

* feat(accounting): update accounting method validation and messaging for aktiebolag and enskild firma

* Remove AI subsystem and related code

- Deleted AI proposals and requests persistence logic from `lib/ai/proposals/persist.ts`.
- Removed re-validation logic for proposals in `lib/ai/proposals/re-validate.ts`.
- Cleaned up schemas related to AI flows in `lib/api/schemas.ts`.
- Removed AI-related fields from bookkeeping engine in `lib/bookkeeping/engine.ts`.
- Eliminated AI event types from `lib/events/types.ts`.
- Updated tests to reflect the removal of AI-related functionality in `lib/extensions/__tests__/sectors.test.ts`.
- Adjusted initialization logic in `lib/init.ts` to exclude AI proposal handler registration.
- Cleaned up transaction ingestion logic in `lib/transactions/ingest.ts` to remove AI flow checks.
- Updated helper functions in `tests/helpers.ts` to remove AI-related settings.
- Removed AI-related types and interfaces from `types/index.ts`.
- Added migration script to drop AI-related tables and settings from the database.

* fix(migrations): ensure foreign key constraint is dropped before removing AI tables

* feat(invoice-inbox): implement deterministic invoice field extraction and inbox provisioning

- Added `extract-invoice-fields.ts` for extracting fields from PDF invoices using regex and pdfjs-dist, replacing the previous AI classifier.
- Introduced `inbox-provisioning.ts` to manage company inbox addresses and rotation of inboxes using Supabase RPCs.
- Created `resend-inbound.ts` for handling inbound email events and attachments via the Resend API.
- Defined the extension manifest for the invoice inbox, specifying required environment variables and descriptions.
- Migrated database schema to remove AI-related columns and tighten the status enum in `invoice_inbox_items`.

* feat(invoice-inbox): remove AI-specific columns and tighten status enum

* fix(skattekonto): remove manual entry creation reference from transaction input

* fix(schemas): remove accounting method validation for aktiebolag in UpdateSettingsSchema
This commit is contained in:
Mattsson
2026-05-05 09:53:37 +02:00
committed by GitHub
parent f3fd4c0822
commit fa7d4075cf
108 changed files with 1491 additions and 13234 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ export function RecaptIdentify({
}) {
useEffect(() => {
const interval = setInterval(() => {
if (window.Recapt) {
if (typeof window.Recapt?.session?.setIdentity === 'function') {
window.Recapt.session.setIdentity({
uid: userId,
email: email,
-562
View File
@@ -1,562 +0,0 @@
'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>
)
}
@@ -1,378 +0,0 @@
'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">&ldquo;{reasoning}&rdquo;</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>
)
}
@@ -1,155 +0,0 @@
'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>
)
}
@@ -1,43 +0,0 @@
'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>
)
}
-346
View File
@@ -1,346 +0,0 @@
'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">&ldquo;{reasoning}&rdquo;</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>
)
}
@@ -1,408 +0,0 @@
'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>
)
}
-93
View File
@@ -1,93 +0,0 @@
'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>
)
}
@@ -1,122 +0,0 @@
'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>
)
}
-55
View File
@@ -1,55 +0,0 @@
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(', ')}.`,
}
}
-23
View File
@@ -12,7 +12,6 @@ import {
ArrowLeftRight,
ChevronDown,
ChevronRight,
Camera,
Users,
Landmark,
CheckCircle2,
@@ -163,28 +162,6 @@ export default function DashboardContent({ firstName, companyId, settings, summa
)
}
if (process.env.NODE_ENV === 'development' && summary.receiptQueue && (summary.receiptQueue.pending_review_count > 0 || summary.receiptQueue.unmatched_receipts_count > 0)) {
alertItems.push(
<Link key="receipts" href="/receipts" className="group">
<Card className="h-full border-primary/30 hover:bg-primary/[0.03] transition-colors">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<Camera className="h-4 w-4 text-primary flex-shrink-0" />
<div>
<p className="font-medium text-sm">Kvitton</p>
<p className="text-xs text-muted-foreground mt-0.5">
{summary.receiptQueue.pending_review_count > 0
? `${summary.receiptQueue.pending_review_count} att granska`
: `${summary.receiptQueue.unmatched_receipts_count} omatchade`}
</p>
</div>
</div>
</CardContent>
</Card>
</Link>
)
}
if (summary.missingUnderlagCount > 0) {
alertItems.push(
<Link key="missing-underlag" href="/bookkeeping?missingUnderlag=true" className="group">
-4
View File
@@ -27,11 +27,9 @@ import {
TrendingUp,
ClipboardCheck,
HandCoins,
Sparkles,
} from 'lucide-react'
import { getBranding } from '@/lib/branding/service'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import { isAgentInboxEnabled } from '@/lib/ai/feature-flag'
import { resolveIcon } from '@/lib/extensions/icon-resolver'
import { SupportLink } from '@/components/ui/support-link'
import CompanySwitcher from '@/components/dashboard/CompanySwitcher'
@@ -79,8 +77,6 @@ const navItems: NavItem[] = [
{ href: '/supplier-invoices', label: 'Leverantörsfakturor', icon: FileInput, group: 'inköp', hidden: true },
// General accounting
{ href: '/pending', label: 'Granskning', icon: ClipboardCheck, group: 'redovisning' },
{ href: '/receipts', label: 'Kvitton', icon: Receipt, group: 'redovisning', hidden: !ENABLED_EXTENSION_IDS.has('invoice-inbox') || process.env.NODE_ENV !== 'development', devBadge: true },
{ href: '/agent-inbox', label: 'Agent-inkorg', icon: Sparkles, group: 'redovisning', hidden: !ENABLED_EXTENSION_IDS.has('ai-agent') || !isAgentInboxEnabled(), devBadge: true },
{ href: '/transactions', label: 'Transaktioner', icon: ArrowLeftRight, group: 'redovisning' },
{ href: '/bookkeeping', label: 'Bokföring', icon: BookOpen, group: 'redovisning' },
{ href: '/reports', label: 'Rapporter', icon: BarChart3, group: 'redovisning' },
@@ -5,6 +5,10 @@ import { getWorkspaceComponent } from '@/lib/extensions/workspace-registry'
import ExtensionWorkspaceShell from './ExtensionWorkspaceShell'
import EmptyExtensionState from './shared/EmptyExtensionState'
// Full-screen workspaces render their own chrome (top bar, title) and opt
// out of the shared ExtensionWorkspaceShell header.
const FULLSCREEN_WORKSPACES = new Set(['general/invoice-inbox'])
export default function ExtensionWorkspaceLoader({
sector,
slug,
@@ -16,8 +20,12 @@ export default function ExtensionWorkspaceLoader({
definition: ExtensionDefinition
userId: string
}) {
const WorkspaceComponent = getWorkspaceComponent(sector, slug)
const isFullScreen = FULLSCREEN_WORKSPACES.has(`${sector}/${slug}`)
if (isFullScreen && WorkspaceComponent) {
return <WorkspaceComponent userId={userId} />
}
return (
<ExtensionWorkspaceShell definition={definition}>
@@ -7,8 +7,8 @@ import { Wand } from 'lucide-react'
export default function AiCategorizationWorkspace({ userId }: WorkspaceComponentProps) {
return (
<EmptyExtensionState
title="AI-kategorisering"
description="AI-kategorisering körs automatiskt när nya transaktioner synkas. Gå till Transaktioner för att se förslag."
title="Transaktionskategorisering"
description="Transaktioner kategoriseras automatiskt baserat på dina regler när nya synkas. Gå till Transaktioner för att se matchningar."
icon={<Wand className="h-12 w-12 text-muted-foreground/40 mb-4" />}
/>
)
File diff suppressed because it is too large Load Diff
+4 -5
View File
@@ -263,11 +263,10 @@ export default function Step4VatAccounting({
? 'Intäkter och kostnader bokförs när fakturan skickas eller tas emot, oavsett när betalningen sker. Detta ger en mer rättvisande bild av verksamhetens ekonomi.'
: 'Intäkter och kostnader bokförs först när betalningen faktiskt sker. Enklare att hantera men ger en mindre exakt bild av verksamhetens ekonomi vid varje given tidpunkt.'}
</p>
{entityType === 'aktiebolag' && (
<p className="text-xs text-amber-800 dark:text-amber-200 bg-warning/10 rounded px-2 py-1">
Aktiebolag med omsättning över 3 MSEK per år måste använda faktureringsmetoden.
</p>
)}
<p className="text-xs text-amber-800 dark:text-amber-200 bg-warning/10 rounded px-2 py-1">
Kontantmetoden får användas om årlig nettoomsättning normalt är högst
3 MSEK (BFL 5 kap. 2 §).
</p>
</div>
</div>
</div>
-177
View File
@@ -1,177 +0,0 @@
'use client'
import { useState } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useToast } from '@/components/ui/use-toast'
import { Loader2 } from 'lucide-react'
import type { ReceiptRowWithPreview } from '@/app/(dashboard)/receipts/page'
// Fallback when AI can't read the image. The source document stays attached;
// we just let the user type the fields AI would have extracted so the row
// can move to 'ready' and be matched to a bank transaction.
export default function ManualExtractDialog({
row,
onClose,
onSaved,
}: {
row: ReceiptRowWithPreview
onClose: () => void
onSaved: () => void
}) {
const data = row.extracted_data as {
merchant?: { name?: string | null }
receipt?: { date?: string | null; currency?: string | null }
totals?: { total?: number | null; vatAmount?: number | null }
} | null
const [merchant, setMerchant] = useState(data?.merchant?.name ?? '')
const [date, setDate] = useState(data?.receipt?.date ?? new Date().toISOString().slice(0, 10))
const [total, setTotal] = useState<string>(data?.totals?.total != null ? String(data.totals.total) : '')
const [vatAmount, setVatAmount] = useState<string>(data?.totals?.vatAmount != null ? String(data.totals.vatAmount) : '')
const [currency, setCurrency] = useState(data?.receipt?.currency ?? 'SEK')
const [saving, setSaving] = useState(false)
const { toast } = useToast()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const totalNum = Number(total)
if (!merchant.trim() || !date || !Number.isFinite(totalNum) || totalNum <= 0) {
toast({
title: 'Kontrollera fälten',
description: 'Butiksnamn, datum och giltigt totalbelopp krävs.',
variant: 'destructive',
})
return
}
const vatNum = vatAmount.trim() === '' ? null : Number(vatAmount)
if (vatNum !== null && !Number.isFinite(vatNum)) {
toast({ title: 'Ogiltigt momsbelopp', variant: 'destructive' })
return
}
setSaving(true)
try {
const res = await fetch('/api/extensions/ext/invoice-inbox/manual-extract', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
inbox_item_id: row.id,
merchant: merchant.trim(),
date,
total: totalNum,
currency,
vat_amount: vatNum,
}),
})
const body = await res.json()
if (!res.ok) {
toast({ title: 'Kunde inte spara', description: body.error, variant: 'destructive' })
return
}
toast({ title: 'Kvitto sparat' })
onSaved()
} finally {
setSaving(false)
}
}
return (
<Dialog open onOpenChange={(open) => { if (!open) onClose() }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Skriv in kvittouppgifter</DialogTitle>
<DialogDescription>
Använd det här när AI inte kan läsa bilden. Bilden behålls som underlag —
du anger bara siffrorna så kan kvittot matchas mot en banktransaktion.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="merchant">Butik / leverantör</Label>
<Input
id="merchant"
value={merchant}
onChange={(e) => setMerchant(e.target.value)}
placeholder="t.ex. ICA Maxi"
autoFocus
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="date">Datum</Label>
<Input
id="date"
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="currency">Valuta</Label>
<Input
id="currency"
value={currency}
onChange={(e) => setCurrency(e.target.value.toUpperCase().slice(0, 3))}
maxLength={3}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="total">Totalbelopp</Label>
<Input
id="total"
type="number"
step="0.01"
inputMode="decimal"
value={total}
onChange={(e) => setTotal(e.target.value)}
placeholder="0.00"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="vat">Varav moms (valfritt)</Label>
<Input
id="vat"
type="number"
step="0.01"
inputMode="decimal"
value={vatAmount}
onChange={(e) => setVatAmount(e.target.value)}
placeholder="0.00"
/>
</div>
</div>
<DialogFooter className="gap-2">
<Button type="button" variant="ghost" onClick={onClose} disabled={saving}>
Avbryt
</Button>
<Button type="submit" disabled={saving}>
{saving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar…
</>
) : (
'Spara'
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
-540
View File
@@ -1,540 +0,0 @@
'use client'
import { useRef, useState, useEffect, useMemo } from 'react'
import { useRouter } from 'next/navigation'
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 { PageHeader } from '@/components/ui/page-header'
import { Upload, Receipt as ReceiptIcon, Loader2, FileText, AlertTriangle, RefreshCw, Pencil, ShieldCheck, ShieldAlert } from 'lucide-react'
import { formatCurrency, formatDate } from '@/lib/utils'
import ManualExtractDialog from './ManualExtractDialog'
import type { ReceiptRowWithPreview } from '@/app/(dashboard)/receipts/page'
const STATUS_LABELS: Record<string, string> = {
pending: 'Väntar',
processing: 'Bearbetar',
ready: 'Klar',
confirmed: 'Bokförd',
rejected: 'Avvisad',
error: 'Fel',
}
const STATUS_VARIANTS: Record<string, 'default' | 'secondary' | 'success' | 'warning' | 'destructive' | 'outline'> = {
pending: 'outline',
processing: 'warning',
ready: 'secondary',
confirmed: 'success',
rejected: 'outline',
error: 'destructive',
}
const ALLOWED_MIME = 'application/pdf,image/jpeg,image/png,image/heic,image/heif,image/webp'
interface ExtractedReceiptShape {
merchant?: { name?: string | null } | null
receipt?: { date?: string | null; currency?: string | null } | null
totals?: { total?: number | null } | null
_verification?: {
agrees?: boolean
claude_total?: number | null
ocr_total?: number | null
delta?: number | null
ocr_confidence?: number | null
} | null
_source?: 'ocr_only' | null
}
// Derive the verification state the UI should render.
// - 'agreed' Claude and Textract read the same total → green badge
// - 'disagreed' they disagree > 1 öre → yellow warning + numbers
// - 'ocr-only' Claude failed but Textract succeeded → neutral
// - 'unverified' Textract didn't run (HEIC, large file, no AWS perms)
// or agreement data is absent → show nothing
type VerificationState = 'agreed' | 'disagreed' | 'ocr-only' | 'unverified'
function getVerificationState(row: ReceiptRowWithPreview): VerificationState {
const data = row.extracted_data as ExtractedReceiptShape | null
if (!data) return 'unverified'
if (data._source === 'ocr_only') return 'ocr-only'
const v = data._verification
if (!v || v.agrees == null) return 'unverified'
return v.agrees ? 'agreed' : 'disagreed'
}
function summarize(row: ReceiptRowWithPreview): { merchant: string; total: number | null; currency: string; date: string | null } {
const data = (row.extracted_data as ExtractedReceiptShape | null) ?? {}
return {
merchant: data.merchant?.name ?? row.document?.file_name ?? 'Okänt kvitto',
total: data.totals?.total ?? null,
currency: data.receipt?.currency ?? 'SEK',
date: data.receipt?.date ?? null,
}
}
// Mirror of the server-side needsRescan heuristic — a row looks stuck when
// extraction failed or the total we need to propose a match is missing.
// Server is still authoritative; this just gates UI affordances.
function rowNeedsRescan(row: ReceiptRowWithPreview): boolean {
if (!row.document_id) return false // nothing to rescan without a file
if (row.status === 'confirmed') return false
if (row.status === 'error') return true
const data = row.extracted_data as ExtractedReceiptShape | null
if (!data) return true
if (data.totals?.total == null) return true
return false
}
// Thumbnail resolves to: image preview | PDF placeholder | missing-source warning.
// The last case is legally important (BFL 5 kap 7§) — a receipt without a
// source document cannot be booked, so we surface it visibly.
function Thumbnail({ row }: { row: ReceiptRowWithPreview }) {
const mime = row.document?.mime_type ?? ''
const isImage = mime.startsWith('image/') && !mime.includes('heic') && !mime.includes('heif')
const isPdf = mime === 'application/pdf'
if (!row.document) {
return (
<div className="w-20 h-20 rounded border border-warning/40 bg-warning/5 flex flex-col items-center justify-center shrink-0 text-warning-foreground">
<AlertTriangle className="h-5 w-5" />
<span className="text-[10px] mt-1 text-center leading-tight">Saknar bild</span>
</div>
)
}
if (isImage && row.preview_url) {
// eslint-disable-next-line @next/next/no-img-element
return (
<img
src={row.preview_url}
alt={row.document.file_name ?? 'Kvitto'}
className="w-20 h-20 rounded object-cover border bg-muted shrink-0"
loading="lazy"
/>
)
}
return (
<div className="w-20 h-20 rounded border bg-muted flex items-center justify-center shrink-0">
<FileText className="h-6 w-6 text-muted-foreground" />
<span className="sr-only">{isPdf ? 'PDF' : 'Fil'}</span>
</div>
)
}
// Optimistic card shown while the upload request is in flight. Replaced by
// the persisted row on router.refresh(). Keeps the page from looking empty
// during the 5-10 s classify call.
function PendingUploadCard({ upload }: { upload: PendingUpload }) {
return (
<Card className="border-primary/20 bg-primary/[0.02]">
<CardContent className="p-4">
<div className="flex items-start gap-4">
<div className="w-20 h-20 rounded border bg-muted flex items-center justify-center shrink-0">
<Loader2 className="h-6 w-6 text-muted-foreground animate-spin" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-baseline justify-between gap-3 flex-wrap">
<span className="font-medium truncate">{upload.file_name}</span>
</div>
<div className="flex items-center gap-2 mt-1">
<Badge variant="warning" className="gap-1.5">
<Loader2 className="h-3 w-3 animate-spin" />
AI läser kvittot…
</Badge>
<span className="text-xs text-muted-foreground">
Det här brukar ta 5–10 sekunder.
</span>
</div>
</div>
</div>
</CardContent>
</Card>
)
}
function VerificationBadge({ row }: { row: ReceiptRowWithPreview }) {
const state = getVerificationState(row)
if (state === 'agreed') {
return (
<Badge variant="outline" className="border-success/40 text-success-foreground gap-1">
<ShieldCheck className="h-3 w-3" />
OCR verifierad
</Badge>
)
}
if (state === 'disagreed') {
return (
<Badge variant="outline" className="border-warning/40 text-warning-foreground gap-1">
<ShieldAlert className="h-3 w-3" />
Behöver granskning
</Badge>
)
}
if (state === 'ocr-only') {
return (
<Badge variant="outline" className="text-muted-foreground">
Endast OCR
</Badge>
)
}
return null
}
// When Claude and Textract disagree on the total, show the raw numbers so
// the user can see which read to trust before accepting downstream.
function DisagreementDetail({ row }: { row: ReceiptRowWithPreview }) {
const data = row.extracted_data as ExtractedReceiptShape | null
const v = data?._verification
if (!v || v.agrees !== false) return null
const currency = data?.receipt?.currency ?? 'SEK'
return (
<p className="text-xs text-warning-foreground mt-2">
AI läste {v.claude_total != null ? formatCurrency(v.claude_total, currency) : '—'}, OCR läste{' '}
{v.ocr_total != null ? formatCurrency(v.ocr_total, currency) : '—'}. Granska bilden innan du godkänner.
</p>
)
}
// Optimistic placeholder shown in the list while a manual upload is in
// flight. The upload handler is synchronous (classify + store + insert
// happen before the response returns), so a 5-10 s gap otherwise leaves the
// user staring at nothing. We insert a fake row here so the UI shows a real
// card immediately and router.refresh() replaces it with the persisted row.
interface PendingUpload {
key: string
file_name: string
size_bytes: number
mime_type: string
}
export default function ReceiptsList({ initialItems }: { initialItems: ReceiptRowWithPreview[] }) {
const [items, setItems] = useState(initialItems)
const [uploading, setUploading] = useState(false)
const [pendingUploads, setPendingUploads] = useState<PendingUpload[]>([])
const [batchScanning, setBatchScanning] = useState(false)
const [rescanId, setRescanId] = useState<string | null>(null)
const [attachingId, setAttachingId] = useState<string | null>(null)
const [manualRow, setManualRow] = useState<ReceiptRowWithPreview | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const attachInputRef = useRef<HTMLInputElement>(null)
const attachTargetRef = useRef<string | null>(null)
const { toast } = useToast()
const router = useRouter()
useEffect(() => { setItems(initialItems) }, [initialItems])
// Count of rows eligible for rescan — drives the "Skanna oskannade (N)" CTA.
const rescanCount = useMemo(() => items.filter(rowNeedsRescan).length, [items])
const handlePickFile = () => fileInputRef.current?.click()
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
e.target.value = ''
const pending: PendingUpload = {
key: `pending-${Date.now()}-${file.name}`,
file_name: file.name,
size_bytes: file.size,
mime_type: file.type,
}
setPendingUploads((p) => [pending, ...p])
setUploading(true)
try {
const form = new FormData()
form.append('file', file)
const res = await fetch('/api/extensions/ext/invoice-inbox/upload', {
method: 'POST',
body: form,
})
const body = await res.json()
if (!res.ok) {
toast({ title: 'Uppladdning misslyckades', description: body.error, variant: 'destructive' })
return
}
toast({ title: 'Kvitto sparat' })
router.refresh()
} catch (err) {
toast({
title: 'Fel',
description: err instanceof Error ? err.message : String(err),
variant: 'destructive',
})
} finally {
// Drop the placeholder on both success and failure. On success the
// real row arrives via router.refresh(); on failure the user gets a
// toast and an empty list state instead of a stuck "AI läser..." card.
setPendingUploads((p) => p.filter((x) => x.key !== pending.key))
setUploading(false)
}
}
const handleRescanOne = async (row: ReceiptRowWithPreview) => {
setRescanId(row.id)
try {
const res = await fetch('/api/extensions/ext/invoice-inbox/rescan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ inbox_item_ids: [row.id] }),
})
const body = await res.json()
if (!res.ok) {
toast({ title: 'Skanning misslyckades', description: body.error, variant: 'destructive' })
return
}
const outcome = body.data.outcomes?.[0]
if (outcome?.ok) {
toast({ title: 'Skanning klar' })
} else {
toast({ title: 'Skanning misslyckades', description: outcome?.error, variant: 'destructive' })
}
router.refresh()
} finally {
setRescanId(null)
}
}
const handlePickAttachFile = (rowId: string) => {
attachTargetRef.current = rowId
attachInputRef.current?.click()
}
const handleAttachFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
const rowId = attachTargetRef.current
e.target.value = ''
attachTargetRef.current = null
if (!file || !rowId) return
setAttachingId(rowId)
try {
const form = new FormData()
form.append('file', file)
const res = await fetch(`/api/extensions/ext/invoice-inbox/items/${rowId}/attach-document`, {
method: 'POST',
body: form,
})
const body = await res.json()
if (!res.ok) {
toast({ title: 'Kunde inte koppla bild', description: body.error, variant: 'destructive' })
return
}
toast({
title: 'Bild kopplad',
description: body.data.classified ? 'Bearbetar siffrorna…' : 'Kunde inte läsa siffror — skanna igen eller skriv in själv.',
})
router.refresh()
} finally {
setAttachingId(null)
}
}
const handleBatchRescan = async () => {
if (rescanCount === 0) return
setBatchScanning(true)
try {
const res = await fetch('/api/extensions/ext/invoice-inbox/rescan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const body = await res.json()
if (!res.ok) {
toast({ title: 'Batch-skanning misslyckades', description: body.error, variant: 'destructive' })
return
}
const { rescanned, failed } = body.data
toast({
title: `${rescanned} skannade${failed > 0 ? `, ${failed} misslyckades` : ''}`,
})
router.refresh()
} finally {
setBatchScanning(false)
}
}
return (
<div className="container mx-auto p-4 sm:p-8 max-w-5xl">
<PageHeader
title="Kvitton"
description="Ladda upp kvitton. AI klassificerar och matchar mot banktransaktioner."
action={
<div className="flex gap-2 flex-wrap">
{rescanCount > 0 && (
<Button variant="outline" onClick={handleBatchRescan} disabled={batchScanning}>
{batchScanning ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Skannar…
</>
) : (
<>
<RefreshCw className="mr-2 h-4 w-4" />
Skanna oskannade ({rescanCount})
</>
)}
</Button>
)}
<input
ref={fileInputRef}
type="file"
className="hidden"
accept={ALLOWED_MIME}
onChange={handleUpload}
/>
<Button onClick={handlePickFile} disabled={uploading}>
{uploading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Laddar upp…
</>
) : (
<>
<Upload className="mr-2 h-4 w-4" />
Ladda upp kvitto
</>
)}
</Button>
</div>
}
/>
{items.length === 0 && pendingUploads.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<div className="p-5 rounded-full bg-muted mb-6">
<ReceiptIcon className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-lg font-medium mb-2">Inga kvitton än</h3>
<p className="text-sm text-muted-foreground text-center max-w-sm">
Ladda upp ett kvitto (PDF, JPG, PNG, HEIC eller WebP) så tar AI hand om resten.
</p>
</CardContent>
</Card>
) : (
<div className="flex flex-col gap-3">
{pendingUploads.map((upload) => (
<PendingUploadCard key={upload.key} upload={upload} />
))}
{items.map((row) => {
const s = summarize(row)
const statusKey = row.status ?? 'pending'
const canRescan = rowNeedsRescan(row)
const isRescanning = rescanId === row.id
const needsImage = !row.document_id && row.status !== 'confirmed'
const isAttaching = attachingId === row.id
return (
<Card key={row.id}>
<CardContent className="p-4">
<div className="flex items-start gap-4">
<Thumbnail row={row} />
<div className="flex-1 min-w-0">
<div className="flex items-baseline justify-between gap-3 flex-wrap">
<span className="font-medium truncate">{s.merchant}</span>
{s.total != null && (
<span className="tabular-nums font-medium">
{formatCurrency(s.total, s.currency)}
</span>
)}
</div>
<div className="flex items-center gap-2 mt-1 flex-wrap">
<Badge variant={STATUS_VARIANTS[statusKey] ?? 'outline'} className="gap-1.5">
{(statusKey === 'processing' || statusKey === 'pending') && (
<Loader2 className="h-3 w-3 animate-spin" />
)}
{STATUS_LABELS[statusKey] ?? statusKey}
</Badge>
<VerificationBadge row={row} />
{s.date && (
<span className="text-xs text-muted-foreground">
{formatDate(s.date)}
</span>
)}
{row.document?.file_name && (
<span className="text-xs text-muted-foreground truncate">
· {row.document.file_name}
</span>
)}
{row.source === 'email' && (
<span className="text-xs text-muted-foreground">· via e-post</span>
)}
</div>
{row.error_message && (
<p className="text-xs text-destructive mt-1">{row.error_message}</p>
)}
<DisagreementDetail row={row} />
{needsImage && (
<div className="flex gap-2 mt-3">
<Button
size="sm"
variant="outline"
onClick={() => handlePickAttachFile(row.id)}
disabled={isAttaching}
>
{isAttaching ? (
<>
<Loader2 className="mr-2 h-3 w-3 animate-spin" />
Laddar upp…
</>
) : (
<>
<Upload className="mr-2 h-3 w-3" />
Ladda upp bild
</>
)}
</Button>
</div>
)}
{!needsImage && canRescan && (
<div className="flex gap-2 mt-3">
<Button size="sm" variant="outline" onClick={() => handleRescanOne(row)} disabled={isRescanning}>
{isRescanning ? (
<>
<Loader2 className="mr-2 h-3 w-3 animate-spin" />
Skannar…
</>
) : (
<>
<RefreshCw className="mr-2 h-3 w-3" />
Skanna igen
</>
)}
</Button>
<Button size="sm" variant="ghost" onClick={() => setManualRow(row)}>
<Pencil className="mr-2 h-3 w-3" />
Skriv in själv
</Button>
</div>
)}
</div>
</div>
</CardContent>
</Card>
)
})}
</div>
)}
<input
ref={attachInputRef}
type="file"
className="hidden"
accept={ALLOWED_MIME}
onChange={handleAttachFile}
/>
{manualRow && (
<ManualExtractDialog
row={manualRow}
onClose={() => setManualRow(null)}
onSaved={() => {
setManualRow(null)
router.refresh()
}}
/>
)}
</div>
)
}
+1 -1
View File
@@ -28,7 +28,7 @@ const SCOPE_GROUPS = [
domain: 'transactions',
label: 'Transaktioner',
read: 'transactions:read' as const,
readLabel: 'Läs — lista transaktioner, mallförslag, kategoriförslag',
readLabel: 'Läs — lista transaktioner, mallar, kategorier',
readTools: 3,
write: 'transactions:write' as const,
writeLabel: 'Skriv — kategorisera, kvittomatchning, koppling mot faktura',
@@ -519,19 +519,8 @@ export default function SwipeCategorizationView({
</div>
</div>
{/* Document upload / pre-attached document */}
{pendingInboxItemId && currentTransaction.matched_inbox_item?.document_id ? (
<div className="rounded-lg border bg-success/5 border-success/30 px-3 py-2.5">
<div className="flex items-center gap-2">
<Paperclip className="h-4 w-4 text-success" />
<span className="text-sm font-medium">Underlag bifogat</span>
<Check className="h-4 w-4 text-success" />
</div>
<p className="text-xs text-muted-foreground mt-1">
Dokumentet från inkorgen länkas automatiskt till verifikationen.
</p>
</div>
) : (
{/* Document upload */}
{(
<div className="rounded-lg border">
<button
type="button"
@@ -711,55 +700,6 @@ export default function SwipeCategorizationView({
</div>
)}
{/* Document Match from Inbox */}
{currentTransaction.matched_inbox_item && (
<div className="p-4 rounded-lg border-2 border-primary/40 bg-primary/5 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-primary">
<Paperclip className="h-5 w-5" />
<span className="font-semibold text-sm">
{currentTransaction.matched_inbox_item.document_type === 'receipt'
? 'Matchat kvitto'
: currentTransaction.matched_inbox_item.document_type === 'supplier_invoice'
? 'Matchad leverantörsfaktura'
: 'Matchat dokument'}
</span>
</div>
{currentTransaction.matched_inbox_item.match_confidence != null && (
<Badge variant="outline" className="text-primary border-primary">
{Math.round(currentTransaction.matched_inbox_item.match_confidence * 100)}%
</Badge>
)}
</div>
<div className="text-sm">
{(() => {
const ext = currentTransaction.matched_inbox_item.extracted_data as Record<string, unknown> | null
if (!ext) return null
const supplierName = (ext as { supplier?: { name?: string } })?.supplier?.name
const merchantName = (ext as { merchant?: { name?: string } })?.merchant?.name
const totals = ext as { totals?: { total?: number } }
return (
<>
{(supplierName || merchantName) && (
<p className="font-medium">{supplierName || merchantName}</p>
)}
{totals?.totals?.total != null && (
<p className="text-muted-foreground">
{formatCurrency(totals.totals.total)}
</p>
)}
</>
)
})()}
{currentTransaction.matched_inbox_item.suggested_template_id && (
<p className="text-xs text-primary mt-1">
Mall: {currentTransaction.matched_inbox_item.suggested_template_id}
</p>
)}
</div>
</div>
)}
{/* Warnings */}
{warnings.length > 0 && (
<div className="space-y-2 pt-4 border-t">
@@ -799,25 +739,6 @@ export default function SwipeCategorizationView({
</div>
)}
{/* Document template match — primary action when inbox item has a suggested template */}
{currentTransaction.matched_inbox_item?.suggested_template_id && (() => {
const inboxItem = currentTransaction.matched_inbox_item
if (!inboxItem?.suggested_template_id) return null
const tmplId = inboxItem.suggested_template_id
const template = getTemplateById(tmplId)
if (!template) return null
return (
<Button
className="w-full"
onClick={() => handleTemplateSelect(tmplId, inboxItem.id)}
disabled={isProcessing}
>
<Paperclip className="mr-2 h-4 w-4" />
Bokför som {template.name_sv}
</Button>
)
})()}
{/* Invoice match button - primary action when there's a match */}
{currentTransaction.potential_invoice && onMatchInvoice && (
<Button
@@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { cn, formatCurrency, formatDate } from '@/lib/utils'
import { ArrowUpRight, ArrowDownRight, FileText, Loader2, Paperclip, Trash2 } from 'lucide-react'
import { ArrowUpRight, ArrowDownRight, FileText, Loader2, Trash2 } from 'lucide-react'
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/info-tooltip'
import { getAccountName, formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
import { getTemplateById } from '@/lib/bookkeeping/booking-templates'
@@ -59,7 +59,6 @@ export default function TransactionInboxCard({
const topSuggestion = suggestions?.[0]
const isUncategorized = transaction.is_business === null && !transaction.journal_entry_id
const showCheckbox = isBatchMode && isUncategorized
const hasDocumentMatch = !!transaction.matched_inbox_item
const isDeletable = !transaction.journal_entry_id
function handleSuggestionClick(suggestion: SuggestedCategory) {
@@ -116,16 +115,7 @@ export default function TransactionInboxCard({
</div>
<div className="min-w-0">
<p className="font-medium truncate">{transaction.description}</p>
<div className="flex items-center gap-2">
<p className="text-sm text-muted-foreground">{formatDate(transaction.date)}</p>
{hasDocumentMatch && (
<Badge variant="secondary" className="text-xs gap-1">
<Paperclip className="h-3 w-3" />
{transaction.matched_inbox_item!.document_type === 'receipt' ? 'Kvitto' :
transaction.matched_inbox_item!.document_type === 'supplier_invoice' ? 'Faktura' : 'Dokument'}
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground">{formatDate(transaction.date)}</p>
</div>
</div>
+1 -2
View File
@@ -1,10 +1,9 @@
import type { Transaction, TransactionCategory, Invoice, Customer, SupplierInvoice, VatTreatment, InvoiceInboxItem } from '@/types'
import type { Transaction, TransactionCategory, Invoice, Customer, SupplierInvoice, VatTreatment } from '@/types'
// Shared transaction type with potential invoice data
export interface TransactionWithInvoice extends Transaction {
potential_invoice?: Invoice & { customer?: Customer }
potential_supplier_invoice?: SupplierInvoice
matched_inbox_item?: InvoiceInboxItem
}
// Page view modes