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:
@@ -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,
|
||||
|
||||
@@ -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">“{reasoning}”</p>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function PickerRow({
|
||||
tx,
|
||||
isCurrent,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: {
|
||||
tx: PickerTx
|
||||
isCurrent: boolean
|
||||
isSelected: boolean
|
||||
onSelect: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
disabled={isCurrent}
|
||||
className={cn(
|
||||
'w-full text-left rounded border p-2.5 transition-colors',
|
||||
isSelected && 'border-primary bg-primary/5',
|
||||
!isSelected && 'hover:bg-muted/40',
|
||||
isCurrent && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-sm truncate">{tx.description ?? 'Okänd'}</span>
|
||||
<span className="text-sm tabular-nums font-medium">
|
||||
{formatCurrency(tx.amount, tx.currency ?? 'SEK')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground mt-0.5">
|
||||
<span>{formatDate(tx.date)}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{isCurrent && <Badge variant="outline" className="text-xs">Nuvarande</Badge>}
|
||||
{isSelected && <Check className="h-4 w-4 text-primary" />}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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">“{reasoning}”</p>
|
||||
)}
|
||||
{hasAlternatives && (
|
||||
<ul className="space-y-1 text-xs">
|
||||
{alternatives!.map((alt) => (
|
||||
<li key={alt.transaction_id} className="text-muted-foreground">
|
||||
<span className="tabular-nums">{Math.round(alt.confidence * 100)}%</span> — {alt.reasoning}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ReceiptBox({
|
||||
inbox,
|
||||
}: {
|
||||
inbox: AgentInboxItemView['inbox_item']
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const data = (inbox.extracted_data as {
|
||||
merchant?: { name?: string | null }
|
||||
receipt?: { date?: string | null; currency?: string | null }
|
||||
totals?: { total?: number | null }
|
||||
} | null) ?? {}
|
||||
const merchant = data.merchant?.name ?? inbox.document?.file_name ?? 'Okänt kvitto'
|
||||
const total = data.totals?.total ?? null
|
||||
const currency = data.receipt?.currency ?? 'SEK'
|
||||
const date = data.receipt?.date ?? null
|
||||
const quality = assessReceiptQuality(inbox)
|
||||
const hasFile = Boolean(inbox.document_id)
|
||||
const needsAttention = !hasFile || !quality.ok
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className={cn(
|
||||
'w-full text-left rounded border bg-muted/40 p-3 text-sm transition-colors hover:bg-muted/60 hover:border-primary/40',
|
||||
needsAttention && 'border-warning/50 bg-warning/5 hover:bg-warning/10'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="truncate">{merchant}</span>
|
||||
{total != null && (
|
||||
<span className="tabular-nums font-medium">
|
||||
{formatCurrency(total, currency)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-1 text-xs text-muted-foreground">
|
||||
<ReceiptIcon className="h-3 w-3" />
|
||||
<span>Kvitto · {date ? formatDate(date) : 'Okänt datum'}</span>
|
||||
</div>
|
||||
{!hasFile && (
|
||||
<p className="mt-2 text-xs text-warning-foreground">
|
||||
Ingen kvittobild — klicka för att ladda upp
|
||||
</p>
|
||||
)}
|
||||
{hasFile && !quality.ok && (
|
||||
<p className="mt-2 text-xs text-warning-foreground">
|
||||
{quality.message}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
<ReceiptDetailDialog open={open} onOpenChange={setOpen} inbox={inbox} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function TransactionBox({
|
||||
tx,
|
||||
}: {
|
||||
tx: NonNullable<AgentInboxItemView['transaction']>
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="w-full text-left rounded border bg-muted/40 p-3 text-sm transition-colors hover:bg-muted/60 hover:border-primary/40"
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="truncate">{tx.description || 'Okänd'}</span>
|
||||
<span className="tabular-nums font-medium">
|
||||
{formatCurrency(tx.amount, tx.currency)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-1 text-xs text-muted-foreground">
|
||||
<Landmark className="h-3 w-3" />
|
||||
<span>Banktransaktion · {formatDate(tx.date)}</span>
|
||||
</div>
|
||||
</button>
|
||||
<TransactionDetailDialog open={open} onOpenChange={setOpen} tx={tx} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function BookingProposalBody({
|
||||
payload,
|
||||
reasoning,
|
||||
}: {
|
||||
payload: BookingProposalPayload
|
||||
reasoning: string | null
|
||||
}) {
|
||||
const totalDebit = payload.lines.reduce((s, l) => s + l.debit_amount, 0)
|
||||
return (
|
||||
<div>
|
||||
<div className="rounded border bg-muted/40 p-3 text-sm">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground">
|
||||
<th className="text-left font-normal pb-1">Konto</th>
|
||||
<th className="text-right font-normal pb-1">Debet</th>
|
||||
<th className="text-right font-normal pb-1">Kredit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{payload.lines.map((line, i) => (
|
||||
<tr key={i} className="border-t border-border/40">
|
||||
<td className="py-1">
|
||||
<span className="font-mono">{line.account_number}</span>{' '}
|
||||
<span className="text-muted-foreground">{line.description}</span>
|
||||
</td>
|
||||
<td className="py-1 text-right tabular-nums">
|
||||
{line.debit_amount > 0 ? line.debit_amount.toFixed(2) : ''}
|
||||
</td>
|
||||
<td className="py-1 text-right tabular-nums">
|
||||
{line.credit_amount > 0 ? line.credit_amount.toFixed(2) : ''}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot className="text-xs text-muted-foreground">
|
||||
<tr className="border-t">
|
||||
<td className="pt-1">
|
||||
{payload.vat_treatment && <span>Moms: {payload.vat_treatment}</span>}
|
||||
{payload.default_private && <span className="ml-2">Privat uttag</span>}
|
||||
</td>
|
||||
<td className="pt-1 text-right tabular-nums">{totalDebit.toFixed(2)}</td>
|
||||
<td className="pt-1 text-right tabular-nums">{totalDebit.toFixed(2)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
<ReasoningDisclosure reasoning={reasoning} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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(', ')}.`,
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,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
|
||||
|
||||
Reference in New Issue
Block a user